@objectstack/plugin-webhooks 17.0.0-rc.0 → 17.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +632 -0
- package/dist/{chunk-6EPMRZ7I.js → chunk-3QGZLM3T.js} +8 -3
- package/dist/chunk-3QGZLM3T.js.map +1 -0
- package/dist/{chunk-QUTQSOQC.cjs → chunk-JQUVS5KK.cjs} +8 -3
- package/dist/chunk-JQUVS5KK.cjs.map +1 -0
- package/dist/index.cjs +182 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +138 -19
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -2
- package/dist/schema.d.cts +721 -548
- package/dist/schema.d.ts +721 -548
- package/dist/schema.js +1 -1
- package/dist/{translations-Y3CXWOTM.js → translations-2VGD4XRF.js} +17 -9
- package/dist/translations-2VGD4XRF.js.map +1 -0
- package/dist/{translations-CBQRUIS5.cjs → translations-BWS57U2V.cjs} +17 -9
- package/dist/translations-BWS57U2V.cjs.map +1 -0
- package/package.json +12 -6
- package/.turbo/turbo-build.log +0 -36
- package/dist/chunk-6EPMRZ7I.js.map +0 -1
- package/dist/chunk-QUTQSOQC.cjs.map +0 -1
- package/dist/translations-CBQRUIS5.cjs.map +0 -1
- package/dist/translations-Y3CXWOTM.js.map +0 -1
- package/scripts/i18n-extract.config.ts +0 -34
- package/src/auto-enqueuer.test.ts +0 -431
- package/src/auto-enqueuer.ts +0 -383
- package/src/bootstrap-declared-webhooks.test.ts +0 -283
- package/src/bootstrap-declared-webhooks.ts +0 -205
- package/src/index.ts +0 -29
- package/src/schema.ts +0 -21
- package/src/sys-webhook.object.ts +0 -231
- package/src/translations/bundle-ownership.test.ts +0 -41
- package/src/translations/en.objects.generated.ts +0 -101
- package/src/translations/es-ES.objects.generated.ts +0 -101
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -101
- package/src/translations/zh-CN.objects.generated.ts +0 -101
- package/src/webhook-outbox-plugin.ts +0 -305
- package/src/webhook-provenance.ts +0 -86
- package/tsconfig.json +0 -10
- package/tsup.config.ts +0 -14
|
@@ -1,431 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* AutoEnqueuer end-to-end test.
|
|
5
|
-
*
|
|
6
|
-
* Verifies the bridge between `IRealtimeService` (data events) and the shared
|
|
7
|
-
* `service-messaging` HTTP outbox (ADR-0018 M3 — enqueue via `messaging.enqueueHttp`):
|
|
8
|
-
*
|
|
9
|
-
* - On startup, subscription rules are loaded from the engine.
|
|
10
|
-
* - `data.record.created/updated/deleted` events fan out to matching
|
|
11
|
-
* `sys_webhook` rows, enqueued as `source: 'webhook'`.
|
|
12
|
-
* - The `triggers` CSV column filters which actions fire.
|
|
13
|
-
* - The `object_name` field scopes events to a specific object.
|
|
14
|
-
* - Edits to `sys_webhook` self-heal the cache without restart.
|
|
15
|
-
* - Enqueue is fire-and-forget (handler never throws or blocks).
|
|
16
|
-
* - The deterministic dedupKey (`<webhookId>:<eventId>`) collapses replays.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
20
|
-
import type {
|
|
21
|
-
IDataEngine,
|
|
22
|
-
IRealtimeService,
|
|
23
|
-
RealtimeEventHandler,
|
|
24
|
-
RealtimeEventPayload,
|
|
25
|
-
} from '@objectstack/spec/contracts';
|
|
26
|
-
import type { EnqueueHttpInput } from '@objectstack/service-messaging';
|
|
27
|
-
import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js';
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Fakes
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Records `enqueueHttp` calls and dedups on `(source, dedupKey)` — mirroring the
|
|
35
|
-
* shared outbox's UNIQUE constraint so the replay test still holds.
|
|
36
|
-
*/
|
|
37
|
-
function makeRecorder() {
|
|
38
|
-
const calls: EnqueueHttpInput[] = [];
|
|
39
|
-
const seen = new Map<string, string>();
|
|
40
|
-
const enqueue: HttpEnqueueFn = async (input) => {
|
|
41
|
-
const key = `${input.source}::${input.dedupKey}`;
|
|
42
|
-
const existing = seen.get(key);
|
|
43
|
-
if (existing) return existing;
|
|
44
|
-
seen.set(key, key);
|
|
45
|
-
calls.push(input);
|
|
46
|
-
return key;
|
|
47
|
-
};
|
|
48
|
-
return { enqueue, calls };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
class FakeRealtime implements IRealtimeService {
|
|
52
|
-
private subs = new Map<string, { handler: RealtimeEventHandler; opts?: any }>();
|
|
53
|
-
private n = 0;
|
|
54
|
-
|
|
55
|
-
async publish(event: RealtimeEventPayload): Promise<void> {
|
|
56
|
-
for (const sub of this.subs.values()) {
|
|
57
|
-
const o = sub.opts ?? {};
|
|
58
|
-
if (o.object && event.object !== o.object) continue;
|
|
59
|
-
await sub.handler(event);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
async subscribe(_channel: string, handler: any, opts?: any): Promise<string> {
|
|
63
|
-
const id = `s-${++this.n}`;
|
|
64
|
-
this.subs.set(id, { handler, opts });
|
|
65
|
-
return id;
|
|
66
|
-
}
|
|
67
|
-
async unsubscribe(id: string): Promise<void> {
|
|
68
|
-
this.subs.delete(id);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
class FakeEngine implements IDataEngine {
|
|
73
|
-
rows: Record<string, any[]> = {};
|
|
74
|
-
|
|
75
|
-
constructor(seed?: Record<string, any[]>) {
|
|
76
|
-
if (seed) this.rows = JSON.parse(JSON.stringify(seed));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async find(name: string, q?: any): Promise<any[]> {
|
|
80
|
-
const all = this.rows[name] ?? [];
|
|
81
|
-
if (!q?.where) return all;
|
|
82
|
-
return all.filter((r) => Object.entries(q.where).every(([k, v]) => r[k] === v));
|
|
83
|
-
}
|
|
84
|
-
async findOne(name: string, q?: any): Promise<any> {
|
|
85
|
-
return (await this.find(name, q))[0] ?? null;
|
|
86
|
-
}
|
|
87
|
-
async insert(name: string, data: any): Promise<any> {
|
|
88
|
-
const arr = (this.rows[name] = this.rows[name] ?? []);
|
|
89
|
-
arr.push(data);
|
|
90
|
-
return data;
|
|
91
|
-
}
|
|
92
|
-
async update(name: string, data: any, opts?: any): Promise<any> {
|
|
93
|
-
const arr = this.rows[name] ?? [];
|
|
94
|
-
for (const r of arr) {
|
|
95
|
-
if (opts?.where && Object.entries(opts.where).every(([k, v]) => r[k] === v)) {
|
|
96
|
-
Object.assign(r, data);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return { affected: 0 };
|
|
100
|
-
}
|
|
101
|
-
async delete(name: string, opts?: any): Promise<any> {
|
|
102
|
-
const arr = this.rows[name] ?? [];
|
|
103
|
-
const before = arr.length;
|
|
104
|
-
this.rows[name] = arr.filter(
|
|
105
|
-
(r) => !(opts?.where && Object.entries(opts.where).every(([k, v]) => r[k] === v)),
|
|
106
|
-
);
|
|
107
|
-
return { affected: before - this.rows[name].length };
|
|
108
|
-
}
|
|
109
|
-
async count(name: string): Promise<number> {
|
|
110
|
-
return (this.rows[name] ?? []).length;
|
|
111
|
-
}
|
|
112
|
-
async aggregate(): Promise<any[]> {
|
|
113
|
-
return [];
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Helpers
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
function webhook(over: Partial<any> = {}): any {
|
|
122
|
-
return {
|
|
123
|
-
id: over.id ?? 'wh-1',
|
|
124
|
-
name: over.name ?? 'default',
|
|
125
|
-
active: over.active ?? true,
|
|
126
|
-
object_name: over.object_name ?? 'contact',
|
|
127
|
-
triggers: over.triggers ?? 'create,update,delete',
|
|
128
|
-
url: over.url ?? 'https://hooks.example/wh',
|
|
129
|
-
method: 'POST',
|
|
130
|
-
definition_json: over.definition_json,
|
|
131
|
-
...over,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function event(
|
|
136
|
-
type: 'created' | 'updated' | 'deleted',
|
|
137
|
-
object: string,
|
|
138
|
-
record: any,
|
|
139
|
-
timestamp = '2026-05-24T00:00:00.000Z',
|
|
140
|
-
): RealtimeEventPayload {
|
|
141
|
-
return {
|
|
142
|
-
type: `data.record.${type}`,
|
|
143
|
-
object,
|
|
144
|
-
payload: { recordId: record.id, after: record },
|
|
145
|
-
timestamp,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function flush() {
|
|
150
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Tests
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
describe('AutoEnqueuer', () => {
|
|
158
|
-
it('enqueues a delivery when a matching data event fires', async () => {
|
|
159
|
-
const engine = new FakeEngine({ sys_webhook: [webhook()] });
|
|
160
|
-
const realtime = new FakeRealtime();
|
|
161
|
-
const { enqueue, calls } = makeRecorder();
|
|
162
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
163
|
-
await ae.start();
|
|
164
|
-
|
|
165
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1', name: 'Alice' }));
|
|
166
|
-
await flush();
|
|
167
|
-
|
|
168
|
-
expect(calls).toHaveLength(1);
|
|
169
|
-
expect(calls[0].source).toBe('webhook');
|
|
170
|
-
expect(calls[0].refId).toBe('wh-1');
|
|
171
|
-
expect(calls[0].url).toBe('https://hooks.example/wh');
|
|
172
|
-
expect(calls[0].label).toBe('data.record.created');
|
|
173
|
-
expect((calls[0].payload as any).recordId).toBe('c-1');
|
|
174
|
-
await ae.stop();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('skips events for other objects', async () => {
|
|
178
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ object_name: 'contact' })] });
|
|
179
|
-
const realtime = new FakeRealtime();
|
|
180
|
-
const { enqueue, calls } = makeRecorder();
|
|
181
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
182
|
-
await ae.start();
|
|
183
|
-
|
|
184
|
-
await realtime.publish(event('created', 'lead', { id: 'l-1' }));
|
|
185
|
-
await flush();
|
|
186
|
-
|
|
187
|
-
expect(calls).toHaveLength(0);
|
|
188
|
-
await ae.stop();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('respects the triggers CSV (create-only webhook ignores updates)', async () => {
|
|
192
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'create' })] });
|
|
193
|
-
const realtime = new FakeRealtime();
|
|
194
|
-
const { enqueue, calls } = makeRecorder();
|
|
195
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
196
|
-
await ae.start();
|
|
197
|
-
|
|
198
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
199
|
-
await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
|
|
200
|
-
await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
|
|
201
|
-
await flush();
|
|
202
|
-
|
|
203
|
-
expect(calls).toHaveLength(1);
|
|
204
|
-
expect(calls[0].label).toBe('data.record.created');
|
|
205
|
-
await ae.stop();
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('parses triggers authored as a multi-select array', async () => {
|
|
209
|
-
// The `triggers` field is now a multi-select stored as an array; the
|
|
210
|
-
// parser must treat it identically to the legacy CSV form.
|
|
211
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: ['create'] })] });
|
|
212
|
-
const realtime = new FakeRealtime();
|
|
213
|
-
const { enqueue, calls } = makeRecorder();
|
|
214
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
215
|
-
await ae.start();
|
|
216
|
-
|
|
217
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
218
|
-
await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
|
|
219
|
-
await flush();
|
|
220
|
-
|
|
221
|
-
expect(calls).toHaveLength(1);
|
|
222
|
-
expect(calls[0].label).toBe('data.record.created');
|
|
223
|
-
await ae.stop();
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('parses triggers stored as a JSON-encoded array string', async () => {
|
|
227
|
-
// Some drivers hand a JSON array column back as a string — accept it.
|
|
228
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '["create","update"]' })] });
|
|
229
|
-
const realtime = new FakeRealtime();
|
|
230
|
-
const { enqueue, calls } = makeRecorder();
|
|
231
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
232
|
-
await ae.start();
|
|
233
|
-
|
|
234
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
235
|
-
await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
|
|
236
|
-
await flush();
|
|
237
|
-
|
|
238
|
-
// create + update are subscribed; delete is not.
|
|
239
|
-
expect(calls).toHaveLength(1);
|
|
240
|
-
expect(calls[0].label).toBe('data.record.created');
|
|
241
|
-
await ae.stop();
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('fans out to multiple matching webhooks', async () => {
|
|
245
|
-
const engine = new FakeEngine({
|
|
246
|
-
sys_webhook: [
|
|
247
|
-
webhook({ id: 'wh-1', name: 'slack', url: 'https://slack.test' }),
|
|
248
|
-
webhook({ id: 'wh-2', name: 'analytics', url: 'https://amplitude.test' }),
|
|
249
|
-
],
|
|
250
|
-
});
|
|
251
|
-
const realtime = new FakeRealtime();
|
|
252
|
-
const { enqueue, calls } = makeRecorder();
|
|
253
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
254
|
-
await ae.start();
|
|
255
|
-
|
|
256
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
257
|
-
await flush();
|
|
258
|
-
|
|
259
|
-
expect(calls).toHaveLength(2);
|
|
260
|
-
expect(calls.map((r) => r.url).sort()).toEqual([
|
|
261
|
-
'https://amplitude.test',
|
|
262
|
-
'https://slack.test',
|
|
263
|
-
]);
|
|
264
|
-
await ae.stop();
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('skips inactive webhooks', async () => {
|
|
268
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ active: false })] });
|
|
269
|
-
const realtime = new FakeRealtime();
|
|
270
|
-
const { enqueue, calls } = makeRecorder();
|
|
271
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
272
|
-
await ae.start();
|
|
273
|
-
|
|
274
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
275
|
-
await flush();
|
|
276
|
-
|
|
277
|
-
expect(calls).toHaveLength(0);
|
|
278
|
-
await ae.stop();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('skips manual-only webhooks (no triggers)', async () => {
|
|
282
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '' })] });
|
|
283
|
-
const realtime = new FakeRealtime();
|
|
284
|
-
const { enqueue, calls } = makeRecorder();
|
|
285
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
286
|
-
await ae.start();
|
|
287
|
-
|
|
288
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
289
|
-
await flush();
|
|
290
|
-
|
|
291
|
-
expect(calls).toHaveLength(0);
|
|
292
|
-
await ae.stop();
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('warns and ignores a legacy trigger the engine never emits (#3196)', async () => {
|
|
296
|
-
// A row authored before undelete/api were removed keeps its valid
|
|
297
|
-
// triggers; the dead ones are dropped with a warning (drift-guard).
|
|
298
|
-
const engine = new FakeEngine({
|
|
299
|
-
sys_webhook: [webhook({ triggers: 'create,undelete,api' })],
|
|
300
|
-
});
|
|
301
|
-
const realtime = new FakeRealtime();
|
|
302
|
-
const { enqueue, calls } = makeRecorder();
|
|
303
|
-
const warn = vi.fn();
|
|
304
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } });
|
|
305
|
-
await ae.start();
|
|
306
|
-
|
|
307
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
308
|
-
await flush();
|
|
309
|
-
|
|
310
|
-
expect(calls).toHaveLength(1); // the valid `create` trigger still fires
|
|
311
|
-
expect(warn).toHaveBeenCalledWith(
|
|
312
|
-
expect.stringContaining('undelete'),
|
|
313
|
-
expect.objectContaining({ unknown: expect.arrayContaining(['undelete', 'api']) }),
|
|
314
|
-
);
|
|
315
|
-
await ae.stop();
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('skips a webhook whose only triggers are removed values (#3196)', async () => {
|
|
319
|
-
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'undelete,api' })] });
|
|
320
|
-
const realtime = new FakeRealtime();
|
|
321
|
-
const { enqueue, calls } = makeRecorder();
|
|
322
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
323
|
-
await ae.start();
|
|
324
|
-
|
|
325
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
326
|
-
await flush();
|
|
327
|
-
|
|
328
|
-
expect(calls).toHaveLength(0);
|
|
329
|
-
await ae.stop();
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('self-heals the cache when sys_webhook changes', async () => {
|
|
333
|
-
const engine = new FakeEngine({ sys_webhook: [] });
|
|
334
|
-
const realtime = new FakeRealtime();
|
|
335
|
-
const { enqueue, calls } = makeRecorder();
|
|
336
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
337
|
-
await ae.start();
|
|
338
|
-
|
|
339
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
340
|
-
await flush();
|
|
341
|
-
expect(calls).toHaveLength(0);
|
|
342
|
-
|
|
343
|
-
await engine.insert('sys_webhook', webhook());
|
|
344
|
-
await realtime.publish({
|
|
345
|
-
type: 'data.record.created',
|
|
346
|
-
object: 'sys_webhook',
|
|
347
|
-
payload: { recordId: 'wh-1' },
|
|
348
|
-
timestamp: '2026-05-24T00:01:00.000Z',
|
|
349
|
-
});
|
|
350
|
-
await flush();
|
|
351
|
-
await flush(); // Two ticks: the self-heal handler itself awaits refresh
|
|
352
|
-
|
|
353
|
-
await realtime.publish(event('created', 'contact', { id: 'c-2' }, '2026-05-24T00:01:01.000Z'));
|
|
354
|
-
await flush();
|
|
355
|
-
|
|
356
|
-
expect(calls).toHaveLength(1);
|
|
357
|
-
expect((calls[0].payload as any).recordId).toBe('c-2');
|
|
358
|
-
await ae.stop();
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('uses a deterministic dedupKey so replays collapse', async () => {
|
|
362
|
-
const engine = new FakeEngine({ sys_webhook: [webhook()] });
|
|
363
|
-
const realtime = new FakeRealtime();
|
|
364
|
-
const { enqueue, calls } = makeRecorder();
|
|
365
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
366
|
-
await ae.start();
|
|
367
|
-
|
|
368
|
-
const evt = event('created', 'contact', { id: 'c-1' });
|
|
369
|
-
await realtime.publish(evt);
|
|
370
|
-
await realtime.publish(evt);
|
|
371
|
-
await flush();
|
|
372
|
-
|
|
373
|
-
expect(calls).toHaveLength(1);
|
|
374
|
-
await ae.stop();
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it('handler is fire-and-forget (publish does not block on enqueue)', async () => {
|
|
378
|
-
const engine = new FakeEngine({ sys_webhook: [webhook()] });
|
|
379
|
-
const realtime = new FakeRealtime();
|
|
380
|
-
let slowResolve!: () => void;
|
|
381
|
-
const blocker = new Promise<void>((res) => {
|
|
382
|
-
slowResolve = res;
|
|
383
|
-
});
|
|
384
|
-
const calls: EnqueueHttpInput[] = [];
|
|
385
|
-
const enqueue: HttpEnqueueFn = async (input) => {
|
|
386
|
-
await blocker;
|
|
387
|
-
calls.push(input);
|
|
388
|
-
return 'id';
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
|
|
392
|
-
await ae.start();
|
|
393
|
-
|
|
394
|
-
const before = Date.now();
|
|
395
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
396
|
-
const elapsed = Date.now() - before;
|
|
397
|
-
expect(elapsed).toBeLessThan(20); // publish must not have awaited blocker
|
|
398
|
-
|
|
399
|
-
slowResolve();
|
|
400
|
-
await flush();
|
|
401
|
-
expect(calls).toHaveLength(1);
|
|
402
|
-
await ae.stop();
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it('logs but swallows enqueue errors so other webhooks still fire', async () => {
|
|
406
|
-
const engine = new FakeEngine({
|
|
407
|
-
sys_webhook: [
|
|
408
|
-
webhook({ id: 'wh-bad', url: 'https://bad.test' }),
|
|
409
|
-
webhook({ id: 'wh-good', url: 'https://good.test' }),
|
|
410
|
-
],
|
|
411
|
-
});
|
|
412
|
-
const realtime = new FakeRealtime();
|
|
413
|
-
const calls: EnqueueHttpInput[] = [];
|
|
414
|
-
const enqueue: HttpEnqueueFn = vi.fn(async (input) => {
|
|
415
|
-
if (input.refId === 'wh-bad') throw new Error('boom');
|
|
416
|
-
calls.push(input);
|
|
417
|
-
return 'id';
|
|
418
|
-
});
|
|
419
|
-
const warn = vi.fn();
|
|
420
|
-
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } });
|
|
421
|
-
await ae.start();
|
|
422
|
-
|
|
423
|
-
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
|
|
424
|
-
await flush();
|
|
425
|
-
|
|
426
|
-
expect(calls).toHaveLength(1);
|
|
427
|
-
expect(calls[0].url).toBe('https://good.test');
|
|
428
|
-
expect(warn).toHaveBeenCalled();
|
|
429
|
-
await ae.stop();
|
|
430
|
-
});
|
|
431
|
-
});
|