|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Marketplace install of a template whose objects declare a `state_machine` |
| 5 | + * with `initialStates` MUST NOT drop the mid-lifecycle seed rows (framework#3433). |
| 6 | + * |
| 7 | + * A marketplace template is a curated snapshot: its seed almost always contains |
| 8 | + * rows past the FSM entry point — a `closed_won` opportunity, a `closed` case, a |
| 9 | + * `completed` project. #3165 made `initialStates` reject any INSERT outside the |
| 10 | + * entry set, which would silently drop every such row on install / rehydrate-heal |
| 11 | + * / per-org replay ("installed but no data"). #3433 fixed it at the platform: |
| 12 | + * `SeedLoaderService.SEED_OPTIONS` carries `seedReplay`, and the engine skips the |
| 13 | + * `state_machine` rule for those writes. |
| 14 | + * |
| 15 | + * This test drives the REAL marketplace install path (the plugin's HTTP handler |
| 16 | + * → dynamic import of the real runtime SeedLoaderService → runInlineSeed) against |
| 17 | + * an engine stub that FAITHFULLY reproduces the #3165 guard: an insert whose |
| 18 | + * state ∉ `initialStates` is rejected UNLESS the write carries |
| 19 | + * `context.seedReplay`. So the assertion below is exactly the #3433 contract on |
| 20 | + * the marketplace seam — remove the flag from `SEED_OPTIONS` and this goes red |
| 21 | + * (3 of 4 deals dropped, `seeded.errors` > 0). |
| 22 | + */ |
| 23 | + |
| 24 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 25 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 26 | +import { join } from 'node:path'; |
| 27 | +import { tmpdir } from 'node:os'; |
| 28 | + |
| 29 | +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; |
| 30 | + |
| 31 | +type Handler = (c: any) => Promise<any>; |
| 32 | + |
| 33 | +function makeRawApp() { |
| 34 | + const routes = new Map<string, Handler>(); |
| 35 | + return { |
| 36 | + routes, |
| 37 | + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), |
| 38 | + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), |
| 39 | + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), |
| 40 | + }; |
| 41 | +} |
| 42 | + |
| 43 | +function makeCtx(rawApp: any, services: Record<string, any>) { |
| 44 | + const hooks = new Map<string, any>(); |
| 45 | + return { |
| 46 | + ctx: { |
| 47 | + hook: (e: string, h: any) => hooks.set(e, h), |
| 48 | + getService: (name: string) => { |
| 49 | + if (name === 'http-server') return { getRawApp: () => rawApp }; |
| 50 | + const svc = services[name]; |
| 51 | + if (svc === undefined) throw new Error(`no ${name}`); |
| 52 | + return svc; |
| 53 | + }, |
| 54 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 55 | + }, |
| 56 | + fire: async () => { await hooks.get('kernel:ready')?.(); }, |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +function makeC(body: any) { |
| 61 | + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); |
| 62 | + return { |
| 63 | + req: { |
| 64 | + url: 'http://localhost:3000/api/v1/marketplace/install-local', |
| 65 | + raw: new Request('http://localhost:3000/x'), |
| 66 | + json: async () => body, |
| 67 | + param: () => undefined, |
| 68 | + header: () => undefined, |
| 69 | + }, |
| 70 | + json, |
| 71 | + }; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Engine stub that reproduces the #3165 insert-time `initialStates` guard the |
| 76 | + * REAL ObjectQL engine applies — and honors the #3433 `seedReplay` exemption. |
| 77 | + * Everything else mirrors the faithful stub in the seed-lookup test. |
| 78 | + */ |
| 79 | +function makeEngine() { |
| 80 | + const store: Record<string, any[]> = {}; |
| 81 | + const registry: Record<string, any> = {}; |
| 82 | + let idCounter = 0; |
| 83 | + |
| 84 | + const enforceInitialState = (objectName: string, rec: any, opts: any) => { |
| 85 | + if (opts?.context?.seedReplay === true) return; // #3433 exemption |
| 86 | + const schema = registry[objectName]; |
| 87 | + const sm = (schema?.validations ?? []).find( |
| 88 | + (v: any) => v?.type === 'state_machine' && Array.isArray(v.initialStates), |
| 89 | + ); |
| 90 | + if (!sm) return; |
| 91 | + const v = rec?.[sm.field]; |
| 92 | + if (v == null || v === '') return; |
| 93 | + if (!sm.initialStates.includes(String(v))) { |
| 94 | + const e: any = new Error(`invalid_initial_state: ${sm.field}='${String(v)}'`); |
| 95 | + e.code = 'VALIDATION_FAILED'; |
| 96 | + throw e; |
| 97 | + } |
| 98 | + }; |
| 99 | + |
| 100 | + const engine: any = { |
| 101 | + find: async (objectName: string, query?: any) => { |
| 102 | + let records = store[objectName] || []; |
| 103 | + if (query?.where) { |
| 104 | + records = records.filter((r) => |
| 105 | + Object.entries(query.where).every(([k, v]) => r[k] === v), |
| 106 | + ); |
| 107 | + } |
| 108 | + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); |
| 109 | + return records; |
| 110 | + }, |
| 111 | + insert: async (objectName: string, data: any, opts?: any) => { |
| 112 | + if (!store[objectName]) store[objectName] = []; |
| 113 | + if (Array.isArray(data)) { |
| 114 | + // Whole-array insert: a bad row throws the batch (the loader's |
| 115 | + // bulkWrite then degrades to per-row writeOne, exactly like the |
| 116 | + // real engine path). |
| 117 | + const records = data.map((d) => { |
| 118 | + enforceInitialState(objectName, d, opts); |
| 119 | + return { id: `row-${++idCounter}`, ...d }; |
| 120 | + }); |
| 121 | + store[objectName].push(...records); |
| 122 | + return records; |
| 123 | + } |
| 124 | + enforceInitialState(objectName, data, opts); |
| 125 | + const record = { id: `row-${++idCounter}`, ...data }; |
| 126 | + store[objectName].push(record); |
| 127 | + return record; |
| 128 | + }, |
| 129 | + update: async (objectName: string, data: any) => { |
| 130 | + const records = store[objectName] || []; |
| 131 | + const idx = records.findIndex((r) => r.id === data.id); |
| 132 | + if (idx >= 0) { |
| 133 | + records[idx] = { ...records[idx], ...data }; |
| 134 | + return records[idx]; |
| 135 | + } |
| 136 | + return data; |
| 137 | + }, |
| 138 | + delete: async () => ({ deleted: 1 }), |
| 139 | + count: async (objectName: string) => (store[objectName] || []).length, |
| 140 | + aggregate: async () => [], |
| 141 | + getSchema: (name: string) => registry[name], |
| 142 | + syncSchemas: async () => undefined, |
| 143 | + registerApp: (manifest: any) => { |
| 144 | + for (const obj of manifest?.objects ?? []) { |
| 145 | + if (obj?.name) registry[obj.name] = obj; |
| 146 | + } |
| 147 | + }, |
| 148 | + }; |
| 149 | + return { engine, store, registry }; |
| 150 | +} |
| 151 | + |
| 152 | +/** A template package whose `deal` object gates INSERT to `prospecting`, with a |
| 153 | + * seed that deliberately spans the whole pipeline (the marketplace reality). */ |
| 154 | +const PIPELINE_MANIFEST = { |
| 155 | + id: 'app.test.pipeline', |
| 156 | + name: 'Pipeline Test', |
| 157 | + version: '1.0.0', |
| 158 | + objects: [ |
| 159 | + { |
| 160 | + name: 'deal', |
| 161 | + label: 'Deal', |
| 162 | + fields: { |
| 163 | + name: { type: 'text', label: 'Name', required: true }, |
| 164 | + stage: { |
| 165 | + type: 'select', |
| 166 | + label: 'Stage', |
| 167 | + options: [ |
| 168 | + { value: 'prospecting' }, |
| 169 | + { value: 'negotiation' }, |
| 170 | + { value: 'closed_won' }, |
| 171 | + { value: 'closed_lost' }, |
| 172 | + ], |
| 173 | + }, |
| 174 | + }, |
| 175 | + validations: [ |
| 176 | + { |
| 177 | + type: 'state_machine', |
| 178 | + name: 'deal_stage_flow', |
| 179 | + field: 'stage', |
| 180 | + events: ['insert', 'update'], |
| 181 | + initialStates: ['prospecting'], |
| 182 | + transitions: { |
| 183 | + prospecting: ['negotiation', 'closed_lost'], |
| 184 | + negotiation: ['closed_won', 'closed_lost'], |
| 185 | + }, |
| 186 | + message: 'Invalid deal stage.', |
| 187 | + }, |
| 188 | + ], |
| 189 | + }, |
| 190 | + ], |
| 191 | + data: [ |
| 192 | + { |
| 193 | + object: 'deal', |
| 194 | + externalId: 'name', |
| 195 | + mode: 'upsert', |
| 196 | + records: [ |
| 197 | + { name: 'Acme Renewal', stage: 'prospecting' }, // the entry state |
| 198 | + { name: 'Globex Expansion', stage: 'negotiation' }, // mid-lifecycle |
| 199 | + { name: 'Initech Migration', stage: 'closed_won' }, // terminal — the killer |
| 200 | + { name: 'Umbrella Deal', stage: 'closed_lost' }, // terminal |
| 201 | + ], |
| 202 | + }, |
| 203 | + ], |
| 204 | +}; |
| 205 | + |
| 206 | +let dir: string; |
| 207 | +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-fsm-exempt-')); }); |
| 208 | +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); |
| 209 | + |
| 210 | +describe('marketplace install — state_machine initialStates exemption (#3433)', () => { |
| 211 | + it('lands every mid-lifecycle seed row (no initialStates rejection on the marketplace seam)', { timeout: 30_000 }, async () => { |
| 212 | + const { engine, store, registry } = makeEngine(); |
| 213 | + const rawApp = makeRawApp(); |
| 214 | + const { ctx, fire } = makeCtx(rawApp, { |
| 215 | + manifest: { register: (m: any) => engine.registerApp(m) }, |
| 216 | + auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, |
| 217 | + objectql: engine, |
| 218 | + metadata: { getObject: vi.fn(async () => undefined), list: vi.fn(async () => []) }, |
| 219 | + }); |
| 220 | + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); |
| 221 | + await plugin.start(ctx as any); |
| 222 | + await fire(); |
| 223 | + |
| 224 | + const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!( |
| 225 | + makeC({ manifest: PIPELINE_MANIFEST }), |
| 226 | + ); |
| 227 | + |
| 228 | + expect(res.payload?.success).toBe(true); |
| 229 | + // The object registered (engine registry) and the guard is armed. |
| 230 | + expect(registry.deal?.validations?.[0]?.initialStates).toEqual(['prospecting']); |
| 231 | + |
| 232 | + // The inline seed ran and landed EVERY row — including the three that |
| 233 | + // start past the FSM entry point. Without the #3433 exemption the stub |
| 234 | + // would reject negotiation/closed_won/closed_lost and this is 1, errors > 0. |
| 235 | + expect(res.payload?.data?.seeded?.mode).toBe('inline'); |
| 236 | + expect(res.payload?.data?.seeded?.errors).toBe(0); |
| 237 | + expect(store.deal).toHaveLength(4); |
| 238 | + expect(store.deal.map((r) => r.stage).sort()).toEqual([ |
| 239 | + 'closed_lost', |
| 240 | + 'closed_won', |
| 241 | + 'negotiation', |
| 242 | + 'prospecting', |
| 243 | + ]); |
| 244 | + }); |
| 245 | +}); |
0 commit comments