@@ -394,3 +394,211 @@ describe('execution runs on the far side of the policy chain', () => {
394394 expect ( hint ) . toContain ( 'no execution wiring' ) ;
395395 } ) ;
396396} ) ;
397+
398+ /**
399+ * The mapping keys, joined to the chain (#5040 E5c / #5137).
400+ *
401+ * `api-mapping.test.ts` owns what a projection IS; what is asserted here is
402+ * where it applies — that a mapped body is what the executor delegates, that a
403+ * mapped result is what the caller receives, that an ERROR answer is never
404+ * remapped whatever produced it, and that a declaration this runtime cannot
405+ * serve is refused before the target runs rather than after.
406+ */
407+ describe ( 'the mapping keys apply on the two sides of the delegation' , ( ) => {
408+ const CREATE : ApiEndpoint = ApiEndpointSchema . parse ( {
409+ name : 'showcase_inquiries' ,
410+ path : '/api/v1/apps/showcase/inquiries' ,
411+ method : 'POST' ,
412+ type : 'object_operation' ,
413+ target : 'showcase_inquiry' ,
414+ objectParams : { object : 'showcase_inquiry' , operation : 'create' } ,
415+ authRequired : false ,
416+ } ) ;
417+
418+ const limiters = ( ) => createEndpointRateLimiterRegistry ( { resolveCache : async ( ) => undefined } ) ;
419+
420+ function callDataSpy ( result : unknown = { id : 'rec_1' , name : 'Ada' , internal_note : 'do not ship' } ) {
421+ const calls : unknown [ ] [ ] = [ ] ;
422+ return { calls, fn : async ( ...args : unknown [ ] ) => { calls . push ( args ) ; return result ; } } ;
423+ }
424+
425+ const mappedStep = (
426+ endpoint : ApiEndpoint ,
427+ callData : unknown ,
428+ body : unknown = { firstName : 'Ada' , secret : 'internal' } ,
429+ policy : Partial < EndpointPolicyContext > = { } ,
430+ ) => runAppEndpointStep ( {
431+ method : endpoint . method ,
432+ path : endpoint . path ,
433+ prefix : '/api/v1' ,
434+ metadataService : matcherFor ( [ endpoint ] ) . service as never ,
435+ policy : { limiters : limiters ( ) , ...policy } ,
436+ execution : {
437+ request : { method : endpoint . method , path : endpoint . path , query : { trace : '1' } , body } ,
438+ deps : { callData : callData as never } ,
439+ } ,
440+ } ) ;
441+
442+ it ( 'delegates the MAPPED body — the executor never sees the raw one' , async ( ) => {
443+ const spy = callDataSpy ( ) ;
444+ const mapped = ApiEndpointSchema . parse ( {
445+ ...CREATE ,
446+ inputMapping : [ { source : 'firstName' , target : 'first_name' } ] ,
447+ } ) ;
448+
449+ const answer = await mappedStep ( mapped , spy . fn ) ;
450+
451+ expect ( answer ?. status ) . toBe ( 201 ) ;
452+ // `data` is the projection: the renamed field is there and the
453+ // undeclared one is gone, delegated through the same `callData` shape
454+ // `/data` uses.
455+ expect ( spy . calls ) . toEqual ( [ [ 'create' , { object : 'showcase_inquiry' , data : { first_name : 'Ada' } } , undefined , undefined , undefined ] ] ) ;
456+ } ) ;
457+
458+ it ( 'leaves the query string alone — inputMapping maps the BODY' , async ( ) => {
459+ // The vocabulary says "Map Request Body to Internal Params"; query
460+ // parameters keep reaching the pipeline exactly as they did before.
461+ const spy = callDataSpy ( { records : [ ] , total : 0 } ) ;
462+ const find = ApiEndpointSchema . parse ( {
463+ ...CREATE ,
464+ name : 'showcase_find' ,
465+ method : 'GET' ,
466+ objectParams : { object : 'showcase_inquiry' , operation : 'find' } ,
467+ inputMapping : [ { source : 'firstName' , target : 'first_name' } ] ,
468+ } ) ;
469+
470+ await mappedStep ( find , spy . fn ) ;
471+
472+ expect ( ( spy . calls [ 0 ] ! [ 1 ] as { query : unknown } ) . query ) . toEqual ( { trace : '1' } ) ;
473+ } ) ;
474+
475+ it ( 'delegates the caller\'s own body when no mapping is declared' , async ( ) => {
476+ const spy = callDataSpy ( ) ;
477+ const body = { firstName : 'Ada' , secret : 'internal' } ;
478+
479+ await mappedStep ( CREATE , spy . fn , body ) ;
480+
481+ // By reference: an endpoint that declares no mapping is served exactly
482+ // as E5b served it, with no projection in between.
483+ expect ( ( spy . calls [ 0 ] ! [ 1 ] as { data : unknown } ) . data ) . toBe ( body ) ;
484+ } ) ;
485+
486+ it ( 'answers with the MAPPED result on a success' , async ( ) => {
487+ const spy = callDataSpy ( ) ;
488+ const mapped = ApiEndpointSchema . parse ( {
489+ ...CREATE ,
490+ outputMapping : [ { source : 'id' , target : 'inquiry_id' } , { source : 'name' , target : 'contact.name' } ] ,
491+ } ) ;
492+
493+ const answer = await mappedStep ( mapped , spy . fn ) ;
494+
495+ expect ( answer ?. status ) . toBe ( 201 ) ;
496+ expect ( answer ?. body ) . toEqual ( {
497+ success : true ,
498+ data : { inquiry_id : 'rec_1' , contact : { name : 'Ada' } } ,
499+ meta : undefined ,
500+ } ) ;
501+ // The allow-list property, end to end: an internal field the pipeline
502+ // returned and the declaration did not name never reaches the wire.
503+ expect ( JSON . stringify ( answer ?. body ) ) . not . toContain ( 'internal_note' ) ;
504+ } ) ;
505+
506+ it ( 'keeps the cacheTtl header on a mapped success' , async ( ) => {
507+ // `cacheTtl` is GET-only (#5040 §3.3), so this is a read endpoint: the
508+ // point is that the two keys compose — the projection replaces the body
509+ // and the policy verdict's header still rides with it.
510+ const mapped = ApiEndpointSchema . parse ( {
511+ ...CREATE ,
512+ name : 'showcase_cached_map' ,
513+ method : 'GET' ,
514+ objectParams : { object : 'showcase_inquiry' , operation : 'find' } ,
515+ cacheTtl : 30 ,
516+ outputMapping : [ { source : 'total' , target : 'count' } ] ,
517+ } ) ;
518+
519+ const answer = await mappedStep ( mapped , callDataSpy ( { records : [ ] , total : 2 } ) . fn ) ;
520+
521+ expect ( answer ?. body ) . toEqual ( { success : true , data : { count : 2 } , meta : undefined } ) ;
522+ expect ( answer ?. headers ) . toEqual ( { 'Cache-Control' : 'private, max-age=30' } ) ;
523+ } ) ;
524+
525+ it ( 'never remaps an ERROR answer — a mapping must not disguise a failure' , async ( ) => {
526+ const outputMapping = [ { source : 'id' , target : 'inquiry_id' } ] ;
527+
528+ // 401: denied by the policy chain, before execution.
529+ const authed = ApiEndpointSchema . parse ( { ...CREATE , name : 'showcase_authed' , authRequired : true , outputMapping } ) ;
530+ const denied = await mappedStep ( authed , callDataSpy ( ) . fn ) ;
531+ expect ( denied ?. status ) . toBe ( 401 ) ;
532+ expect ( ( denied ! . body as { error : { code : string } } ) . error . code ) . toBe ( 'UNAUTHENTICATED' ) ;
533+
534+ // 400: a delegated pipeline's own failure.
535+ const failing = ApiEndpointSchema . parse ( { ...CREATE , name : 'showcase_failing' , outputMapping } ) ;
536+ const bad = await mappedStep ( failing , async ( ) => { throw { statusCode : 400 , message : 'name is required' } ; } ) ;
537+ expect ( bad ?. status ) . toBe ( 400 ) ;
538+ expect ( ( bad ! . body as { error : { message : string } } ) . error . message ) . toBe ( 'name is required' ) ;
539+
540+ // 501: a declaration this runtime does not execute.
541+ const proxied = ApiEndpointSchema . parse ( {
542+ ...CREATE , name : 'showcase_proxy_map' , type : 'proxy' , target : 'https://example.invalid' , outputMapping,
543+ } ) ;
544+ const unsupported = await mappedStep ( proxied , callDataSpy ( ) . fn ) ;
545+ expect ( unsupported ?. status ) . toBe ( 501 ) ;
546+ expect ( ( unsupported ! . body as { error : { code : string } } ) . error . code ) . toBe ( 'NOT_IMPLEMENTED' ) ;
547+
548+ // 429: the endpoint budget, spent. Every one of these bodies is the
549+ // error envelope, untouched by the declared projection.
550+ const entries = new Map < string , unknown > ( ) ;
551+ const store : CounterStore = {
552+ get : async < T , > ( k : string ) => entries . get ( k ) as T | undefined ,
553+ set : async ( k : string , v : unknown ) => { entries . set ( k , v ) ; } ,
554+ } ;
555+ const limited = ApiEndpointSchema . parse ( {
556+ ...CREATE , name : 'showcase_limited_map' , outputMapping,
557+ rateLimit : { enabled : true , windowMs : 1_000 , maxRequests : 1 } ,
558+ } ) ;
559+ const policy = { limiters : createEndpointRateLimiterRegistry ( { resolveCache : async ( ) => store } ) } ;
560+ expect ( ( await mappedStep ( limited , callDataSpy ( ) . fn , undefined , policy ) ) ?. status ) . toBe ( 201 ) ;
561+ const over = await mappedStep ( limited , callDataSpy ( ) . fn , undefined , policy ) ;
562+ expect ( over ?. status ) . toBe ( 429 ) ;
563+
564+ for ( const answer of [ denied , bad , unsupported , over ] ) {
565+ expect ( JSON . stringify ( answer ?. body ) ) . not . toContain ( 'inquiry_id' ) ;
566+ expect ( ( answer ! . body as { success : boolean } ) . success ) . toBe ( false ) ;
567+ }
568+ } ) ;
569+
570+ it ( 'refuses a `transform` declaration at request time, without executing anything' , async ( ) => {
571+ const spy = callDataSpy ( ) ;
572+ const withTransform = ApiEndpointSchema . parse ( {
573+ ...CREATE ,
574+ inputMapping : [ { source : 'price' , target : 'amount' , transform : 'convertToInt' } ] ,
575+ } ) ;
576+
577+ const answer = await mappedStep ( withTransform , spy . fn ) ;
578+
579+ expect ( answer ?. status ) . toBe ( 501 ) ;
580+ const error = ( answer ! . body as { error : Record < string , unknown > } ) . error ;
581+ expect ( error . code ) . toBe ( 'NOT_IMPLEMENTED' ) ;
582+ expect ( String ( error . message ) ) . toContain ( 'inputMapping[0].transform' ) ;
583+ expect ( spy . calls , 'a refused declaration still reached the pipeline' ) . toEqual ( [ ] ) ;
584+ // No `Cache-Control` on a refusal, for the same reason as any error.
585+ expect ( answer ?. headers ) . toBeUndefined ( ) ;
586+ } ) ;
587+
588+ it ( 'refuses a broken outputMapping BEFORE the target runs, not after' , async ( ) => {
589+ // The ordering that matters: a `create` with an unservable projection
590+ // must not insert the record and then fail to answer with it.
591+ const spy = callDataSpy ( ) ;
592+ const broken = ApiEndpointSchema . parse ( {
593+ ...CREATE ,
594+ outputMapping : [ { source : 'id' , target : 'a' } , { source : 'name' , target : 'a.b' } ] ,
595+ } ) ;
596+
597+ const answer = await mappedStep ( broken , spy . fn ) ;
598+
599+ expect ( answer ?. status ) . toBe ( 501 ) ;
600+ expect ( String ( ( answer ! . body as { error : { message : string } } ) . error . message ) )
601+ . toContain ( 'outputMapping[1].target' ) ;
602+ expect ( spy . calls , 'the record was created and then the answer was refused' ) . toEqual ( [ ] ) ;
603+ } ) ;
604+ } ) ;
0 commit comments