-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathapp-plugin.seed.test.ts
More file actions
236 lines (194 loc) · 9.83 KB
/
Copy pathapp-plugin.seed.test.ts
File metadata and controls
236 lines (194 loc) · 9.83 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
// 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 { 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: [] });
});
});
});