Skip to content

Commit ffdf019

Browse files
os-zhuangclaude
andcommitted
test(trigger-record-change): end-to-end regression coverage for record-change flows (#1491)
#1491 reported record-change flows never firing on data writes (7.4.1–7.7.0). It no longer reproduces on current main — the triggers-first-class-dir + flow-engine-alignment refactor fixed the wiring. The gap that let it regress silently: the existing tests only used a FAKE data engine, never the real dispatch path. Adds a full-kernel integration test (ObjectQL + automation + record-change trigger + a small inline in-memory driver) that boots the real stack, inserts a row, and asserts a `record-after-create` flow fires and its `update_record` writes back — in BOTH registration orderings: 1. flow registered after the trigger (engine.registerFlow), and 2. flow pulled from the registry at automation.start(), bound when the trigger registers on kernel:ready (the production sequence). The driver is inlined (mirrors objectql's own real-engine test helper) to avoid adding a driver package dependency. Test-only; empty changeset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3642509 commit ffdf019

4 files changed

Lines changed: 219 additions & 8 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
---
3+
4+
test(trigger-record-change): add end-to-end regression coverage for record-change flows (#1491)
5+
6+
Test-only — no shipped-code change, so no version bump. #1491 reported that
7+
record-change flows never fired on data writes (7.4.1–7.7.0); it no longer
8+
reproduces on current `main` (fixed by the triggers-first-class-dir + flow-engine
9+
alignment refactor). The existing tests only used a fake data engine, so the real
10+
path was uncovered. Adds a full-kernel integration test (ObjectQL + automation +
11+
record-change trigger + in-memory driver) asserting a `record-after-create` flow
12+
fires and its `update_record` writes back — in both registration orderings
13+
(direct `registerFlow`, and registry-pull at `automation.start()` with the
14+
trigger binding on `kernel:ready`).

packages/triggers/trigger-record-change/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"@objectstack/spec": "workspace:*"
2222
},
2323
"devDependencies": {
24+
"@objectstack/objectql": "workspace:*",
25+
"@objectstack/service-automation": "workspace:*",
2426
"@types/node": "^25.9.2",
2527
"typescript": "^6.0.3",
2628
"vitest": "^4.1.8"
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* End-to-end integration test for the record-change trigger (#1491).
5+
*
6+
* #1491 reported that record-change flows never fired on data writes (observed
7+
* 7.4.1–7.7.0). The existing unit tests only exercised a *fake* data engine, so
8+
* they never covered the real path: a flow pulled into the automation engine,
9+
* the trigger binding to an ObjectQL lifecycle hook on `kernel:ready`, an actual
10+
* insert firing that hook, and the flow's `update_record` writing back through
11+
* the live data engine. This test boots a real kernel (ObjectQL + automation +
12+
* record-change trigger + in-memory driver) and asserts the full chain — in BOTH
13+
* registration orderings, since the engine relies on re-activating already-pulled
14+
* flows when the trigger registers later.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { ObjectKernel } from '@objectstack/core';
19+
import { ObjectQLPlugin } from '@objectstack/objectql';
20+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
21+
import { RecordChangeTriggerPlugin } from './plugin.js';
22+
23+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
24+
25+
/**
26+
* A tiny equality-WHERE in-memory driver — enough to exercise the real engine's
27+
* insert/update/find path without pulling a driver package as a dependency
28+
* (mirrors objectql's own real-engine test helper). One record store per object.
29+
*/
30+
function makeMemoryDriver(): any {
31+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
32+
const storeFor = (obj: string) => {
33+
let s = stores.get(obj);
34+
if (!s) { s = new Map(); stores.set(obj, s); }
35+
return s;
36+
};
37+
let nextId = 0;
38+
const matches = (row: Record<string, unknown>, where: any): boolean => {
39+
if (!where || typeof where !== 'object') return true;
40+
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matches(row, w));
41+
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matches(row, w));
42+
for (const [k, v] of Object.entries(where)) {
43+
if (k.startsWith('$')) continue;
44+
const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
45+
const a = row[k] === undefined ? null : row[k];
46+
const b = expected === undefined ? null : expected;
47+
if (a !== b) return false;
48+
}
49+
return true;
50+
};
51+
return {
52+
name: 'memory', version: '0.0.0', supports: {},
53+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
54+
async execute() { return null; }, async syncSchema() {},
55+
async find(object: string, ast: any) {
56+
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
57+
},
58+
findStream() { throw new Error('not implemented'); },
59+
async findOne(object: string, ast: any) {
60+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
61+
return null;
62+
},
63+
async create(object: string, data: Record<string, unknown>) {
64+
nextId += 1;
65+
const id = (data.id as string) ?? `r_${nextId}`;
66+
const row = { ...data, id };
67+
storeFor(object).set(id, row);
68+
return row;
69+
},
70+
async update(object: string, id: string, data: Record<string, unknown>) {
71+
const s = storeFor(object);
72+
const cur = s.get(id);
73+
if (!cur) throw new Error(`not found: ${object}/${id}`);
74+
const updated = { ...cur, ...data, id };
75+
s.set(id, updated);
76+
return updated;
77+
},
78+
async upsert(object: string, data: Record<string, unknown>) {
79+
const id = data.id as string | undefined;
80+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
81+
return this.create(object, data);
82+
},
83+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
84+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
85+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
86+
return Promise.all(rows.map((r) => this.create(object, r)));
87+
},
88+
async bulkUpdate() { return []; }, async bulkDelete() {},
89+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
90+
async commit() {}, async rollback() {},
91+
};
92+
}
93+
94+
/** A flow that stamps `stamp: 'done'` on the just-created record of `object`. */
95+
function stampFlow(name: string, object: string) {
96+
return {
97+
name,
98+
label: name,
99+
type: 'autolaunched',
100+
nodes: [
101+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-create' } },
102+
{ id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: object, filter: { id: '{record.id}' }, fields: { stamp: 'done' } } },
103+
{ id: 'end', type: 'end', label: 'End' },
104+
],
105+
edges: [
106+
{ id: 'e1', source: 'start', target: 'stamp' },
107+
{ id: 'e2', source: 'stamp', target: 'end' },
108+
],
109+
};
110+
}
111+
112+
const objectDef = (name: string) => ({
113+
name,
114+
label: name,
115+
fields: {
116+
status: { name: 'status', label: 'S', type: 'text' },
117+
stamp: { name: 'stamp', label: 'St', type: 'text' },
118+
},
119+
});
120+
121+
describe('record-change trigger — end-to-end (#1491)', () => {
122+
it('fires a record-after-create flow registered AFTER the trigger (engine.registerFlow path)', async () => {
123+
const kernel = new ObjectKernel({ logLevel: 'silent' });
124+
await kernel.use(new ObjectQLPlugin());
125+
await kernel.use(new AutomationServicePlugin());
126+
await kernel.use(new RecordChangeTriggerPlugin());
127+
await kernel.bootstrap();
128+
129+
const objectql = kernel.getService('objectql') as any;
130+
const data = kernel.getService('data') as any;
131+
const automation = kernel.getService<AutomationEngine>('automation');
132+
133+
objectql.registerDriver(makeMemoryDriver(), true);
134+
objectql.registry.registerObject(objectDef('wid'), 'test', 'test');
135+
automation.registerFlow('stamp_flow', stampFlow('stamp_flow', 'wid') as any);
136+
137+
// The flow bound to the trigger…
138+
expect((automation as any).getActiveTriggerBindings()).toContainEqual({
139+
flowName: 'stamp_flow',
140+
triggerType: 'record_change',
141+
});
142+
143+
const created = await data.insert('wid', { status: 'new' });
144+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
145+
await sleep(200);
146+
147+
const row = await data.findOne('wid', { where: { id } });
148+
expect(row?.stamp).toBe('done');
149+
}, 15000);
150+
151+
it('fires a flow PULLED FROM THE REGISTRY at automation.start(), bound when the trigger registers on kernel:ready (production ordering)', async () => {
152+
const flowDef = stampFlow('stamp_flow2', 'wid2');
153+
154+
// Seeds the driver + object + flow into the registry in start(), which runs
155+
// before AutomationServicePlugin.start() pulls flows — the production
156+
// sequence (metadata seeds → automation pulls → trigger binds on
157+
// kernel:ready via re-activation of the already-registered flow).
158+
const seeder = {
159+
name: 'test.seeder',
160+
type: 'standard',
161+
version: '1.0.0',
162+
dependencies: ['com.objectstack.engine.objectql'],
163+
async init() {},
164+
async start(ctx: any) {
165+
const ql = ctx.getService('objectql');
166+
ql.registerDriver(makeMemoryDriver(), true);
167+
ql.registry.registerObject(objectDef('wid2'), 'test', 'test');
168+
ql.registry.registerItem('flow', flowDef, 'name', 'test');
169+
},
170+
};
171+
172+
const kernel = new ObjectKernel({ logLevel: 'silent' });
173+
await kernel.use(new ObjectQLPlugin());
174+
await kernel.use(seeder as any);
175+
await kernel.use(new AutomationServicePlugin());
176+
await kernel.use(new RecordChangeTriggerPlugin());
177+
await kernel.bootstrap();
178+
179+
const data = kernel.getService('data') as any;
180+
const automation = kernel.getService<AutomationEngine>('automation');
181+
182+
// The registry-pulled flow bound to the trigger after kernel:ready.
183+
expect((automation as any).getActiveTriggerBindings()).toContainEqual({
184+
flowName: 'stamp_flow2',
185+
triggerType: 'record_change',
186+
});
187+
188+
const created = await data.insert('wid2', { status: 'new' });
189+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
190+
await sleep(200);
191+
192+
const row = await data.findOne('wid2', { where: { id } });
193+
expect(row?.stamp).toBe('done');
194+
}, 15000);
195+
});

pnpm-lock.yaml

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)