|
27 | 27 |
|
28 | 28 | import { describe, it, expect, vi } from 'vitest'; |
29 | 29 | import { readFileSync } from 'node:fs'; |
30 | | -import { ApiErrorSchema, BaseResponseSchema, DispatcherErrorCode, envelopeViolations } from '@objectstack/spec/api'; |
| 30 | +import { |
| 31 | + ApiErrorSchema, |
| 32 | + BaseResponseSchema, |
| 33 | + DispatcherErrorCode, |
| 34 | + ErrorCode, |
| 35 | + envelopeViolations, |
| 36 | + standardErrorCodeForHttpStatus, |
| 37 | +} from '@objectstack/spec/api'; |
31 | 38 | import { HttpDispatcher } from './http-dispatcher.js'; |
32 | 39 | import { buildApiError, splitSemanticCode } from './error-envelope.js'; |
| 40 | +import { |
| 41 | + PENDING_AT_DISPATCHER_DOOR, |
| 42 | + PENDING_LEDGER_REGISTRATION, |
| 43 | + SANDBOX_AUTHORED_LIMB, |
| 44 | + UNREGISTERED_CODE_SITES, |
| 45 | +} from './dispatcher-error-vocabulary.js'; |
33 | 46 |
|
34 | 47 | /** Minimal kernel — these branches fail before any service is reached. */ |
35 | 48 | function makeDispatcher(kernel: any = { context: { getService: () => null } }) { |
@@ -193,6 +206,131 @@ describe('#3842 — every dispatcher error exit answers in the declared envelope |
193 | 206 | }); |
194 | 207 | }); |
195 | 208 |
|
| 209 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 210 | + * [#8087] Direction 3 — the vocabulary, not just the cases this file drives |
| 211 | + * |
| 212 | + * The suite above parses the bodies it DRIVES, which is how three suites came |
| 213 | + * to pin bodies `ApiErrorSchema` rejects without anything noticing: a |
| 214 | + * conformance suite can only ever cover the branches that existed the day it |
| 215 | + * was written. `check:dispatcher-error-vocabulary` derives the whole set of |
| 216 | + * codes this door can emit from source; this block drives every |
| 217 | + * dispatcher-reachable member of that derivation through the REAL builder and |
| 218 | + * parses the result. |
| 219 | + * |
| 220 | + * That is the "parse EVERY body it emits" half of the maintainer ruling |
| 221 | + * (2026-08-12, option B as a gate) — the gate finds the set, and this asserts |
| 222 | + * on it, so a producer added next month is driven here without anyone adding |
| 223 | + * a case for it. |
| 224 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 225 | +describe('#8087 — every code the dispatcher door can emit is parsed against ApiErrorSchema', () => { |
| 226 | + /** The real error path: a producer throw, resolved and built exactly as production does. */ |
| 227 | + const emitFor = (code: string, status: number) => |
| 228 | + (makeDispatcher() as any).errorFromThrown( |
| 229 | + Object.assign(new Error('a producer refused'), { code, status }), |
| 230 | + 500, |
| 231 | + ); |
| 232 | + |
| 233 | + it('drives a code from the derivation rather than a list written by hand', () => { |
| 234 | + // Guards the wiring itself: if the derivation ever produced nothing, |
| 235 | + // every per-code assertion below would vacuously pass and this suite |
| 236 | + // would go quiet exactly when it had the most to say. |
| 237 | + expect(PENDING_AT_DISPATCHER_DOOR.length).toBeGreaterThan(0); |
| 238 | + // Spread: both lists are `readonly string[]`, and `arrayContaining` |
| 239 | + // takes a mutable one — passing the frozen list straight in is a tsc |
| 240 | + // error that only the TEST_DEBT ratchet would have caught. |
| 241 | + expect(PENDING_LEDGER_REGISTRATION).toEqual(expect.arrayContaining([...PENDING_AT_DISPATCHER_DOOR])); |
| 242 | + }); |
| 243 | + |
| 244 | + for (const code of PENDING_AT_DISPATCHER_DOOR) { |
| 245 | + it(`'${code}' reaches the wire verbatim, and ApiErrorSchema rejects it on \`code\` alone`, () => { |
| 246 | + const response = emitFor(code, 500); |
| 247 | + |
| 248 | + // Verbatim — option B was ruled, so the door does NOT narrow. A |
| 249 | + // failure here means someone quietly implemented option A. |
| 250 | + expect(response.body.error.code).toBe(code); |
| 251 | + |
| 252 | + // The body is STRUCTURALLY conformant — right envelope, status |
| 253 | + // mirrored, `details` context only — so the one thing standing |
| 254 | + // between it and its declared schema is the missing ledger row. |
| 255 | + // That is precisely the claim handed to #8846. |
| 256 | + expect(envelopeViolations(response.body)).toEqual([]); |
| 257 | + expect(response.body.success).toBe(false); |
| 258 | + expect(response.body.error.httpStatus).toBe(response.status); |
| 259 | + |
| 260 | + const parsed = ApiErrorSchema.safeParse(response.body.error); |
| 261 | + expect(parsed.success).toBe(false); |
| 262 | + // Rejected on `code` and nothing else — an entry that failed for a |
| 263 | + // second reason would be a different defect wearing this one's label. |
| 264 | + expect([...new Set((parsed.error?.issues ?? []).map((i) => i.path.join('.')))]).toEqual(['code']); |
| 265 | + |
| 266 | + // MEASURED while writing this: the damage is not confined to the |
| 267 | + // nested error object. `BaseResponseSchema` embeds `ApiErrorSchema`, |
| 268 | + // so the WHOLE response body fails to parse — one unregistered |
| 269 | + // string invalidates the envelope every consumer validates against, |
| 270 | + // for the same single reason and no other. |
| 271 | + const envelope = BaseResponseSchema.safeParse(response.body); |
| 272 | + expect(envelope.success).toBe(false); |
| 273 | + expect([...new Set((envelope.error?.issues ?? []).map((i) => i.path.join('.')))]).toEqual(['error.code']); |
| 274 | + }); |
| 275 | + } |
| 276 | + |
| 277 | + it('the same drive with a REGISTERED code is fully conformant — the control', () => { |
| 278 | + // Without this, "ApiErrorSchema rejects it" above would also be |
| 279 | + // satisfied by a builder that emits a broken envelope for everything. |
| 280 | + const error = expectConformantError(emitFor('DATABASE_ERROR', 500)); |
| 281 | + expect(error.code).toBe('DATABASE_ERROR'); |
| 282 | + // And not merely the status-derived answer — 500 derives INTERNAL_ERROR, |
| 283 | + // so this proves the producer's code was carried, not invented. |
| 284 | + expect(standardErrorCodeForHttpStatus(500)).not.toBe('DATABASE_ERROR'); |
| 285 | + }); |
| 286 | + |
| 287 | + it('every pending code is still unregistered — the row comes out when #8846 lands', () => { |
| 288 | + // The ratchet's test-side half. When the spec lane registers one of |
| 289 | + // these, this goes red and the stale row must be deleted rather than |
| 290 | + // left promising work already done. |
| 291 | + for (const code of PENDING_LEDGER_REGISTRATION) { |
| 292 | + expect(ErrorCode.safeParse(code).success, `${code} is registered now — drop its row`).toBe(false); |
| 293 | + } |
| 294 | + }); |
| 295 | + |
| 296 | + it('the status-derived limb cannot produce an unregistered code, by construction', () => { |
| 297 | + // The third limb of `buildApiError`'s precedence needs no ledger row and |
| 298 | + // no gate: `standardErrorCodeForHttpStatus` returns a StandardErrorCode |
| 299 | + // for every input, so a branch that spells no code of its own is always |
| 300 | + // parseable. Asserted rather than assumed, across the bands the |
| 301 | + // dispatcher actually answers with. |
| 302 | + for (const status of [400, 401, 403, 404, 405, 409, 415, 422, 428, 429, 500, 501, 503, 504, 507]) { |
| 303 | + const derived = standardErrorCodeForHttpStatus(status); |
| 304 | + expect(ErrorCode.safeParse(derived).success, `${status} derived an unregistered ${derived}`).toBe(true); |
| 305 | + } |
| 306 | + }); |
| 307 | + |
| 308 | + it('records the sandbox limb as open rather than pretending the door is closed', () => { |
| 309 | + // `SandboxError` carries a metadata app's OWN `.code` across the QuickJS |
| 310 | + // boundary on purpose (#7867), and `domains/actions.ts` serves it through |
| 311 | + // `errorFromThrown`. So this door's vocabulary has a limb authored by |
| 312 | + // tenants at runtime, which no ledger can enumerate — the honest bound on |
| 313 | + // what "closed" can mean here, and the reason the witness below is NOT |
| 314 | + // re-spelled to a registered code. |
| 315 | + const witness = SANDBOX_AUTHORED_LIMB.witness; |
| 316 | + expect(ErrorCode.safeParse(witness).success).toBe(false); |
| 317 | + expect(emitFor(witness, 400).body.error.code).toBe(witness); |
| 318 | + // It is deliberately absent from the registration hand-off: registering |
| 319 | + // it would close nothing, since the next app picks a different string. |
| 320 | + expect(PENDING_LEDGER_REGISTRATION).not.toContain(witness); |
| 321 | + }); |
| 322 | + |
| 323 | + it('classifies every derived site — no verdict is left to a default', () => { |
| 324 | + for (const site of UNREGISTERED_CODE_SITES) { |
| 325 | + expect(site.why.length, `${site.code} at ${site.file} carries no evidence`).toBeGreaterThan(40); |
| 326 | + // A site that reaches a door must be on its way to a ledger row; |
| 327 | + // anything else must say which non-wire vocabulary it belongs to. |
| 328 | + if (site.door !== 'none') expect(site.verdict).toBe('pending-registration'); |
| 329 | + else expect(site.verdict).not.toBe('pending-registration'); |
| 330 | + } |
| 331 | + }); |
| 332 | +}); |
| 333 | + |
196 | 334 | describe('#3842 — buildApiError precedence', () => { |
197 | 335 | it('prefers an explicit code over a promoted one over a derived one', () => { |
198 | 336 | expect(buildApiError({ message: 'm', httpStatus: 403, code: 'EXPLICIT' }).code).toBe('EXPLICIT'); |
|
0 commit comments