|
29 | 29 | */ |
30 | 30 |
|
31 | 31 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
32 | | -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; |
| 32 | +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, hashSpec } from '@objectstack/metadata-core'; |
33 | 33 | import { ObjectStackProtocolImplementation } from './protocol.js'; |
34 | 34 |
|
35 | 35 | /** The LIVE object universe: a deal with a formula field, and its account. */ |
@@ -974,3 +974,145 @@ describe('[#29007] a stored object carrying the retired titleFormat', () => { |
974 | 974 | expect(saved.success).toBe(true); |
975 | 975 | }); |
976 | 976 | }); |
| 977 | + |
| 978 | +/** |
| 979 | + * [#29557] A flow draft stored in a legacy ADR-0087 D2 spelling the engine |
| 980 | + * registers (`get_record` `config.filters`, `flow-node-crud-filter-alias`). The |
| 981 | + * save door judges a flow on the canonicalizer's fallback in its D2-converted |
| 982 | + * spelling and stores the raw body (#21982), so the draft saves; both publish |
| 983 | + * doors and the draft read judge the stored draft through the same seam, so it |
| 984 | + * publishes, and what goes live is the stored bytes. |
| 985 | + * |
| 986 | + * The draft is the realistic one: a stored active row carries the spelling (a |
| 987 | + * row at rest), and the author's draft re-save of it carries the same hit, so |
| 988 | + * the save replays the full chain (#23903 ruling B'). This harness reaches no |
| 989 | + * automation service, so every save takes the fallback. |
| 990 | + */ |
| 991 | +describe('[#29557] a stored flow draft in a legacy D2 spelling the engine registers', () => { |
| 992 | + let warn: ReturnType<typeof vi.spyOn>; |
| 993 | + beforeEach(() => { |
| 994 | + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 995 | + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; |
| 996 | + }); |
| 997 | + afterEach(() => { |
| 998 | + warn.mockRestore(); |
| 999 | + }); |
| 1000 | + |
| 1001 | + /** start → get_record → end, the match map spelled under `filterKey`. */ |
| 1002 | + const fetchFlow = (name: string, filterKey: 'filters' | 'filter') => ({ |
| 1003 | + name, |
| 1004 | + label: name, |
| 1005 | + type: 'autolaunched', |
| 1006 | + nodes: [ |
| 1007 | + { id: 'start', type: 'start', label: 'Start' }, |
| 1008 | + { id: 'fetch', type: 'get_record', label: 'Fetch', config: { objectName: 'deal', [filterKey]: { name: 'Acme' }, outputVariable: 'deal' } }, |
| 1009 | + { id: 'end', type: 'end', label: 'End' }, |
| 1010 | + ], |
| 1011 | + edges: [ |
| 1012 | + { id: 'e1', source: 'start', target: 'fetch' }, |
| 1013 | + { id: 'e2', source: 'fetch', target: 'end' }, |
| 1014 | + ], |
| 1015 | + }); |
| 1016 | + |
| 1017 | + /** The stored active row a legacy draft is a re-save of. */ |
| 1018 | + async function seedActive(engine: any, body: { name: string }) { |
| 1019 | + await engine.insert('sys_metadata', { |
| 1020 | + type: 'flow', |
| 1021 | + name: body.name, |
| 1022 | + organization_id: null, |
| 1023 | + package_id: PKG, |
| 1024 | + state: 'active', |
| 1025 | + metadata: JSON.stringify(body), |
| 1026 | + checksum: hashSpec(body, 'flow'), |
| 1027 | + version: 1, |
| 1028 | + }); |
| 1029 | + } |
| 1030 | + |
| 1031 | + const storedConfig = (rows: Map<string, Row>, name: string, state: 'active' | 'draft') => { |
| 1032 | + const row = [...rows.values()].find((r) => r.type === 'flow' && r.name === name && r.state === state); |
| 1033 | + return row ? JSON.parse(row.metadata).nodes[1].config : undefined; |
| 1034 | + }; |
| 1035 | + |
| 1036 | + async function stageLegacy(name: string, body = fetchFlow(name, 'filters')) { |
| 1037 | + const { engine, rows } = makeStubEngine(); |
| 1038 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 1039 | + await seedActive(engine, body); |
| 1040 | + const saved = await stageDraft(protocol, 'flow', body); |
| 1041 | + expect(saved).toMatchObject({ success: true, state: 'draft' }); |
| 1042 | + // Stored as written: the conversion is the verdict's, never the row's. |
| 1043 | + expect(storedConfig(rows, name, 'draft')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' }); |
| 1044 | + return { engine, rows, protocol }; |
| 1045 | + } |
| 1046 | + |
| 1047 | + it('publishes through the per-item door, and the active row is the stored bytes', async () => { |
| 1048 | + const { rows, protocol } = await stageLegacy('legacy_fetch'); |
| 1049 | + |
| 1050 | + const published: any = await protocol.publishMetaItem({ type: 'flow', name: 'legacy_fetch', packageId: PKG } as any); |
| 1051 | + |
| 1052 | + expect(published.success).toBe(true); |
| 1053 | + expect(storedConfig(rows, 'legacy_fetch', 'active')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' }); |
| 1054 | + expect(storedConfig(rows, 'legacy_fetch', 'draft')).toBeUndefined(); |
| 1055 | + }); |
| 1056 | + |
| 1057 | + it('publishes through the package door, and the active row is the stored bytes', async () => { |
| 1058 | + const { rows, protocol } = await stageLegacy('legacy_fetch'); |
| 1059 | + |
| 1060 | + const published: any = await protocol.publishPackageDrafts({ packageId: PKG }); |
| 1061 | + |
| 1062 | + expect(published).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); |
| 1063 | + expect(storedConfig(rows, 'legacy_fetch', 'active')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' }); |
| 1064 | + }); |
| 1065 | + |
| 1066 | + it('its draft read reports no publish refusal for it', async () => { |
| 1067 | + const { rows, protocol } = await stageLegacy('legacy_fetch'); |
| 1068 | + const stored = JSON.parse([...rows.values()].find((r) => r.name === 'legacy_fetch' && r.state === 'draft')!.metadata); |
| 1069 | + |
| 1070 | + // The draft read's publish verdict, on the stored draft: nothing. |
| 1071 | + expect(await (protocol as any).draftPublishVerdict({ type: 'flow', name: 'legacy_fetch', body: stored, packageId: PKG })) |
| 1072 | + .toEqual([]); |
| 1073 | + // Served, it carries none of the rules the publish refused it on before. |
| 1074 | + // (The read's own schema half still judges the served body as stored; |
| 1075 | + // that seam is the read's, not the publish verdict's.) |
| 1076 | + const codes = ((await draftDiagnostics(protocol, 'flow', 'legacy_fetch')).errors ?? []).map((e) => e.code); |
| 1077 | + expect(codes).not.toContain('expression-invalid'); |
| 1078 | + expect(codes).not.toContain('flow-region-shape-invalid'); |
| 1079 | + }); |
| 1080 | + |
| 1081 | + /** The same flow with a conditional edge whose CEL does not parse. */ |
| 1082 | + const brokenEdge = (name: string, filterKey: 'filters' | 'filter') => { |
| 1083 | + const flow = fetchFlow(name, filterKey); |
| 1084 | + flow.edges[1] = { ...flow.edges[1]!, type: 'conditional', condition: 'record.name ==' } as typeof flow.edges[number]; |
| 1085 | + return flow; |
| 1086 | + }; |
| 1087 | + |
| 1088 | + /** The per-item door's 422 for a draft of `body`, staged as `stageLegacy` stages it. */ |
| 1089 | + async function perItemRefusal(body: ReturnType<typeof fetchFlow>) { |
| 1090 | + const { engine } = makeStubEngine(); |
| 1091 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 1092 | + await seedActive(engine, body); |
| 1093 | + await stageDraft(protocol, 'flow', body); |
| 1094 | + const err: any = await protocol.publishMetaItem({ type: 'flow', name: body.name, packageId: PKG } as any) |
| 1095 | + .then(() => null, (e: unknown) => e); |
| 1096 | + return { protocol, err }; |
| 1097 | + } |
| 1098 | + |
| 1099 | + const issueTriples = (issues: Array<{ rule?: string; path?: string; message: string }>) => |
| 1100 | + issues.map((i) => [i.rule, i.path, i.message]).sort(); |
| 1101 | + |
| 1102 | + it('a draft still refused once converted is refused at both doors with the issues its canonical spelling gets', async () => { |
| 1103 | + const canonical = await perItemRefusal(brokenEdge('broken_fetch', 'filter')); |
| 1104 | + const legacy = await perItemRefusal(brokenEdge('broken_fetch', 'filters')); |
| 1105 | + |
| 1106 | + expect({ code: canonical.err?.code, status: canonical.err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); |
| 1107 | + expect(canonical.err.issues.map((i: any) => i.rule)).toContain('expression-invalid'); |
| 1108 | + expect({ code: legacy.err?.code, status: legacy.err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); |
| 1109 | + expect(issueTriples(legacy.err.issues)).toEqual(issueTriples(canonical.err.issues)); |
| 1110 | + |
| 1111 | + // The package door refuses the same draft, on the same issues, and |
| 1112 | + // publishes nothing. |
| 1113 | + const batch: any = await legacy.protocol.publishPackageDrafts({ packageId: PKG }); |
| 1114 | + expect(batch).toMatchObject({ success: false, publishedCount: 0 }); |
| 1115 | + expect(batch.failed.map((f: any) => f.name)).toEqual(['broken_fetch']); |
| 1116 | + expect(issueTriples(batch.failed[0].issues)).toEqual(issueTriples(canonical.err.issues)); |
| 1117 | + }); |
| 1118 | +}); |
0 commit comments