bridge-index-kickoff-response.test.mjs
144 lines 5.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Unit tests for `lib/bridge-index-kickoff-response.mjs`.
3 *
4 * Why this file exists (regression context, May 2026):
5 * In the auto-routing PR (PR #205) the sync `bridge` function called
6 * `await fetch('/.netlify/functions/bridge-index-background', …)` and only
7 * awaited the promise — it did NOT inspect `response.status`. The bridge's
8 * `[[redirects]] from = "/*" force = true` rule turned out to capture
9 * `/.netlify/functions/*` paths too (Netlify's normal exemption is bypassed
10 * when `force = true`). The kickoff request was rewritten to the regular
11 * bridge function, returned 404, and `await fetch(…)` resolved successfully.
12 * The sync handler then returned `202 status:"background"` to the browser
13 * while the actual background function never ran. The user saw "Large
14 * re-index started" but the lock sat for 16 min and `setLastIndexedAt`
15 * never fired.
16 *
17 * The hotfix is two-pronged:
18 * 1. Fix the redirect (deploy/bridge/netlify.toml: add an explicit
19 * `/.netlify/functions/*` passthrough BEFORE the catch-all).
20 * 2. Defense in depth: assert that the kickoff actually got HTTP 202 from
21 * Netlify's background-function dispatcher. If anything else comes back
22 * (404 from a redirect, 5xx from a deploy gap), throw so the caller's
23 * catch handler can release the lock and surface the failure as 502.
24 *
25 * These tests lock in (2). They are pure — no network, no filesystem.
26 */
27
28 import test from 'node:test';
29 import assert from 'node:assert/strict';
30 import { assertBackgroundKickoffOk } from '../lib/bridge-index-kickoff-response.mjs';
31
32 test('assertBackgroundKickoffOk: accepts HTTP 202 (the only valid response from a Netlify background fn)', () => {
33 // Netlify always returns 202 within ~50–100 ms when a background function is
34 // successfully dispatched, regardless of how long the function body runs.
35 assert.doesNotThrow(() => assertBackgroundKickoffOk({ status: 202 }, ''));
36 });
37
38 test('assertBackgroundKickoffOk: throws on 404 (redirect captured the URL — the actual bug)', () => {
39 // This is the real-world failure mode the hotfix exists to detect: the
40 // catch-all redirect rewrote /.netlify/functions/bridge-index-background to
41 // the regular bridge function, which has no Express route for it and returned
42 // 404. Without this assert, the sync handler thought the kickoff succeeded.
43 assert.throws(
44 () => assertBackgroundKickoffOk({ status: 404 }, 'Cannot POST /bridge-index-background'),
45 /HTTP 404/,
46 );
47 });
48
49 test('assertBackgroundKickoffOk: throws on 5xx (Netlify deploy gap or runtime error)', () => {
50 assert.throws(
51 () => assertBackgroundKickoffOk({ status: 500 }, 'internal'),
52 /HTTP 500/,
53 );
54 assert.throws(
55 () => assertBackgroundKickoffOk({ status: 502 }, ''),
56 /HTTP 502/,
57 );
58 assert.throws(
59 () => assertBackgroundKickoffOk({ status: 503 }, ''),
60 /HTTP 503/,
61 );
62 });
63
64 test('assertBackgroundKickoffOk: throws on 200 (function returned synchronously — config wrong)', () => {
65 // If we ever see 200 from this URL it means Netlify did NOT recognize the
66 // function as a background function (e.g. someone removed the `-background`
67 // suffix or moved the file out of `netlify/functions/`). 200 plus an "OK"
68 // body would silently swallow the actual indexing work, so we reject it.
69 assert.throws(
70 () => assertBackgroundKickoffOk({ status: 200 }, 'OK'),
71 /HTTP 200/,
72 );
73 });
74
75 test('assertBackgroundKickoffOk: includes truncated body snippet in error for diagnostics', () => {
76 let caught;
77 try {
78 assertBackgroundKickoffOk(
79 { status: 404 },
80 'Cannot POST /bridge-index-background — no route matches',
81 );
82 } catch (err) {
83 caught = err;
84 }
85 assert.ok(caught, 'must throw');
86 assert.match(
87 caught.message,
88 /Cannot POST \/bridge-index-background/,
89 'error must surface the response body so logs show WHY the kickoff failed',
90 );
91 });
92
93 test('assertBackgroundKickoffOk: caps body snippet at 500 chars (avoid logging huge HTML pages)', () => {
94 // Netlify error responses can be multi-KB HTML pages; we don't want to dump
95 // them into Lambda logs (cost) or surface them in a JSON error body to the UI.
96 const huge = 'x'.repeat(2000);
97 let caught;
98 try {
99 assertBackgroundKickoffOk({ status: 500 }, huge);
100 } catch (err) {
101 caught = err;
102 }
103 assert.ok(caught, 'must throw');
104 assert.ok(
105 caught.message.length < 1000,
106 `error message should be truncated; was ${caught.message.length} chars`,
107 );
108 });
109
110 test('assertBackgroundKickoffOk: tolerates missing body (response.text() may have failed)', () => {
111 assert.throws(
112 () => assertBackgroundKickoffOk({ status: 404 }, undefined),
113 /HTTP 404/,
114 );
115 assert.throws(
116 () => assertBackgroundKickoffOk({ status: 404 }, null),
117 /HTTP 404/,
118 );
119 });
120
121 test('assertBackgroundKickoffOk: throws on null/undefined response (caller bug)', () => {
122 // A null response would be a programmer error in the caller (forgot to await
123 // fetch, or fetch threw and was swallowed). Better to surface than silently pass.
124 assert.throws(() => assertBackgroundKickoffOk(null, ''), /invalid response/);
125 assert.throws(() => assertBackgroundKickoffOk(undefined, ''), /invalid response/);
126 assert.throws(() => assertBackgroundKickoffOk({}, ''), /invalid response/);
127 });
128
129 test('assertBackgroundKickoffOk: error message mentions the function URL so log readers can grep', () => {
130 // Operators looking at Netlify logs need a fast way to know WHICH endpoint
131 // failed. Including the function name in the error ensures `rg` can find it.
132 let caught;
133 try {
134 assertBackgroundKickoffOk({ status: 404 }, '');
135 } catch (err) {
136 caught = err;
137 }
138 assert.ok(caught, 'must throw');
139 assert.match(
140 caught.message,
141 /bridge-index-background/,
142 'error must reference the background function name',
143 );
144 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago