Skip to content

Commit e38db3d

Browse files
hotlongclaude
andauthored
test(runtime): drive /data's success exit through the real ADR-0112 envelope (#7362) (#7997)
Converges the DomainHandlerDeps stand-in's success exit onto the same real HttpDispatcher the error exits (#6719) already use, and adds cases that read a real /data success response's body for success:true / data-nesting / meta -- the mirror half #6719 deliberately deferred. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40e8653 commit e38db3d

2 files changed

Lines changed: 129 additions & 14 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
test(runtime): drive `/data`'s success exit through the real ADR-0112 envelope (#7362)
6+
7+
Coverage only, no behaviour change. `data-path-object.test.ts`'s `DomainHandlerDeps`
8+
stand-in answered its success exit with `success: (data) => ({ status: 200, body: data
9+
})` — the domain's return value handed back AS the whole body, with no `success: true`
10+
flag, no `data` nesting, and no `meta` key, while production's
11+
`HttpDispatcher.success()` wraps all three. So a success-envelope regression could not
12+
go red in that harness.
13+
14+
This is the mirror half of #6719, which converged the same harness's three error exits
15+
(`error` / `routeNotFound` / `errorFromThrown`) onto the real `HttpDispatcher`. The
16+
success exit is now taken off the same real dispatcher (`success: domainDeps.success`),
17+
and two new cases drive a real `/data` success path through it and assert the envelope
18+
off the actual response body (`success: true`, the payload nested under `data`, the
19+
`meta` key) — not a hand-built object. Reverse-verified: restoring the old stand-in
20+
makes both new cases fail with `BaseResponseSchema.safeParse(body).success === false`,
21+
i.e. the envelope is absent, not a compile error.

packages/runtime/src/domains/data-path-object.test.ts

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,22 @@
2121
// harness was STRUCTURALLY incapable of going red on an envelope regression —
2222
// a wrong `error.code`, a dropped `httpStatus`, an unpromoted `details.code`, a
2323
// missing `success` — because none of those fields existed here to be wrong.
24-
// The exits are now taken off a real dispatcher (see `realErrorExits`), and the
24+
// The exits are now taken off a real dispatcher (see `realDomainDeps`), and the
2525
// cases at the bottom of this file drive the two error branches `/data` owns.
26+
//
27+
// [#7362] The mirror-image half of the same gap, on the SUCCESS exit. The
28+
// stand-in used to answer:
29+
//
30+
// success: (data: any) => ({ status: 200, body: data })
31+
//
32+
// — the domain's return value handed straight back AS the whole body. No
33+
// `success: true` flag, no `data` nesting, no `meta`, while production's
34+
// `HttpDispatcher.success()` wraps all three:
35+
// `{ status: 200, body: { success: true, data, meta } }`. `success` is now
36+
// taken off the same real dispatcher as the error exits (`realDomainDeps`),
37+
// and the `[#7362]` describe block below drives a real `/data` success path
38+
// through it and reads the envelope off the response body — not a hand-built
39+
// object — so a dropped `success`/`data`/`meta` can go red here too.
2640

2741
import { describe, it, expect, vi } from 'vitest';
2842
import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
@@ -31,27 +45,30 @@ import { HttpDispatcher } from '../http-dispatcher.js';
3145
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
3246

3347
/**
34-
* [#6719] The dispatcher's OWN `DomainHandlerDeps` error exits — not a
35-
* lookalike. `HttpDispatcher.domainDeps` is the exact object every `/data`
36-
* request runs against in production; it is borrowed off a real dispatcher
37-
* built over a kernel stub, exactly as `error-envelope.conformance.test.ts`'s
38-
* `makeDispatcher()` does (these branches never reach a service).
48+
* [#6719] [#7362] The dispatcher's OWN `DomainHandlerDeps` success + error
49+
* exits — not a lookalike. `HttpDispatcher.domainDeps` is the exact object
50+
* every `/data` request runs against in production; it is borrowed off a real
51+
* dispatcher built over a kernel stub, exactly as
52+
* `error-envelope.conformance.test.ts`'s `makeDispatcher()` does (these
53+
* branches never reach a service).
3954
*
40-
* Deliberately NOT `apiErrorResponse({ … })` re-expressed here: `error()` also
41-
* carries the #3867 5xx message-leak guard, and a restatement would make these
42-
* cases green against the restatement's rules instead of production's — the
43-
* same class of mistake as the hand-written double it replaces.
55+
* Deliberately NOT `apiErrorResponse({ … })` / `{ success: true, data }`
56+
* re-expressed here: `error()` also carries the #3867 5xx message-leak guard,
57+
* and a restatement would make these cases green against the restatement's
58+
* rules instead of production's — the same class of mistake as the
59+
* hand-written doubles this replaces.
4460
*
4561
* `routeNotFound` is unreachable from `handleDataRequest` today (the domain has
4662
* no route-resolution exit of its own) and `errorFromThrown` is applied one
4763
* layer up, by the dispatcher, to what this domain THROWS. Both are supplied
4864
* real anyway so a future `/data` branch is born conformant rather than
4965
* inheriting a stand-in.
5066
*/
51-
const realErrorExits = (() => {
67+
const realDomainDeps = (() => {
5268
const dispatcher: any = new HttpDispatcher({ context: { getService: () => null } } as any);
5369
const domainDeps: DomainHandlerDeps = dispatcher.domainDeps;
5470
return {
71+
success: domainDeps.success,
5572
error: domainDeps.error,
5673
routeNotFound: domainDeps.routeNotFound,
5774
errorFromThrown: domainDeps.errorFromThrown,
@@ -98,6 +115,36 @@ function expectDataErrorEnvelope(response: { status: number; body: any } | undef
98115
return body.error;
99116
}
100117

118+
/**
119+
* [#7362] Every assertion a `/data` SUCCESS body must satisfy, spelled once —
120+
* the mirror of `expectDataErrorEnvelope` above. Production's
121+
* `HttpDispatcher.success()` wraps the domain's return value as
122+
* `{ success: true, data, meta }`. The OLD stand-in
123+
* (`success: (data) => ({ status: 200, body: data })`) handed that same
124+
* return value back AS the whole body, so none of these assertions were ever
125+
* true of a case driven through it — `envelopeViolations` alone catches the
126+
* missing `success` flag and the un-nested payload; the explicit `data` /
127+
* `meta` checks below are what pins the shape on top of that.
128+
*/
129+
function expectDataSuccessEnvelope(response: { status: number; body: any } | undefined) {
130+
expect(response, 'branch produced no response').toBeTruthy();
131+
const body = response!.body;
132+
133+
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
134+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
135+
expect(body.success).toBe(true);
136+
expect(body.data).toBeDefined();
137+
// Production's `success()` always writes the `meta` key — `{ success:
138+
// true, data, meta }` — even when the caller passed no second argument,
139+
// which every `/data` call site does (`deps.success(result)`, one arg).
140+
// The key is therefore present but `undefined`-valued here; the OLD
141+
// stand-in never had the key at all, since it returned the payload AS
142+
// the body rather than wrapping it.
143+
expect(Object.prototype.hasOwnProperty.call(body, 'meta')).toBe(true);
144+
145+
return body.data;
146+
}
147+
101148
/** Records what the protocol service was asked for. */
102149
function setup(
103150
objectDefs: Record<string, any> = {},
@@ -120,9 +167,8 @@ function setup(
120167
getObjectQL: async () => engine,
121168
getRequestKernelService: async () => null,
122169
isMultiTenantHost: () => opts.multiTenantHost === true,
123-
success: (data: any) => ({ status: 200, body: data }),
124-
// [#6719] The REAL exits — see `realErrorExits`.
125-
...realErrorExits,
170+
// [#6719] [#7362] The REAL success + error exits — see `realDomainDeps`.
171+
...realDomainDeps,
126172
resolveActiveOrganizationId: async () => undefined,
127173
announceKernelEvent: async () => {},
128174
};
@@ -272,3 +318,51 @@ describe('[#6719] /data error exits answer in the ADR-0112 envelope', () => {
272318
expect(error.details).toBeUndefined();
273319
});
274320
});
321+
322+
/**
323+
* [#7362] The mirror-image half of the #6719 error-exit convergence: `/data`'s
324+
* SUCCESS exit, driven through a real `/data` success path (not a hand-built
325+
* object), reading the envelope off the actual response body.
326+
*
327+
* Every existing case above asserts through `findData`'s call arguments, not
328+
* the response body — none of them would flip if the success exit regressed.
329+
* These do: `expectDataSuccessEnvelope` reads the exact fields the old
330+
* stand-in dropped (`success: true`, the `data` nesting, the `meta` key) off
331+
* a response `handleDataRequest` actually produced.
332+
*/
333+
describe('[#7362] /data success exit answers in the production envelope', () => {
334+
it('a real query success is wrapped in success/data/meta, not returned bare', async () => {
335+
const { deps, context, findData } = setup({ crm_account: { apiEnabled: true } });
336+
const res: any = await post(deps, context, 'crm_account/query', { where: { status: 'open' } });
337+
338+
expect(res.handled).toBe(true);
339+
expect(res.response.status).toBe(200);
340+
const data = expectDataSuccessEnvelope(res.response);
341+
342+
// The domain's return value is NESTED under `data` — not spread as
343+
// the whole body, which is what the old stand-in produced.
344+
expect(data).toEqual({ object: 'crm_account', records: [], total: 0 });
345+
expect((res.response.body as any).object).toBeUndefined();
346+
expect((res.response.body as any).records).toBeUndefined();
347+
expect(findData).toHaveBeenCalledTimes(1);
348+
});
349+
350+
it('a create success (201) still answers in the envelope, status override included', async () => {
351+
// Distinct call site from every other branch: `domains/data.ts`
352+
// mutates the ALREADY-WRAPPED response's `status` to 201 rather than
353+
// building a fresh one — pin that the envelope survives the mutation.
354+
// The fake `protocol` only implements `findData`, so this goes
355+
// through the ObjectQL fallback (`ql.insert`), same pattern as the
356+
// record-miss case above (`setup({}, { engine })`).
357+
const engine = { insert: vi.fn(async (_object: string, data: any) => ({ id: 'rec_1', ...data })) };
358+
const { deps, context } = setup({ crm_account: { apiEnabled: true } }, { engine });
359+
const res: any = await handleDataRequest(deps, 'crm_account', 'POST', { name: 'Acme' }, {}, context);
360+
361+
expect(res.handled).toBe(true);
362+
expect(res.response.status).toBe(201);
363+
const data = expectDataSuccessEnvelope(res.response);
364+
365+
expect(data).toEqual({ object: 'crm_account', id: 'rec_1', record: { name: 'Acme', id: 'rec_1' } });
366+
expect(engine.insert).toHaveBeenCalledTimes(1);
367+
});
368+
});

0 commit comments

Comments
 (0)