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
2741import { describe , it , expect , vi } from 'vitest' ;
2842import { ApiErrorSchema , BaseResponseSchema , envelopeViolations } from '@objectstack/spec/api' ;
@@ -31,27 +45,30 @@ import { HttpDispatcher } from '../http-dispatcher.js';
3145import 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. */
102149function 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