-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathapp-plugin.seed.test.ts
More file actions
348 lines (291 loc) · 14.6 KB
/
Copy pathapp-plugin.seed.test.ts
File metadata and controls
348 lines (291 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AppPlugin } from './app-plugin';
import { readSeedDatasets } from './seed-datasets.js';
import { readSeedSettlement } from './seed-settlement.js';
import type { PluginContext } from '@objectstack/core';
/**
* #2996 — AppPlugin emits `app:seeded` when its inline seed settles, so
* reconcilers that read seeded rows (plugin-auth's ADR-0093 D6 membership
* backfill) can re-run for users inserted by a seed that overran the inline
* budget and finished in the background AFTER `kernel:ready`.
*
* These tests force the basic-insert fallback path (no `metadata` service) so
* the SeedLoaderService plumbing is unnecessary — the settle signalling is the
* same on both branches.
*/
describe('AppPlugin inline-seed settle signal (app:seeded, #2996)', () => {
const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS;
const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED;
let trigger: ReturnType<typeof vi.fn>;
let insert: ReturnType<typeof vi.fn>;
const makeContext = (overrides: Partial<PluginContext> = {}): PluginContext =>
({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
registerService: vi.fn(),
getService: vi.fn((name: string) => {
if (name === 'objectql') return { insert };
// `metadata` absent → basic-insert fallback; all others absent too.
return undefined;
}),
getServices: vi.fn(() => new Map()),
hook: vi.fn(),
trigger,
...overrides,
}) as unknown as PluginContext;
const bundleWithUser = () => ({
id: 'seed-test-app',
data: [{ object: 'sys_user', records: [{ id: 'seeded_u1' }] }],
});
beforeEach(() => {
delete process.env.OS_MULTI_ORG_ENABLED;
trigger = vi.fn();
});
afterEach(() => {
if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS;
else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET;
if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI;
});
it('emits app:seeded with overBudget=true after a background seed finishes past the budget', async () => {
process.env.OS_INLINE_SEED_BUDGET_MS = '10';
// Insert resolves well after the 10ms budget, so the race yields to the
// budget and the seed continues in the background.
insert = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 60)));
const ctx = makeContext();
const plugin = new AppPlugin(bundleWithUser());
await plugin.start(ctx);
// start() returned once the budget elapsed — the background seed (and
// thus the settle signal) has NOT fired yet.
expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything());
await vi.waitFor(() => {
expect(trigger).toHaveBeenCalledWith('app:seeded', {
appId: 'seed-test-app',
overBudget: true,
});
});
// The user was still inserted by the background seed.
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'seeded_u1' }, expect.anything());
});
it('emits app:seeded with overBudget=false when the seed completes within budget', async () => {
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
insert = vi.fn(async () => undefined); // resolves immediately
const ctx = makeContext();
const plugin = new AppPlugin(bundleWithUser());
await plugin.start(ctx);
expect(trigger).toHaveBeenCalledWith('app:seeded', {
appId: 'seed-test-app',
overBudget: false,
});
});
it('does not emit app:seeded when the app has no seed datasets', async () => {
insert = vi.fn(async () => undefined);
const ctx = makeContext();
const plugin = new AppPlugin({ id: 'seed-test-app' });
await plugin.start(ctx);
expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything());
});
it('does not throw when the kernel context has no trigger() function', async () => {
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
insert = vi.fn(async () => undefined);
const ctx = makeContext({ trigger: undefined as any });
const plugin = new AppPlugin(bundleWithUser());
await expect(plugin.start(ctx)).resolves.toBeUndefined();
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'seeded_u1' }, expect.anything());
});
it('does not run the inline seed (or emit) in multi-tenant mode', async () => {
process.env.OS_MULTI_ORG_ENABLED = 'true';
insert = vi.fn(async () => undefined);
const ctx = makeContext();
const plugin = new AppPlugin(bundleWithUser());
await plugin.start(ctx);
expect(insert).not.toHaveBeenCalled();
expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything());
});
/**
* #4795 — the same three branches, seen through the `seed-settlement`
* contract. `app:seeded` alone cannot answer "is a seed still landing?"
* because the two branches that never emit it are exactly the two where
* the answer is "yes, and it never will be" — so the settle signal needs a
* standing tally beside it, declared before the branch is chosen.
*/
describe('seed-settlement signal (#4795)', () => {
/** Like `makeContext`, but with a service map the tally can live in. */
const makeSettlementContext = (): PluginContext => {
const services = new Map<string, unknown>();
return makeContext({
registerService: vi.fn((name: string, svc: unknown) => {
if (services.has(name)) throw new Error(`service '${name}' already registered`);
services.set(name, svc);
}),
getService: vi.fn((name: string) => {
if (name === 'objectql') return { insert };
if (services.has(name)) return services.get(name);
return undefined; // `metadata` absent → basic-insert fallback
}),
} as unknown as Partial<PluginContext>);
};
it('an over-budget seed stays pending until the background run settles', async () => {
process.env.OS_INLINE_SEED_BUDGET_MS = '10';
insert = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 60)));
const ctx = makeSettlementContext();
await new AppPlugin(bundleWithUser()).start(ctx);
// start() returned on the budget; the rows are still landing. This
// is the window in which `kernel:ready` used to certify the store.
expect(readSeedSettlement(ctx)).toEqual({ pending: 1, inFlight: 1, suppressed: [] });
await vi.waitFor(() => {
expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] });
});
expect(trigger).toHaveBeenCalledWith('app:seeded', {
appId: 'seed-test-app',
overBudget: true,
});
});
it('a seed that fits its budget has already settled when start() returns', async () => {
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
insert = vi.fn(async () => undefined);
const ctx = makeSettlementContext();
await new AppPlugin(bundleWithUser()).start(ctx);
expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] });
});
it('multi-tenant suppresses its source, which therefore never settles', async () => {
process.env.OS_MULTI_ORG_ENABLED = 'true';
insert = vi.fn(async () => undefined);
const ctx = makeSettlementContext();
await new AppPlugin(bundleWithUser()).start(ctx);
expect(readSeedSettlement(ctx)).toEqual({
pending: 1,
inFlight: 0,
suppressed: ['multi-tenant-replay'],
});
});
it('skipSeedData suppresses its source, which therefore never settles', async () => {
insert = vi.fn(async () => undefined);
const ctx = makeSettlementContext();
await new AppPlugin(bundleWithUser(), undefined, { skipSeedData: true }).start(ctx);
expect(insert).not.toHaveBeenCalled();
expect(readSeedSettlement(ctx)).toEqual({
pending: 1,
inFlight: 0,
suppressed: ['skip-seed-data'],
});
});
it('registers no tally at all when the app has no seed datasets', async () => {
insert = vi.fn(async () => undefined);
const ctx = makeSettlementContext();
await new AppPlugin({ id: 'seed-test-app' }).start(ctx);
// Nothing to wait for — the attestation backstop runs unchanged.
expect(readSeedSettlement(ctx)).toBeUndefined();
});
/**
* The settle must land before the `trigger` guard, not after it: a
* kernel context with no `trigger()` still finished its seed, and
* leaving the tally pending there would strand every consumer waiting
* on a signal that cannot arrive.
*/
it('settles even when the kernel context has no trigger()', async () => {
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
insert = vi.fn(async () => undefined);
const base = makeSettlementContext();
const ctx = { ...base, trigger: undefined } as unknown as PluginContext;
await new AppPlugin(bundleWithUser()).start(ctx);
expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] });
});
});
});
/**
* #15262 — a FLAT bundle (manifest fields written directly on the bundle, no
* `manifest:` key) had every seed dataset collected TWICE.
*
* `start()` reads seed data from two locations: the top-level `data` field and
* the legacy `manifest.data`. The legacy read resolves its base as
* `this.bundle.manifest || this.bundle`, so on a flat bundle it re-reads the
* very array the top-level read just contributed. `mergeSeedDatasets` is a
* plain `push` with no de-duplication, so both copies reach the shared
* `seed-datasets` registry AND the inline loader — for a `mode: 'insert'`
* dataset that is the row applied twice per boot, not merely doubled work.
*
* The sibling two-location collector for `translations` (`loadTranslations`)
* has always carried the reference guard that makes the same read safe. These
* tests pin BOTH halves of that guard: the flat bundle collects once, and a
* bundle whose `manifest.data` is a genuinely DIFFERENT array still collects
* both — a fix that simply deleted the legacy read would pass the first
* assertion and fail the second.
*/
describe('AppPlugin flat-bundle seed collection (#15262)', () => {
const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS;
const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED;
let insert: ReturnType<typeof vi.fn>;
/** A context with a real service map, so `seed-datasets` can be read back. */
const makeContext = (): PluginContext => {
const services = new Map<string, unknown>();
return {
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
registerService: vi.fn((name: string, svc: unknown) => {
if (services.has(name)) throw new Error(`service '${name}' already registered`);
services.set(name, svc);
}),
getService: vi.fn((name: string) => {
if (name === 'objectql') return { insert };
if (services.has(name)) return services.get(name);
return undefined; // `metadata` absent → basic-insert fallback
}),
getServices: vi.fn(() => new Map()),
hook: vi.fn(),
trigger: vi.fn(),
} as unknown as PluginContext;
};
/** The shape the card names: NO `manifest` key, a top-level `data` array. */
const flatBundle = () => ({
id: 'com.test.flat-seed',
name: 'flat-seed',
data: [
{
object: 'sys_user',
mode: 'insert',
records: [{ id: 'flat_u1', name: 'Flat One' }],
},
],
});
beforeEach(() => {
delete process.env.OS_MULTI_ORG_ENABLED;
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
insert = vi.fn(async () => undefined);
});
afterEach(() => {
if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS;
else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET;
if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI;
});
it('collects a flat bundle seed dataset ONCE, not once per read location', async () => {
const ctx = makeContext();
await new AppPlugin(flatBundle()).start(ctx);
expect(readSeedDatasets(ctx)).toHaveLength(1);
});
it('applies a mode:insert record ONCE per boot on a flat bundle', async () => {
const ctx = makeContext();
await new AppPlugin(flatBundle()).start(ctx);
// The row consequence, not the collection count: the doubled dataset
// was applied twice by the inline seed — the #3434 end state reached
// by a different route.
expect(insert).toHaveBeenCalledTimes(1);
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'flat_u1', name: 'Flat One' }, expect.anything());
});
it('still collects BOTH sources when manifest.data is a genuinely different array', async () => {
const ctx = makeContext();
const bundle = {
manifest: {
id: 'com.test.nested-seed',
data: [{ object: 'sys_user', mode: 'insert', records: [{ id: 'legacy_u1' }] }],
},
data: [{ object: 'sys_user', mode: 'insert', records: [{ id: 'top_u1' }] }],
};
await new AppPlugin(bundle).start(ctx);
// Anti-vacuity: the legacy fallback is GUARDED, not removed.
expect(readSeedDatasets(ctx)).toHaveLength(2);
expect(insert).toHaveBeenCalledTimes(2);
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'top_u1' }, expect.anything());
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'legacy_u1' }, expect.anything());
});
});