|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #21725 — a flow saved through the metadata API is armed on the running |
| 5 | + * engine, on the same signal that already re-binds hooks and actions. |
| 6 | + * |
| 7 | + * The defect: `PUT /api/v1/meta/flow/:name` answered `200 "Saved flow … (env-wide, |
| 8 | + * state=active)"`, `GET /meta/flow/:name` served the row, and the engine held |
| 9 | + * nothing — `GET /api/v1/automation/:name` and its trigger door answered 404 |
| 10 | + * and a record-triggered flow never fired, until a restart. The engine armed |
| 11 | + * flows only at boot, at `kernel:ready` and on `metadata:reloaded`, and only |
| 12 | + * the publish doors announce that event. A direct save raises the protocol's |
| 13 | + * post-persistence signal (`onMetadataMutation`); ObjectQL re-binds authored |
| 14 | + * hooks and actions on it, and `service-automation` did not listen. |
| 15 | + * |
| 16 | + * These tests run the REAL write path — ObjectKernel, ObjectQLPlugin's |
| 17 | + * metadata protocol (the producer behind `PUT /meta/flow/:name`, the per-item |
| 18 | + * publish door and `DELETE /meta/flow/:name`), driver-sql on better-sqlite3 |
| 19 | + * `:memory:` and the real engine. `saveMetaItem` IS what the REST route calls; |
| 20 | + * the HTTP layer adds nothing on this path. The one stand-in is the |
| 21 | + * record-change trigger: `@objectstack/trigger-record-change` depends on this |
| 22 | + * package, so it binds through the same ObjectQL `registerHook` / |
| 23 | + * `unregisterHooksByPackage` pair the real trigger uses, and a real insert |
| 24 | + * fires it. |
| 25 | + * |
| 26 | + * The signal is fire-and-forget, so a save's answer precedes the arming by one |
| 27 | + * read of the protocol's view: the positive rows wait for it (`vi.waitFor`), |
| 28 | + * and the rows that assert something did NOT happen shut the kernel down |
| 29 | + * first — the plugin's `destroy()` waits for every flow sync already queued. |
| 30 | + */ |
| 31 | + |
| 32 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 33 | +import { ObjectKernel } from '@objectstack/core'; |
| 34 | +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; |
| 35 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 36 | +import type { AutomationContext } from '@objectstack/spec/contracts'; |
| 37 | +import { AutomationServicePlugin } from './plugin.js'; |
| 38 | +import type { AutomationEngine, FlowTrigger, FlowTriggerBinding } from './engine.js'; |
| 39 | + |
| 40 | +/** The saved surface these tests drive: `ObjectStackProtocolImplementation`'s write doors. */ |
| 41 | +interface MetadataWriteDoors { |
| 42 | + saveMetaItem(request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish' }): Promise<unknown>; |
| 43 | + publishMetaItem(request: { type: string; name: string }): Promise<unknown>; |
| 44 | + deleteMetaItem(request: { type: string; name: string }): Promise<unknown>; |
| 45 | +} |
| 46 | + |
| 47 | +const TICKET = 'qa_ticket'; |
| 48 | +const SYS = { isSystem: true } as const; |
| 49 | + |
| 50 | +/** The repro's flow: a minimal start → end, saved active. */ |
| 51 | +const plainFlow = (name: string, status: 'active' | 'obsolete' = 'active') => ({ |
| 52 | + name, |
| 53 | + label: name, |
| 54 | + type: 'autolaunched', |
| 55 | + status, |
| 56 | + nodes: [ |
| 57 | + { id: 'start', type: 'start', label: 'Start' }, |
| 58 | + { id: 'end', type: 'end', label: 'End' }, |
| 59 | + ], |
| 60 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 61 | +}); |
| 62 | + |
| 63 | +/** A flow launched by every insert into `qa_ticket`. */ |
| 64 | +const ticketCreatedFlow = (name: string, status: 'active' | 'obsolete' = 'active') => ({ |
| 65 | + ...plainFlow(name, status), |
| 66 | + nodes: [ |
| 67 | + { |
| 68 | + id: 'start', |
| 69 | + type: 'start', |
| 70 | + label: 'Start', |
| 71 | + config: { objectName: TICKET, triggerType: 'record-after-create' }, |
| 72 | + }, |
| 73 | + { id: 'end', type: 'end', label: 'End' }, |
| 74 | + ], |
| 75 | +}); |
| 76 | + |
| 77 | +/** |
| 78 | + * A `record_change` trigger bound through ObjectQL's own hook registry, the |
| 79 | + * mechanism `RecordChangeTrigger` uses: `start` registers an `afterInsert` hook |
| 80 | + * under a per-flow package id, `stop` drops everything under it. |
| 81 | + */ |
| 82 | +function objectqlRecordTrigger(ql: ObjectQL) { |
| 83 | + const fired: string[] = []; |
| 84 | + const packageOf = (flowName: string) => `qa-record-trigger:${flowName}`; |
| 85 | + const trigger: FlowTrigger = { |
| 86 | + type: 'record_change', |
| 87 | + start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>) { |
| 88 | + ql.unregisterHooksByPackage(packageOf(binding.flowName)); |
| 89 | + ql.registerHook( |
| 90 | + 'afterInsert', |
| 91 | + async (hook: { result?: unknown }) => { |
| 92 | + fired.push(binding.flowName); |
| 93 | + await callback({ record: hook.result, object: binding.object, event: 'afterInsert' } as never); |
| 94 | + }, |
| 95 | + { object: binding.object, packageId: packageOf(binding.flowName) }, |
| 96 | + ); |
| 97 | + }, |
| 98 | + stop(flowName: string) { |
| 99 | + ql.unregisterHooksByPackage(packageOf(flowName)); |
| 100 | + }, |
| 101 | + }; |
| 102 | + return { trigger, fired }; |
| 103 | +} |
| 104 | + |
| 105 | +describe('a flow saved through the metadata API is armed on the running engine (#21725)', () => { |
| 106 | + let kernel: ObjectKernel; |
| 107 | + let ql: ObjectQL; |
| 108 | + let protocol: MetadataWriteDoors; |
| 109 | + let automation: AutomationEngine; |
| 110 | + let fired: string[]; |
| 111 | + |
| 112 | + afterEach(async () => { |
| 113 | + vi.restoreAllMocks(); |
| 114 | + if (kernel?.getState() === 'running') await kernel.shutdown(); |
| 115 | + }); |
| 116 | + |
| 117 | + async function boot() { |
| 118 | + kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false } as never); |
| 119 | + await kernel.use(new ObjectQLPlugin()); |
| 120 | + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); |
| 121 | + await kernel.bootstrap(); |
| 122 | + |
| 123 | + ql = kernel.getService<ObjectQL>('objectql'); |
| 124 | + automation = kernel.getService<AutomationEngine>('automation'); |
| 125 | + protocol = kernel.getService<MetadataWriteDoors>('protocol'); |
| 126 | + |
| 127 | + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); |
| 128 | + await driver.connect(); |
| 129 | + ql.registerDriver(driver as never, true); |
| 130 | + ql.registry.registerObject( |
| 131 | + { name: TICKET, label: 'Ticket', fields: { title: { name: 'title', label: 'Title', type: 'text' } } } as never, |
| 132 | + 'qa-21725', |
| 133 | + 'qa-21725', |
| 134 | + ); |
| 135 | + await ql.syncSchemas(); |
| 136 | + |
| 137 | + const rec = objectqlRecordTrigger(ql); |
| 138 | + automation.registerTrigger(rec.trigger); |
| 139 | + fired = rec.fired; |
| 140 | + } |
| 141 | + |
| 142 | + const saveActive = (name: string, item: unknown) => protocol.saveMetaItem({ type: 'flow', name, item }); |
| 143 | + const insertTicket = (title: string) => ql.insert(TICKET, { title }, { context: SYS } as never); |
| 144 | + const runtimeState = (name: string) => automation.getFlowRuntimeStates().find((s) => s.name === name); |
| 145 | + |
| 146 | + /** Every flow sync already queued has run: `destroy()` waits for them. */ |
| 147 | + const settleFlowSync = () => kernel.shutdown(); |
| 148 | + |
| 149 | + it('an active save is triggerable without a restart or a publish', async () => { |
| 150 | + await boot(); |
| 151 | + const saved = await saveActive('qa_meta_flow', plainFlow('qa_meta_flow')); |
| 152 | + // The card's step 2, as the door answers it. |
| 153 | + expect((saved as { message?: string }).message).toContain("Saved flow 'qa_meta_flow' (env-wide, state=active)"); |
| 154 | + |
| 155 | + await vi.waitFor(async () => { |
| 156 | + expect(await automation.getFlow('qa_meta_flow'), 'GET /automation/:name reads this').not.toBeNull(); |
| 157 | + }); |
| 158 | + const run = await automation.execute('qa_meta_flow', {} as never); |
| 159 | + expect(run.success, `the trigger door's run: ${JSON.stringify(run)}`).toBe(true); |
| 160 | + }); |
| 161 | + |
| 162 | + it('a record-triggered flow saved that way fires on the next matching write', async () => { |
| 163 | + await boot(); |
| 164 | + await saveActive('ticket_created', ticketCreatedFlow('ticket_created')); |
| 165 | + await vi.waitFor(() => { |
| 166 | + expect(runtimeState('ticket_created')?.bound, 'the record trigger is bound').toBe(true); |
| 167 | + }); |
| 168 | + |
| 169 | + await insertTicket('first'); |
| 170 | + expect(fired).toEqual(['ticket_created']); |
| 171 | + }); |
| 172 | + |
| 173 | + it("a save that leaves the flow `status: 'obsolete'` disarms it, as the boot registers such a flow", async () => { |
| 174 | + await boot(); |
| 175 | + await saveActive('ticket_created', ticketCreatedFlow('ticket_created')); |
| 176 | + await vi.waitFor(() => expect(runtimeState('ticket_created')?.bound).toBe(true)); |
| 177 | + |
| 178 | + await saveActive('ticket_created', ticketCreatedFlow('ticket_created', 'obsolete')); |
| 179 | + await vi.waitFor(() => { |
| 180 | + expect(runtimeState('ticket_created'), 'still registered, disabled and unbound').toMatchObject({ |
| 181 | + status: 'obsolete', |
| 182 | + enabled: false, |
| 183 | + bound: false, |
| 184 | + }); |
| 185 | + }); |
| 186 | + await insertTicket('after deactivation'); |
| 187 | + expect(fired, 'a disarmed flow does not fire').toEqual([]); |
| 188 | + }); |
| 189 | + |
| 190 | + it('a delete through the same door unregisters it', async () => { |
| 191 | + await boot(); |
| 192 | + await saveActive('ticket_created', ticketCreatedFlow('ticket_created')); |
| 193 | + await vi.waitFor(() => expect(runtimeState('ticket_created')?.bound).toBe(true)); |
| 194 | + |
| 195 | + await protocol.deleteMetaItem({ type: 'flow', name: 'ticket_created' }); |
| 196 | + await vi.waitFor(async () => { |
| 197 | + expect(await automation.getFlow('ticket_created'), 'GET /automation/:name answers 404 again').toBeNull(); |
| 198 | + }); |
| 199 | + await insertTicket('after delete'); |
| 200 | + expect(fired, 'an unregistered flow does not fire').toEqual([]); |
| 201 | + }); |
| 202 | + |
| 203 | + it('a draft save arms nothing: the live row is unchanged', async () => { |
| 204 | + await boot(); |
| 205 | + const register = vi.spyOn(automation, 'registerFlow'); |
| 206 | + await protocol.saveMetaItem({ type: 'flow', name: 'drafted', item: plainFlow('drafted'), mode: 'draft' }); |
| 207 | + await settleFlowSync(); |
| 208 | + |
| 209 | + expect(register.mock.calls.filter(([name]) => name === 'drafted')).toEqual([]); |
| 210 | + expect(await automation.getFlow('drafted')).toBeNull(); |
| 211 | + }); |
| 212 | + |
| 213 | + it('the per-item publish door (#10219) still arms the published flow exactly once', async () => { |
| 214 | + await boot(); |
| 215 | + const register = vi.spyOn(automation, 'registerFlow'); |
| 216 | + await protocol.saveMetaItem({ type: 'flow', name: 'ticket_created', item: ticketCreatedFlow('ticket_created'), mode: 'draft' }); |
| 217 | + // The door awaits its `metadata:reloaded` announce, which runs behind the |
| 218 | + // mutation sync the same publish raised — both are done when it answers. |
| 219 | + await protocol.publishMetaItem({ type: 'flow', name: 'ticket_created' }); |
| 220 | + |
| 221 | + expect(register.mock.calls.filter(([name]) => name === 'ticket_created')).toHaveLength(1); |
| 222 | + await insertTicket('after publish'); |
| 223 | + expect(fired, 'one binding, so one launch per write').toEqual(['ticket_created']); |
| 224 | + }); |
| 225 | + |
| 226 | + it('PUT /automation/:name, which registers before it saves, is not registered a second time', async () => { |
| 227 | + await boot(); |
| 228 | + const register = vi.spyOn(automation, 'registerFlow'); |
| 229 | + // `registerAndSaveFlow`'s order (runtime `domains/automation.ts`): the |
| 230 | + // engine first, then the metadata door's own save. |
| 231 | + automation.registerFlow('door_flow', plainFlow('door_flow')); |
| 232 | + await saveActive('door_flow', plainFlow('door_flow')); |
| 233 | + await settleFlowSync(); |
| 234 | + |
| 235 | + expect(register.mock.calls.filter(([name]) => name === 'door_flow')).toHaveLength(1); |
| 236 | + expect(await automation.getFlow('door_flow')).not.toBeNull(); |
| 237 | + }); |
| 238 | + |
| 239 | + it('a flow is env-wide only: an organization-scoped save is refused and arms nothing', async () => { |
| 240 | + // The tenancy half of the boot's reach. `flow` declares no per-org |
| 241 | + // channel, so the door refuses the write before it lands — no |
| 242 | + // mutation is raised, and a flow write's signal never names an |
| 243 | + // organization the boot's env-wide read would not. |
| 244 | + await boot(); |
| 245 | + const register = vi.spyOn(automation, 'registerFlow'); |
| 246 | + await expect( |
| 247 | + protocol.saveMetaItem({ type: 'flow', name: 'org_flow', item: plainFlow('org_flow'), organizationId: 'org_a' } as never), |
| 248 | + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); |
| 249 | + await settleFlowSync(); |
| 250 | + |
| 251 | + expect(register.mock.calls.filter(([name]) => name === 'org_flow')).toEqual([]); |
| 252 | + expect(await automation.getFlow('org_flow')).toBeNull(); |
| 253 | + }); |
| 254 | +}); |
0 commit comments