|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #8422 — the stdio bridge's by-id write seams must throw the repo's ONE |
| 5 | + * not-found envelope (`recordNotFoundError`, `@objectstack/core`), not a |
| 6 | + * locally minted `Error`. |
| 7 | + * |
| 8 | + * The HTTP bridge routes its data verbs through `callData`, which throws |
| 9 | + * `recordNotFoundError` — `code: 'RECORD_NOT_FOUND'`, `status: 404` |
| 10 | + * (`packages/core/src/utils/record-not-found.ts`, #4435/#5138/#7867). The |
| 11 | + * stdio bridge minted its own bare `Error` for the identical miss, so a |
| 12 | + * stdio caller got a message with no machine-readable code and nothing that |
| 13 | + * maps to 404 — the same operation, two different envelopes depending on |
| 14 | + * which transport served it. |
| 15 | + * |
| 16 | + * Both by-id write seams are covered — `update()` and `remove()` — asserting |
| 17 | + * on `code` AND `status`, not merely that something threw: an assertion that |
| 18 | + * only checks for a thrown error would have passed against the bare `Error` |
| 19 | + * this card exists to remove. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, vi } from 'vitest'; |
| 23 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 24 | +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; |
| 25 | +import { createStdioDataBridge } from './stdio-data-bridge.js'; |
| 26 | + |
| 27 | +/** An engine that resolves NO row for any id — every by-id write is a miss. */ |
| 28 | +function makeEmptyEngine() { |
| 29 | + return { |
| 30 | + find: vi.fn(async () => []), |
| 31 | + findOne: vi.fn(async () => null), |
| 32 | + insert: vi.fn(), |
| 33 | + update: vi.fn(), |
| 34 | + delete: vi.fn(), |
| 35 | + count: vi.fn(async () => 0), |
| 36 | + aggregate: vi.fn(async () => [{ n: 0 }]), |
| 37 | + }; |
| 38 | +} |
| 39 | + |
| 40 | +/** An object definition with no exposure restriction, so the miss is what refuses. */ |
| 41 | +function makeMetadata() { |
| 42 | + return { |
| 43 | + listObjects: vi.fn(async () => [{ name: 'task', label: 'Task', fields: {} }]), |
| 44 | + getObject: vi.fn(async () => ({ name: 'task', label: 'Task', enable: { apiEnabled: true } })), |
| 45 | + get: vi.fn(async () => null), |
| 46 | + list: vi.fn(async () => []), |
| 47 | + exists: vi.fn(async () => true), |
| 48 | + getRegisteredTypes: vi.fn(async () => ['object']), |
| 49 | + register: vi.fn(), |
| 50 | + unregister: vi.fn(), |
| 51 | + }; |
| 52 | +} |
| 53 | + |
| 54 | +const PRINCIPAL = { userId: 'u1', isSystem: false } as unknown as ExecutionContext; |
| 55 | + |
| 56 | +function makeBridge() { |
| 57 | + const engine = makeEmptyEngine(); |
| 58 | + const metadataService = makeMetadata(); |
| 59 | + const bridge = createStdioDataBridge({ |
| 60 | + engine: engine as unknown as IDataEngine, |
| 61 | + metadataService: metadataService as unknown as IMetadataService, |
| 62 | + resolvePrincipal: async () => PRINCIPAL, |
| 63 | + }); |
| 64 | + return { bridge, engine, metadataService }; |
| 65 | +} |
| 66 | + |
| 67 | +/** A rejection's payload, narrowed once so callers never juggle `unknown`. */ |
| 68 | +type NotFoundEnvelope = Error & { code?: string; status?: number }; |
| 69 | + |
| 70 | +/** |
| 71 | + * The rejection `run` throws, typed — or `null` if it resolved. One explicit |
| 72 | + * cast, shared, rather than `.catch((e) => e)` at each call site: that idiom |
| 73 | + * infers the settled value as `unknown` here (TResult from an `any`-typed |
| 74 | + * catch parameter widens to `unknown`), which is a second, unrelated way to |
| 75 | + * fail `tsc` — this repo's TEST_DEBT ledger already carries 53 raw errors for |
| 76 | + * `packages/mcp` from the *other* half of that idiom (`await res.json()`), |
| 77 | + * and a fix for this card must not add a fourth without narrowing it. |
| 78 | + */ |
| 79 | +async function catchError(run: () => Promise<unknown>): Promise<NotFoundEnvelope | null> { |
| 80 | + return (await run().then( |
| 81 | + () => null, |
| 82 | + (e: unknown) => e, |
| 83 | + )) as NotFoundEnvelope | null; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Assert a not-found refusal by its ENVELOPE, not by the fact that something |
| 88 | + * threw — a bare `Error` also satisfies `.toThrow()`, which is exactly the |
| 89 | + * defect this card removes. |
| 90 | + */ |
| 91 | +async function expectRecordNotFound(run: () => Promise<unknown>): Promise<void> { |
| 92 | + const err = await catchError(run); |
| 93 | + expect(err, 'the call resolved — no not-found refusal was raised').toBeTruthy(); |
| 94 | + expect(err!.code).toBe('RECORD_NOT_FOUND'); |
| 95 | + expect(err!.status).toBe(404); |
| 96 | +} |
| 97 | + |
| 98 | +describe('#8422 stdio bridge by-id writes throw the shared not-found envelope', () => { |
| 99 | + it('update() on a missing id throws RECORD_NOT_FOUND / 404', async () => { |
| 100 | + const { bridge, engine } = makeBridge(); |
| 101 | + |
| 102 | + await expectRecordNotFound(() => bridge.update('task', 'ghost', { title: 'x' })); |
| 103 | + // Refused before the write dispatched — the same existence-before-mutation |
| 104 | + // property the HTTP path (`callData`) holds. |
| 105 | + expect(engine.update).not.toHaveBeenCalled(); |
| 106 | + }); |
| 107 | + |
| 108 | + it('remove() on a missing id throws RECORD_NOT_FOUND / 404', async () => { |
| 109 | + const { bridge, engine } = makeBridge(); |
| 110 | + |
| 111 | + await expectRecordNotFound(() => bridge.remove('task', 'ghost')); |
| 112 | + expect(engine.delete).not.toHaveBeenCalled(); |
| 113 | + }); |
| 114 | + |
| 115 | + it('both seams throw the SAME envelope shape — one declaration, not two', async () => { |
| 116 | + const { bridge } = makeBridge(); |
| 117 | + |
| 118 | + const updateErr = await catchError(() => bridge.update('task', 'ghost', { title: 'x' })); |
| 119 | + const removeErr = await catchError(() => bridge.remove('task', 'ghost')); |
| 120 | + |
| 121 | + expect(updateErr?.code).toBe(removeErr?.code); |
| 122 | + expect(updateErr?.status).toBe(removeErr?.status); |
| 123 | + expect(updateErr?.code).toBe('RECORD_NOT_FOUND'); |
| 124 | + }); |
| 125 | +}); |
0 commit comments