@@ -26,6 +26,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2626import { ObjectQL } from '@objectstack/objectql' ;
2727import { SqlDriver } from '@objectstack/driver-sql' ;
2828import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol' ;
29+ import { MetadataManager } from '@objectstack/metadata' ;
2930import { RestServer } from './rest-server' ;
3031import { loadExcelJs } from './xlsx-module.js' ;
3132
@@ -130,7 +131,15 @@ function makeRes() {
130131 return res ;
131132}
132133
133- async function boot ( ) {
134+ /**
135+ * @param services [#15907] the kernel's service registry, when the test needs
136+ * one installed. Only the `metadata` service producer path uses it — a
137+ * {@link MetadataManager} reaches `GET /meta/:type` through
138+ * `getServicesRegistry()`, so it cannot be attached after the protocol is
139+ * constructed. Omitted ⇒ no registry at all, which is what every other suite
140+ * in this file boots and what it booted before that path was pinned.
141+ */
142+ async function boot ( services ?: Map < string , any > ) {
134143 const engine = new ObjectQL ( ) ;
135144 liveEngines . push ( engine ) ;
136145 engine . registerDriver ( makeSqliteDriver ( ) , true ) ;
@@ -144,7 +153,10 @@ async function boot() {
144153 await engine . insert ( 'user' , { id : 'u1' , name : '张三' , email : 'zhang@x.com' } ) ;
145154 await engine . insert ( 'user' , { id : 'u2' , name : '李四' , email : 'li@x.com' } ) ;
146155
147- const protocol = new ObjectStackProtocolImplementation ( engine as any ) ;
156+ const protocol = new ObjectStackProtocolImplementation (
157+ engine as any ,
158+ services ? ( ) => services : undefined ,
159+ ) ;
148160 const rest = new RestServer ( createMockServer ( ) as any , protocol as any , { api : { requireAuth : false } } as any ) ;
149161 ( rest as any ) . resolveExecCtx = async ( ) => ( { userId : 'test-user' } ) ;
150162 rest . registerRoutes ( ) ;
@@ -676,3 +688,138 @@ describe('import + create routes — number `scale` enforcement (#7501)', () =>
676688 expect ( ( await engine . findOne ( 'member' , { where : { id : 'w5' } } ) ) ?. work_hours ) . toBe ( 8 ) ;
677689 } ) ;
678690} ) ;
691+
692+ // ---------------------------------------------------------------------------
693+ // #15907 — the PRODUCER-side pin on `GET /api/v1/meta/mapping`'s per-item shape.
694+ //
695+ // The console's import wizard FEATURE-DETECTS its saved-mapping selector:
696+ // `@object-ui/data-objectstack`'s `listImportMappings` keeps the items whose
697+ // TOP-LEVEL `targetObject` names the object being imported, and degrades every
698+ // failure — a throw, a refusal, a body it cannot read — to an EMPTY LIST. So a
699+ // producer that started serving the `MetadataManager` publish envelope
700+ // (`name` / `packageId` / `state` / `metadata`) instead of the document itself
701+ // would fail NOWHERE: the selector would simply stop appearing, on every
702+ // deployment, and a released feature would read downstream as a hardcoded
703+ // client — which is exactly how #14026 was raised.
704+ //
705+ // Nothing else covers it. `GetMetaItemsResponseSchema` declares `items` as
706+ // `unknown[]`, so the spec states nothing about the per-item shape; the
707+ // consumer-side pin (objectui#7738) carries a COPIED FIXTURE of today's body
708+ // and by construction moves right or wrong TOGETHER with this producer; and the
709+ // named-mapping suite above reads its artifact through `getMetaItem`, never
710+ // through the list door. A consumer that degrades to empty + a schema that
711+ // declares nothing + no producer pin = a reading that cannot fail. This block
712+ // is the producer's half of it.
713+ //
714+ // ⭐ THREE producer paths, and the third is the reason this is not one test.
715+ // A pin over `registerApp` alone would be green forever precisely because
716+ // `registerApp` is not the producer anyone would change. `MetadataManager`
717+ // — installed as the `metadata` service, the registrar the file-based artifact
718+ // loader uses — is the one whose envelope could realistically start reaching
719+ // the wire, so it is pinned here explicitly rather than assumed to travel with
720+ // the others. Measured: the three paths agree on the document but NOT on the
721+ // decorations — `registerApp` stamps `_packageId` + `_provenance` (it knows the
722+ // owning package), the other two carry `_diagnostics` alone — so "they all look
723+ // the same" is not a premise this block is entitled to.
724+ //
725+ // ⚠️ Scope: this asserts the two facts the consumer actually reads — a
726+ // TOP-LEVEL `targetObject`, and NO nested `metadata` member. Whether
727+ // `GetMetaItemsResponseSchema.items` should be narrowed from `unknown` is a
728+ // SPEC decision (narrowing a published declaration carries a manual floor) and
729+ // is deliberately not taken here.
730+ // ---------------------------------------------------------------------------
731+ describe ( 'GET /meta/mapping — items are raw documents, not publish envelopes (#15907)' , ( ) => {
732+ const MAPPING_TEMPLATE = {
733+ name : 'saved_task_feed' ,
734+ label : 'Saved task feed' ,
735+ sourceFormat : 'csv' ,
736+ targetObject : 'task' ,
737+ fieldMapping : [
738+ { source : 'ID' , target : 'id' , transform : 'none' } ,
739+ { source : 'Task Title' , target : 'title' , transform : 'none' } ,
740+ ] ,
741+ mode : 'upsert' ,
742+ upsertKey : [ 'id' ] ,
743+ } ;
744+
745+ // Every producer path DECORATES the document it was handed, IN PLACE, so each
746+ // registration gets its own copy. A shared literal carries one path's
747+ // `_packageId` into the next path's reading — measured while writing this
748+ // block, and it makes the weaker paths look like the stronger one.
749+ const mappingDoc = ( ) => JSON . parse ( JSON . stringify ( MAPPING_TEMPLATE ) ) ;
750+
751+ /**
752+ * The consumer's own predicate, replicated on this side of the wire:
753+ * `listImportMappings` keeps the items whose TOP-LEVEL `targetObject` names
754+ * the object. Spelled as a filter rather than an index lookup because the
755+ * filter is what makes a shape change SILENT downstream — it answers `[]`,
756+ * not an error — and therefore what has to be made loud here.
757+ */
758+ const asConsumerSees = ( items : unknown [ ] , object : string ) =>
759+ ( items ?? [ ] ) . filter ( ( it : any ) => it ?. targetObject === object ) ;
760+
761+ const listMapping = async ( rest : any ) => {
762+ const route = rest . getRoutes ( ) . find (
763+ ( r : any ) => r . method === 'GET' && r . path === '/api/v1/meta/:type' ,
764+ ) ;
765+ expect ( route ) . toBeDefined ( ) ;
766+ const res = makeRes ( ) ;
767+ // `headers` is not dressing — the conditional-GET branch reads
768+ // `req.headers['if-none-match']`.
769+ await route . handler (
770+ { method : 'GET' , params : { type : 'mapping' } , query : { } , body : { } , headers : { } } as any ,
771+ res ,
772+ ) ;
773+ return res ;
774+ } ;
775+
776+ /** The whole claim, applied identically to every producer path below. */
777+ const expectRawDocumentShape = ( res : any ) => {
778+ expect ( res . _status ?? 200 ) . toBeLessThan ( 400 ) ;
779+ expect ( res . _json ) . toMatchObject ( { type : 'mapping' } ) ;
780+ expect ( Array . isArray ( res . _json . items ) ) . toBe ( true ) ;
781+
782+ // What the console actually sees. A publish envelope has no top-level
783+ // `targetObject`, so this filter answers `[]` — the silent failure, made
784+ // into a red one.
785+ const visible = asConsumerSees ( res . _json . items , 'task' ) ;
786+ expect ( visible . map ( ( m : any ) => m . name ) ) . toEqual ( [ 'saved_task_feed' ] ) ;
787+
788+ const item : any = visible [ 0 ] ;
789+ // `targetObject` is TOP-LEVEL — not `item.metadata.targetObject`.
790+ expect ( item . targetObject ) . toBe ( 'task' ) ;
791+ // …and there is no nested `metadata` member it could have moved into. The
792+ // envelope this refuses is `{ name, packageId, state, metadata }`.
793+ expect ( Object . prototype . hasOwnProperty . call ( item , 'metadata' ) ) . toBe ( false ) ;
794+ // The rest of the document is served at the top level as well, so a
795+ // half-move (identity kept, body nested) cannot pass either.
796+ expect ( item . sourceFormat ) . toBe ( 'csv' ) ;
797+ expect ( Array . isArray ( item . fieldMapping ) ) . toBe ( true ) ;
798+ expect ( item . fieldMapping [ 0 ] ) . toMatchObject ( { source : 'ID' , target : 'id' } ) ;
799+ } ;
800+
801+ it ( 'path 1 — a manifest `mappings:` entry installed through `registerApp`' , async ( ) => {
802+ const { engine, rest } = await boot ( ) ;
803+ engine . registerApp ( { id : 'feed_pkg' , name : 'feed_pkg' , mappings : [ mappingDoc ( ) ] } ) ;
804+ expectRawDocumentShape ( await listMapping ( rest ) ) ;
805+ } ) ;
806+
807+ it ( 'path 2 — a direct `registry.registerItem`' , async ( ) => {
808+ const { engine, rest } = await boot ( ) ;
809+ engine . registry . registerItem ( 'mapping' , mappingDoc ( ) as any , 'name' ) ;
810+ expectRawDocumentShape ( await listMapping ( rest ) ) ;
811+ } ) ;
812+
813+ it ( 'path 3 — `MetadataManager.register`, installed as the `metadata` service' , async ( ) => {
814+ // The registrar the file-based artifact loader uses. It holds the document
815+ // under `(type, name)` and `list()` hands the document back; the publish
816+ // envelope this test refuses is the shape it would serve if that ever
817+ // became the thing it returns.
818+ const services = new Map < string , any > ( ) ;
819+ const metadata = new MetadataManager ( { formats : [ 'json' ] , loaders : [ ] } ) ;
820+ await metadata . register ( 'mapping' , MAPPING_TEMPLATE . name , mappingDoc ( ) ) ;
821+ services . set ( 'metadata' , metadata ) ;
822+ const { rest } = await boot ( services ) ;
823+ expectRawDocumentShape ( await listMapping ( rest ) ) ;
824+ } ) ;
825+ } ) ;
0 commit comments