@omg-dev/server 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
+ import { defineSchema, collection, fields, schemaToSQL, type Schema } from "@omg-dev/schema"
3
+ import { openDb, setDbInstance, markScoped, type VibesDb } from "../db.ts"
4
+ import { addClient, removeClient } from "../broker.ts"
5
+ import { buildAutoCrudRoutes } from "../auto-crud.ts"
6
+ import { handleRequest, type Route } from "../dispatcher.ts"
7
+ import { ctxStore } from "../ctx.ts"
8
+
9
+ // ── Fixture ──────────────────────────────────────────────────────────────────
10
+
11
+ function makeSchema(): Schema {
12
+ return defineSchema({
13
+ collections: {
14
+ tasks: collection({
15
+ fields: {
16
+ title: fields.string(),
17
+ done: fields.boolean(),
18
+ },
19
+ }).scoped("global"),
20
+ },
21
+ })
22
+ }
23
+
24
+ let db: VibesDb
25
+ let schema: Schema
26
+
27
+ beforeEach(() => {
28
+ schema = makeSchema()
29
+ db = openDb(":memory:")
30
+ setDbInstance(db)
31
+ for (const sql of schemaToSQL(schema)) db.raw().exec(sql)
32
+ })
33
+
34
+ afterEach(() => {
35
+ db.close()
36
+ })
37
+
38
+ // ── Route shape ──────────────────────────────────────────────────────────────
39
+
40
+ describe("buildAutoCrudRoutes — route shape", () => {
41
+ it("emits 5 routes per collection with correct verb/path", () => {
42
+ const routes = buildAutoCrudRoutes(schema)
43
+ expect(routes).toHaveLength(5)
44
+
45
+ const byKey = new Map(routes.map(r => [`${r.method} ${r.path}`, r]))
46
+ expect(byKey.has("GET /api/tasks")).toBe(true)
47
+ expect(byKey.has("GET /api/tasks/:id")).toBe(true)
48
+ expect(byKey.has("POST /api/tasks")).toBe(true)
49
+ expect(byKey.has("PATCH /api/tasks/:id")).toBe(true)
50
+ expect(byKey.has("DELETE /api/tasks/:id")).toBe(true)
51
+
52
+ expect(byKey.get("GET /api/tasks")!.handler).toBe("list")
53
+ expect(byKey.get("GET /api/tasks/:id")!.handler).toBe("get")
54
+ expect(byKey.get("POST /api/tasks")!.handler).toBe("create")
55
+ expect(byKey.get("PATCH /api/tasks/:id")!.handler).toBe("update")
56
+ expect(byKey.get("DELETE /api/tasks/:id")!.handler).toBe("remove")
57
+
58
+ for (const r of routes) {
59
+ expect(typeof r.inlineHandler).toBe("function")
60
+ }
61
+ })
62
+ })
63
+
64
+ // ── End-to-end ───────────────────────────────────────────────────────────────
65
+
66
+ describe("auto-crud — end-to-end via apiHandler", () => {
67
+ it("supports POST → GET → PATCH → DELETE → GET", async () => {
68
+ const routes = buildAutoCrudRoutes(schema)
69
+
70
+ // POST
71
+ const createRes = await handleRequest(
72
+ new Request("http://x/api/tasks", {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ title: "x", done: false }),
76
+ }),
77
+ routes,
78
+ )
79
+ expect(createRes.status).toBe(200)
80
+ const created = await createRes.json() as { id: string; title: string; done: boolean }
81
+ expect(typeof created.id).toBe("string")
82
+ expect(created.title).toBe("x")
83
+ expect(created.done).toBe(false)
84
+
85
+ // GET list
86
+ const listRes1 = await handleRequest(new Request("http://x/api/tasks"), routes)
87
+ expect(listRes1.status).toBe(200)
88
+ const list1 = await listRes1.json() as Array<{ id: string; done: boolean }>
89
+ expect(list1).toHaveLength(1)
90
+ expect(list1[0].done).toBe(false)
91
+
92
+ // PATCH
93
+ const patchRes = await handleRequest(
94
+ new Request(`http://x/api/tasks/${created.id}`, {
95
+ method: "PATCH",
96
+ headers: { "content-type": "application/json" },
97
+ body: JSON.stringify({ done: true }),
98
+ }),
99
+ routes,
100
+ )
101
+ expect(patchRes.status).toBe(200)
102
+ const patched = await patchRes.json() as { done: boolean }
103
+ expect(patched.done).toBe(true)
104
+
105
+ // GET list again — done flips
106
+ const listRes2 = await handleRequest(new Request("http://x/api/tasks"), routes)
107
+ const list2 = await listRes2.json() as Array<{ done: boolean }>
108
+ expect(list2[0].done).toBe(true)
109
+
110
+ // DELETE
111
+ const delRes = await handleRequest(
112
+ new Request(`http://x/api/tasks/${created.id}`, { method: "DELETE" }),
113
+ routes,
114
+ )
115
+ expect(delRes.status).toBe(204)
116
+
117
+ // GET list — empty
118
+ const listRes3 = await handleRequest(new Request("http://x/api/tasks"), routes)
119
+ const list3 = await listRes3.json() as unknown[]
120
+ expect(list3).toHaveLength(0)
121
+ })
122
+
123
+ it("PATCH returns 404 for unknown id", async () => {
124
+ const routes = buildAutoCrudRoutes(schema)
125
+ const res = await handleRequest(
126
+ new Request("http://x/api/tasks/nope", {
127
+ method: "PATCH",
128
+ headers: { "content-type": "application/json" },
129
+ body: JSON.stringify({ done: true }),
130
+ }),
131
+ routes,
132
+ )
133
+ expect(res.status).toBe(404)
134
+ })
135
+
136
+ it("PATCH rejects unknown columns", async () => {
137
+ const routes = buildAutoCrudRoutes(schema)
138
+ const createRes = await handleRequest(
139
+ new Request("http://x/api/tasks", {
140
+ method: "POST",
141
+ headers: { "content-type": "application/json" },
142
+ body: JSON.stringify({ title: "x", done: false }),
143
+ }),
144
+ routes,
145
+ )
146
+ const created = await createRes.json() as { id: string }
147
+ const res = await handleRequest(
148
+ new Request(`http://x/api/tasks/${created.id}`, {
149
+ method: "PATCH",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify({ malicious: 1 }),
152
+ }),
153
+ routes,
154
+ )
155
+ expect(res.status).toBe(400)
156
+ })
157
+ })
158
+
159
+ // ── Override precedence ──────────────────────────────────────────────────────
160
+
161
+ describe("auto-crud — function-file precedence", () => {
162
+ it("explicit route at same (method, path) shadows the auto-route", async () => {
163
+ // Simulate the merge done in createVibesServer: explicit first.
164
+ const explicit: Route = {
165
+ method: "POST",
166
+ path: "/api/tasks",
167
+ module: "<test>",
168
+ handler: "create",
169
+ inlineHandler: async () => Response.json({ from: "explicit" }, { status: 201 }),
170
+ }
171
+
172
+ const auto = buildAutoCrudRoutes(schema)
173
+ const explicitKeys = new Set([`${explicit.method} ${explicit.path}`])
174
+ const merged = [...auto.filter(r => !explicitKeys.has(`${r.method} ${r.path}`)), explicit]
175
+
176
+ const res = await handleRequest(
177
+ new Request("http://x/api/tasks", {
178
+ method: "POST",
179
+ headers: { "content-type": "application/json" },
180
+ body: JSON.stringify({ title: "x", done: false }),
181
+ }),
182
+ merged,
183
+ )
184
+ expect(res.status).toBe(201)
185
+ const body = await res.json() as { from: string }
186
+ expect(body.from).toBe("explicit")
187
+ })
188
+ })
189
+
190
+ // ── User-scoped collections ──────────────────────────────────────────────────
191
+
192
+ function makeScopedSchema(): Schema {
193
+ return defineSchema({
194
+ collections: {
195
+ notes: collection({
196
+ fields: {
197
+ title: fields.string(),
198
+ },
199
+ }).scoped("user"),
200
+ },
201
+ })
202
+ }
203
+
204
+ describe("auto-crud — scope:user", () => {
205
+ let scopedDb: VibesDb
206
+ let scopedSchema: Schema
207
+
208
+ beforeEach(() => {
209
+ scopedSchema = makeScopedSchema()
210
+ scopedDb = openDb(":memory:")
211
+ setDbInstance(scopedDb)
212
+ for (const sql of schemaToSQL(scopedSchema)) scopedDb.raw().exec(sql)
213
+ markScoped("notes")
214
+ })
215
+
216
+ afterEach(() => {
217
+ scopedDb.close()
218
+ })
219
+
220
+ function runAs(userId: string | null, fn: () => Promise<Response>): Promise<Response> {
221
+ return ctxStore.run({ userId }, fn)
222
+ }
223
+
224
+ it("create stamps _owner from ctx and lists filter by owner", async () => {
225
+ const routes = buildAutoCrudRoutes(scopedSchema)
226
+
227
+ const aRes = await runAs("user-a", () => handleRequest(
228
+ new Request("http://x/api/notes", {
229
+ method: "POST",
230
+ headers: { "content-type": "application/json" },
231
+ body: JSON.stringify({ title: "A's note" }),
232
+ }),
233
+ routes,
234
+ ))
235
+ expect(aRes.status).toBe(200)
236
+ const aRow = await aRes.json() as { id: string; _owner: string }
237
+ expect(aRow._owner).toBe("user-a")
238
+
239
+ await runAs("user-b", () => handleRequest(
240
+ new Request("http://x/api/notes", {
241
+ method: "POST",
242
+ headers: { "content-type": "application/json" },
243
+ body: JSON.stringify({ title: "B's note" }),
244
+ }),
245
+ routes,
246
+ ))
247
+
248
+ const aListRes = await runAs("user-a", () => handleRequest(
249
+ new Request("http://x/api/notes"),
250
+ routes,
251
+ ))
252
+ const aList = await aListRes.json() as Array<{ title: string }>
253
+ expect(aList).toHaveLength(1)
254
+ expect(aList[0].title).toBe("A's note")
255
+
256
+ const bListRes = await runAs("user-b", () => handleRequest(
257
+ new Request("http://x/api/notes"),
258
+ routes,
259
+ ))
260
+ const bList = await bListRes.json() as Array<{ title: string }>
261
+ expect(bList).toHaveLength(1)
262
+ expect(bList[0].title).toBe("B's note")
263
+ })
264
+
265
+ it("returns 401 when listing scoped collection anonymously", async () => {
266
+ const routes = buildAutoCrudRoutes(scopedSchema)
267
+ const res = await runAs(null, () => handleRequest(
268
+ new Request("http://x/api/notes"),
269
+ routes,
270
+ ))
271
+ expect(res.status).toBe(401)
272
+ })
273
+
274
+ it("update by another user returns 404 (no leak)", async () => {
275
+ const routes = buildAutoCrudRoutes(scopedSchema)
276
+ const aRes = await runAs("user-a", () => handleRequest(
277
+ new Request("http://x/api/notes", {
278
+ method: "POST",
279
+ headers: { "content-type": "application/json" },
280
+ body: JSON.stringify({ title: "A's secret" }),
281
+ }),
282
+ routes,
283
+ ))
284
+ const a = await aRes.json() as { id: string }
285
+
286
+ const patchRes = await runAs("user-b", () => handleRequest(
287
+ new Request(`http://x/api/notes/${a.id}`, {
288
+ method: "PATCH",
289
+ headers: { "content-type": "application/json" },
290
+ body: JSON.stringify({ title: "hacked" }),
291
+ }),
292
+ routes,
293
+ ))
294
+ expect(patchRes.status).toBe(404)
295
+
296
+ // Original is untouched
297
+ const getRes = await runAs("user-a", () => handleRequest(
298
+ new Request(`http://x/api/notes/${a.id}`),
299
+ routes,
300
+ ))
301
+ const got = await getRes.json() as { title: string }
302
+ expect(got.title).toBe("A's secret")
303
+ })
304
+
305
+ it("get returns 404 for another user's record", async () => {
306
+ const routes = buildAutoCrudRoutes(scopedSchema)
307
+ const aRes = await runAs("user-a", () => handleRequest(
308
+ new Request("http://x/api/notes", {
309
+ method: "POST",
310
+ headers: { "content-type": "application/json" },
311
+ body: JSON.stringify({ title: "private" }),
312
+ }),
313
+ routes,
314
+ ))
315
+ const a = await aRes.json() as { id: string }
316
+
317
+ const bRes = await runAs("user-b", () => handleRequest(
318
+ new Request(`http://x/api/notes/${a.id}`),
319
+ routes,
320
+ ))
321
+ expect(bRes.status).toBe(404)
322
+ })
323
+
324
+ it("create anonymously on scoped collection returns 401", async () => {
325
+ const routes = buildAutoCrudRoutes(scopedSchema)
326
+ const res = await runAs(null, () => handleRequest(
327
+ new Request("http://x/api/notes", {
328
+ method: "POST",
329
+ headers: { "content-type": "application/json" },
330
+ body: JSON.stringify({ title: "anon" }),
331
+ }),
332
+ routes,
333
+ ))
334
+ expect(res.status).toBe(401)
335
+ })
336
+ })
337
+
338
+ // ── Invalidation ─────────────────────────────────────────────────────────────
339
+
340
+ describe("auto-crud — invalidation", () => {
341
+ it("create/update/delete each emit invalidate(<collection>)", async () => {
342
+ const routes = buildAutoCrudRoutes(schema)
343
+ const messages: string[] = []
344
+ const client = { readyState: 1, send(d: string) { messages.push(d) } }
345
+ addClient(client)
346
+
347
+ try {
348
+ const createRes = await handleRequest(
349
+ new Request("http://x/api/tasks", {
350
+ method: "POST",
351
+ headers: { "content-type": "application/json" },
352
+ body: JSON.stringify({ title: "x", done: false }),
353
+ }),
354
+ routes,
355
+ )
356
+ const created = await createRes.json() as { id: string }
357
+ expect(messages).toHaveLength(1)
358
+ expect(JSON.parse(messages[0]).collection).toBe("tasks")
359
+
360
+ await handleRequest(
361
+ new Request(`http://x/api/tasks/${created.id}`, {
362
+ method: "PATCH",
363
+ headers: { "content-type": "application/json" },
364
+ body: JSON.stringify({ done: true }),
365
+ }),
366
+ routes,
367
+ )
368
+ expect(messages).toHaveLength(2)
369
+
370
+ await handleRequest(
371
+ new Request(`http://x/api/tasks/${created.id}`, { method: "DELETE" }),
372
+ routes,
373
+ )
374
+ expect(messages).toHaveLength(3)
375
+
376
+ for (const m of messages) {
377
+ const ev = JSON.parse(m)
378
+ expect(ev.type).toBe("invalidate")
379
+ expect(ev.collection).toBe("tasks")
380
+ }
381
+ } finally {
382
+ removeClient(client)
383
+ }
384
+ })
385
+ })
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect } from "bun:test"
2
+ import { handleRequest, handlerToRoute, type Route } from "../dispatcher.ts"
3
+ import { VibesAuthRequiredError } from "../db.ts"
4
+ import { VibesHttpError } from "../http-error.ts"
5
+
6
+ // ── Response passthrough ─────────────────────────────────────────────────────
7
+ //
8
+ // Streaming handlers (e.g. streamText(...).toTextStreamResponse()) rely on
9
+ // the dispatcher returning the Response untouched. Wrapping it via
10
+ // Response.json would coerce the streaming body to "{}" / a JSON snapshot
11
+ // and break SSE/text-stream consumers in the browser.
12
+
13
+ describe("handleRequest — Response passthrough", () => {
14
+ it("returns the handler's Response object verbatim when one is returned", async () => {
15
+ const stream = new ReadableStream<Uint8Array>({
16
+ start(controller) {
17
+ controller.enqueue(new TextEncoder().encode("hello "))
18
+ controller.enqueue(new TextEncoder().encode("world"))
19
+ controller.close()
20
+ },
21
+ })
22
+
23
+ const route: Route = {
24
+ method: "POST",
25
+ path: "/api/echo",
26
+ module: "inline",
27
+ handler: "create",
28
+ mod: {
29
+ create: async () =>
30
+ new Response(stream, {
31
+ status: 201,
32
+ headers: { "Content-Type": "text/plain", "X-Custom": "ok" },
33
+ }),
34
+ },
35
+ }
36
+
37
+ const res = await handleRequest(
38
+ new Request("http://x/api/echo", {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify({}),
42
+ }),
43
+ [route],
44
+ )
45
+
46
+ expect(res.status).toBe(201)
47
+ expect(res.headers.get("X-Custom")).toBe("ok")
48
+ expect(res.headers.get("Content-Type")).toContain("text/plain")
49
+ expect(await res.text()).toBe("hello world")
50
+ })
51
+
52
+ it("still wraps non-Response values via Response.json (CRUD shape unchanged)", async () => {
53
+ const route: Route = {
54
+ method: "GET",
55
+ path: "/api/things",
56
+ module: "inline",
57
+ handler: "list",
58
+ style: "crud",
59
+ mod: {
60
+ list: async () => [{ id: "1" }, { id: "2" }],
61
+ },
62
+ }
63
+
64
+ const res = await handleRequest(new Request("http://x/api/things"), [route])
65
+ expect(res.status).toBe(200)
66
+ expect(res.headers.get("Content-Type")).toContain("application/json")
67
+ expect(await res.json()).toEqual([{ id: "1" }, { id: "2" }])
68
+ })
69
+ })
70
+
71
+ // ── Method-style handlers ────────────────────────────────────────────────────
72
+ //
73
+ // Handlers exported as HTTP-method names (POST, GET, ...) receive the raw
74
+ // Request and any path params. The dispatcher must NOT pre-parse the body
75
+ // — the handler may read a streaming body, inspect headers, etc.
76
+
77
+ describe("handleRequest — method-style routes", () => {
78
+ it("invokes a POST handler with the raw Request and returns its Response", async () => {
79
+ let receivedReq: Request | null = null
80
+ let receivedParams: Record<string, string> | null = null
81
+
82
+ const route: Route = {
83
+ method: "POST",
84
+ path: "/api/chat",
85
+ module: "inline",
86
+ handler: "POST",
87
+ style: "method",
88
+ mod: {
89
+ POST: async (req: Request, params: Record<string, string>) => {
90
+ receivedReq = req
91
+ receivedParams = params
92
+ const body = await req.json() as { messages: string[] }
93
+ return new Response(`got ${body.messages.length} msgs`, {
94
+ headers: { "Content-Type": "text/plain" },
95
+ })
96
+ },
97
+ },
98
+ }
99
+
100
+ const res = await handleRequest(
101
+ new Request("http://x/api/chat", {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json" },
104
+ body: JSON.stringify({ messages: ["a", "b", "c"] }),
105
+ }),
106
+ [route],
107
+ )
108
+ expect(res.status).toBe(200)
109
+ expect(await res.text()).toBe("got 3 msgs")
110
+ expect(receivedReq).toBeInstanceOf(Request)
111
+ expect(receivedParams).toEqual({})
112
+ })
113
+
114
+ it("handlerToRoute classifies HTTP-verb names as method-style", () => {
115
+ const r = handlerToRoute("chat", "POST", "/x/chat.ts")
116
+ expect(r.method).toBe("POST")
117
+ expect(r.path).toBe("/api/chat")
118
+ expect(r.style).toBe("method")
119
+ })
120
+
121
+ it("handlerToRoute classifies CRUD names with style=crud", () => {
122
+ const r = handlerToRoute("entries", "list", "/x/entries.ts")
123
+ expect(r.method).toBe("GET")
124
+ expect(r.path).toBe("/api/entries")
125
+ expect(r.style).toBe("crud")
126
+ })
127
+ })
128
+
129
+ // ── VibesAuthRequiredError → 401 mapping ─────────────────────────────────────
130
+ //
131
+ // User-authored handlers (and our own auto-crud paths) can throw
132
+ // VibesAuthRequiredError when an unauthenticated request reaches a scoped
133
+ // table operation. The dispatcher must map that to a 401, NOT a 500 — the
134
+ // 500 path would still deny but mislabel the cause in dashboards and
135
+ // surface a confusing "Internal server error" payload to the client.
136
+
137
+ describe("VibesAuthRequiredError → 401", () => {
138
+ function makeAuthThrowingRoutes(): Route[] {
139
+ return [
140
+ {
141
+ method: "POST",
142
+ path: "/api/inline",
143
+ module: "inline",
144
+ handler: "_",
145
+ inlineHandler: async () => {
146
+ throw new VibesAuthRequiredError("notes")
147
+ },
148
+ },
149
+ {
150
+ method: "POST",
151
+ path: "/api/method",
152
+ module: "inline",
153
+ handler: "POST",
154
+ style: "method",
155
+ mod: {
156
+ POST: async () => {
157
+ throw new VibesAuthRequiredError("notes")
158
+ },
159
+ },
160
+ },
161
+ {
162
+ method: "GET",
163
+ path: "/api/crud",
164
+ module: "inline",
165
+ handler: "list",
166
+ style: "crud",
167
+ mod: {
168
+ list: async () => {
169
+ throw new VibesAuthRequiredError("notes")
170
+ },
171
+ },
172
+ },
173
+ ]
174
+ }
175
+
176
+ it("inline handler throwing VibesAuthRequiredError yields a 401", async () => {
177
+ const res = await handleRequest(
178
+ new Request("http://x/api/inline", { method: "POST" }),
179
+ makeAuthThrowingRoutes(),
180
+ )
181
+ expect(res.status).toBe(401)
182
+ const body = await res.json() as { error: string }
183
+ expect(body.error).toContain("requires an authenticated user")
184
+ })
185
+
186
+ it("method-style handler throwing VibesAuthRequiredError yields a 401", async () => {
187
+ const res = await handleRequest(
188
+ new Request("http://x/api/method", { method: "POST" }),
189
+ makeAuthThrowingRoutes(),
190
+ )
191
+ expect(res.status).toBe(401)
192
+ })
193
+
194
+ it("crud handler throwing VibesAuthRequiredError yields a 401", async () => {
195
+ const res = await handleRequest(
196
+ new Request("http://x/api/crud"),
197
+ makeAuthThrowingRoutes(),
198
+ )
199
+ expect(res.status).toBe(401)
200
+ })
201
+
202
+ it("other errors still yield a 500 (regression: don't over-eagerly map)", async () => {
203
+ const routes: Route[] = [{
204
+ method: "GET",
205
+ path: "/api/oops",
206
+ module: "inline",
207
+ handler: "list",
208
+ style: "crud",
209
+ mod: { list: async () => { throw new Error("boom") } },
210
+ }]
211
+ const res = await handleRequest(new Request("http://x/api/oops"), routes)
212
+ expect(res.status).toBe(500)
213
+ })
214
+ })
215
+
216
+ // ── VibesHttpError → its own status ──────────────────────────────────────────
217
+ //
218
+ // Expected refusals (ownership checks, proxied upstream 4xx like the
219
+ // orchestrator's 403 "not your deploy") carry an explicit status. The
220
+ // dispatcher must return that status with the message — NOT a 500 — so the
221
+ // client sees the refusal instead of "Internal server error" and the logs
222
+ // don't record a handler crash for a request that was correctly denied.
223
+
224
+ describe("VibesHttpError → explicit status", () => {
225
+ function routesThrowing(err: Error): Route[] {
226
+ return [{
227
+ method: "GET",
228
+ path: "/api/guarded",
229
+ module: "inline",
230
+ handler: "list",
231
+ style: "crud",
232
+ mod: { list: async () => { throw err } },
233
+ }]
234
+ }
235
+
236
+ it("a 403 VibesHttpError keeps its status and message", async () => {
237
+ const res = await handleRequest(
238
+ new Request("http://x/api/guarded"),
239
+ routesThrowing(new VibesHttpError(403, "not your deploy")),
240
+ )
241
+ expect(res.status).toBe(403)
242
+ const body = await res.json() as { error: string }
243
+ expect(body.error).toBe("not your deploy")
244
+ })
245
+
246
+ it("a 404 VibesHttpError keeps its status", async () => {
247
+ const res = await handleRequest(
248
+ new Request("http://x/api/guarded"),
249
+ routesThrowing(new VibesHttpError(404, "no such thing")),
250
+ )
251
+ expect(res.status).toBe(404)
252
+ })
253
+
254
+ it("a 502 VibesHttpError (upstream outage) keeps its status", async () => {
255
+ const res = await handleRequest(
256
+ new Request("http://x/api/guarded"),
257
+ routesThrowing(new VibesHttpError(502, "Infra API 524")),
258
+ )
259
+ expect(res.status).toBe(502)
260
+ const body = await res.json() as { error: string }
261
+ expect(body.error).toBe("Infra API 524")
262
+ })
263
+
264
+ it("a plain Error is still a 500", async () => {
265
+ const res = await handleRequest(
266
+ new Request("http://x/api/guarded"),
267
+ routesThrowing(new Error("vibes-infra GET /v1/sandboxes/x: HTTP 524")),
268
+ )
269
+ expect(res.status).toBe(500)
270
+ })
271
+ })