|
63 | 63 | // benign and selective controls with it; a fix that only looked like it worked |
64 | 64 | // would leave the outage cases green. The two halves are in one file so a |
65 | 65 | // future edit that re-widens the catch is a diff nobody can read as harmless. |
| 66 | +// |
| 67 | +// --------------------------------------------------------------------------- |
| 68 | +// [#8924] The SECOND classified verdict: the request-arm 400 |
| 69 | +// --------------------------------------------------------------------------- |
| 70 | +// The same catch swallowed one more producer-classified error: the refusal |
| 71 | +// `canonicalizeMetaRequestType` raises inside `getMetaItems` (`status: 400`, |
| 72 | +// `code: 'INVALID_REQUEST'`) for an unrecognised spelling of a DECLARED type. |
| 73 | +// Only the `request.type` arm can reach it — the full-sweep arm's target set |
| 74 | +// comes canonical out of the registry. Measured on a booted kernel before the |
| 75 | +// fix: `GET /api/v1/meta/diagnostics?type=fieldes` answered |
| 76 | +// `200 {"entries":[],"total":0,"scannedTypes":1,"scannedItems":0,"stats":{}}` |
| 77 | +// — "scanned 1 type, no issues" — while the sibling door `/meta/fieldes` |
| 78 | +// refused the same spelling with the 400 that names both accepted spellings. |
| 79 | +// Ruled 2026-08-20 (option 1): rethrow the 400 the same way the 503 is |
| 80 | +// rethrown. The [#8924] describe below pins the refusal, the two spellings |
| 81 | +// that must KEEP answering (a recognised plural, a plural-of-nothing), and — |
| 82 | +// the positive control — that a genuine listing failure still skips even when |
| 83 | +// it wears an unrelated status, so the guard admits exactly the two |
| 84 | +// classified verdicts and never widens into a blanket throw. |
66 | 85 |
|
67 | 86 | import { describe, it, expect, vi } from 'vitest'; |
68 | 87 | import { ErrorCode } from '@objectstack/spec/api'; |
@@ -249,3 +268,84 @@ describe('[#8855] the discrimination is selective, not a blanket refusal', () => |
249 | 268 | expect(res.scannedTypes).toBe(SWEPT_TYPES.length); |
250 | 269 | }); |
251 | 270 | }); |
| 271 | + |
| 272 | +describe('[#8924] a caller error the producer classified is refused loudly, never published as "0 problems"', () => { |
| 273 | + /** A healthy engine — the only failure in play is the caller's spelling. */ |
| 274 | + function healthyEngine() { |
| 275 | + return { |
| 276 | + registry: emptyRegistry(), |
| 277 | + find: vi.fn(async () => []), |
| 278 | + findOne: vi.fn(async () => null), |
| 279 | + } as any; |
| 280 | + } |
| 281 | + |
| 282 | + it("an unrecognised spelling of a declared type rejects with the producer's 400, not 200-with-empty-stats", async () => { |
| 283 | + const p = new ObjectStackProtocolImplementation(healthyEngine()); |
| 284 | + |
| 285 | + // Before the fix this RESOLVED with |
| 286 | + // `{ entries: [], total: 0, scannedTypes: 1, scannedItems: 0, stats: {} }` |
| 287 | + // — the exact payload objectui's DiagnosticsPage renders as "All clear". |
| 288 | + const caught = await rejection(() => p.getMetaDiagnostics({ type: 'fieldes' })); |
| 289 | + |
| 290 | + // The ADR-0112 envelope is the contract: code + status, and the code |
| 291 | + // must be in the declared wire vocabulary. |
| 292 | + expect(caught?.status).toBe(400); |
| 293 | + expect(caught?.code).toBe('INVALID_REQUEST'); |
| 294 | + expect(ErrorCode.safeParse(caught?.code).success).toBe(true); |
| 295 | + // The refusal TEACHES: it names both accepted spellings at the moment |
| 296 | + // of the mistake. Asserted on top of code+status, never instead. |
| 297 | + expect(caught.message).toContain("'field'"); |
| 298 | + expect(caught.message).toContain("'fields'"); |
| 299 | + }); |
| 300 | + |
| 301 | + it('a recognised plural still answers — what is refused is the unrecognised spelling, not the plural form', async () => { |
| 302 | + const p = new ObjectStackProtocolImplementation(healthyEngine()); |
| 303 | + |
| 304 | + const res: any = await p.getMetaDiagnostics({ type: 'fields' }); |
| 305 | + expect(res.total).toBe(0); |
| 306 | + expect(res.scannedTypes).toBe(1); |
| 307 | + expect(res.stats.fields).toEqual({ count: 0, locked: 0, packages: [] }); |
| 308 | + }); |
| 309 | + |
| 310 | + it('a name that is a plural of NOTHING still answers benignly — the second verdict (#8421) lives on the mint door, not here', async () => { |
| 311 | + const p = new ObjectStackProtocolImplementation(healthyEngine()); |
| 312 | + |
| 313 | + // `metaUrlSpellingRefusal('fieldz')` is null: 'fieldz' reaches for no |
| 314 | + // declared type, so the request-boundary refusal deliberately stays |
| 315 | + // silent and the sweep answers an honest count-0 entry for it. |
| 316 | + const res: any = await p.getMetaDiagnostics({ type: 'fieldz' }); |
| 317 | + expect(res.total).toBe(0); |
| 318 | + expect(res.scannedTypes).toBe(1); |
| 319 | + expect(res.stats.fieldz).toEqual({ count: 0, locked: 0, packages: [] }); |
| 320 | + }); |
| 321 | + |
| 322 | + it('a genuine listing failure wearing an UNRELATED status still skips — the guard admits exactly the two classified verdicts', async () => { |
| 323 | + // The positive control for the fix itself, constructed deliberately |
| 324 | + // because the benign-skip population is empty in kernel scope (six |
| 325 | + // booted scopes, zero skips) — nothing exercises this by accident. |
| 326 | + // A widened guard (`if (status)` / rethrow-everything) turns every |
| 327 | + // benign skip into a sweep failure; this test is what goes red. |
| 328 | + const unlistable = SWEPT_TYPES.find((t) => t !== 'object')!; |
| 329 | + const engine = { |
| 330 | + registry: emptyRegistry({ |
| 331 | + listItems: (type: string) => { |
| 332 | + if (type === unlistable) { |
| 333 | + throw Object.assign( |
| 334 | + new Error(`type ${unlistable} is not listable in this kernel scope`), |
| 335 | + { status: 500 }, |
| 336 | + ); |
| 337 | + } |
| 338 | + return []; |
| 339 | + }, |
| 340 | + }), |
| 341 | + find: vi.fn(async () => []), |
| 342 | + findOne: vi.fn(async () => null), |
| 343 | + } as any; |
| 344 | + const p = new ObjectStackProtocolImplementation(engine); |
| 345 | + |
| 346 | + const res: any = await p.getMetaDiagnostics({}); |
| 347 | + expect(res.stats[unlistable]).toBeUndefined(); |
| 348 | + expect(Object.keys(res.stats)).toHaveLength(SWEPT_TYPES.length - 1); |
| 349 | + expect(res.stats.object).toEqual({ count: 0, locked: 0, packages: [] }); |
| 350 | + }); |
| 351 | +}); |
0 commit comments