Skip to content

Commit 222a528

Browse files
os-litantclaude
andauthored
test(rest): pin the /meta/mapping item shape on three producer paths (#16475)
`GET /api/v1/meta/mapping` serves each item as the raw spec document with `targetObject` at the TOP LEVEL, and the console's import wizard depends on that silently: `@object-ui/data-objectstack`'s `listImportMappings` filters items by a top-level `targetObject` and degrades every failure to an EMPTY LIST. `GetMetaItemsResponseSchema` declares `items` as `unknown[]`, so the spec states nothing about the per-item shape, and the consumer-side pin carries a copied fixture of today's body -- by construction it moves right or wrong together with this producer. A consumer that degrades to empty + a schema that declares nothing + no producer pin = a reading that cannot fail. This adds the producer's half, in the existing real-stack harness (real `RestServer` route table over a real `ObjectStackProtocolImplementation` on a real `ObjectQL` + sqlite `:memory:`), over THREE producer paths rather than one: a manifest `mappings:` entry through `registerApp`, a direct `registry.registerItem`, and `MetadataManager.register` installed as the `metadata` service -- the registrar the file-based artifact loader uses, and the one whose publish envelope (`name`/`packageId`/`state`/`metadata`) could realistically start reaching the wire. A pin over `registerApp` alone would be green forever precisely because `registerApp` is not the producer anyone would change. `boot()` gains an optional service-registry argument, because a `metadata` service is read through `getServicesRegistry()` and cannot be attached after the protocol is constructed. Omitted, it is byte-for-byte the previous boot. Whether `GetMetaItemsResponseSchema.items` should be narrowed from `unknown` is a spec decision and is deliberately NOT taken here. Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74628d9 commit 222a528

1 file changed

Lines changed: 149 additions & 2 deletions

File tree

packages/rest/src/import-integration.test.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2626
import { ObjectQL } from '@objectstack/objectql';
2727
import { SqlDriver } from '@objectstack/driver-sql';
2828
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
29+
import { MetadataManager } from '@objectstack/metadata';
2930
import { RestServer } from './rest-server';
3031
import { 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

Comments
 (0)