@objectstack/plugin-reports 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.
@@ -1,475 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, beforeEach, vi } from 'vitest';
4
- import { ReportService, renderReport, type ReportEmail } from './report-service.js';
5
-
6
- // ─── Fake engine ──────────────────────────────────────────────────
7
-
8
- interface FakeRow { [k: string]: any }
9
-
10
- function makeFakeEngine() {
11
- const tables: Record<string, FakeRow[]> = {};
12
- const ensure = (n: string) => (tables[n] ??= []);
13
-
14
- function matches(row: FakeRow, filter: any): boolean {
15
- if (!filter || typeof filter !== 'object') return true;
16
- for (const [k, v] of Object.entries(filter)) {
17
- if (row[k] !== v) return false;
18
- }
19
- return true;
20
- }
21
-
22
- return {
23
- _tables: tables,
24
- async find(object: string, options?: any) {
25
- const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
26
- if (options?.orderBy?.[0]) {
27
- // Canonical SortNode key only (spec/data/query.zod.ts): the real
28
- // engine strips an unknown `direction:` key and defaults to asc, so
29
- // the mock must too — honoring both keys masks wrong-key sorts.
30
- const { field, order } = options.orderBy[0];
31
- rows.sort((a, b) => {
32
- const av = a[field]; const bv = b[field];
33
- if (av === bv) return 0;
34
- const cmp = av > bv ? 1 : -1;
35
- return order === 'desc' ? -cmp : cmp;
36
- });
37
- }
38
- return rows.slice(0, options?.limit ?? 1000);
39
- },
40
- async insert(object: string, data: any) {
41
- ensure(object).push({ ...data });
42
- return { ...data };
43
- },
44
- async update(object: string, idOrData: any, _opts?: any) {
45
- const data = typeof idOrData === 'object' ? idOrData : _opts;
46
- const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
47
- const table = ensure(object);
48
- const i = table.findIndex(r => r.id === id);
49
- if (i >= 0) table[i] = { ...table[i], ...data };
50
- return table[i];
51
- },
52
- async delete(object: string, options?: any) {
53
- const table = ensure(object);
54
- const id = options?.where?.id ?? options?.id;
55
- const i = table.findIndex(r => r.id === id);
56
- if (i >= 0) table.splice(i, 1);
57
- return { id };
58
- },
59
- };
60
- }
61
-
62
- function makeFakeEmail() {
63
- const sent: any[] = [];
64
- const email: ReportEmail & { _sent: any[] } = {
65
- _sent: sent,
66
- async send(input) { sent.push(input); return { status: 'sent' }; },
67
- };
68
- return email;
69
- }
70
-
71
- const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] };
72
-
73
- // ─── Rendering ─────────────────────────────────────────────────────
74
-
75
- describe('renderReport', () => {
76
- it('csv: escapes quotes / commas / newlines per RFC 4180', () => {
77
- const out = renderReport(
78
- [{ a: 'x,y', b: 'has "q"', c: 'line\n2' }],
79
- 'csv',
80
- ['a', 'b', 'c'],
81
- );
82
- expect(out).toBe('a,b,c\r\n"x,y","has ""q""","line\n2"');
83
- });
84
-
85
- it('csv: header-only when no rows', () => {
86
- expect(renderReport([], 'csv', ['a', 'b'])).toBe('a,b');
87
- });
88
-
89
- it('html_table: escapes HTML entities', () => {
90
- const out = renderReport([{ name: '<script>' }], 'html_table', ['name']);
91
- expect(out).toContain('&lt;script&gt;');
92
- expect(out).not.toContain('<script>');
93
- });
94
-
95
- it('json: pretty-prints rows', () => {
96
- const out = renderReport([{ a: 1 }], 'json');
97
- expect(JSON.parse(out)).toEqual([{ a: 1 }]);
98
- });
99
-
100
- it('auto-detects fields from first 50 rows when none specified', () => {
101
- const out = renderReport([{ a: 1 }, { b: 2 }], 'csv');
102
- expect(out.split('\r\n')[0]).toMatch(/a|b/);
103
- });
104
- });
105
-
106
- // ─── Service ──────────────────────────────────────────────────────
107
-
108
- describe('ReportService', () => {
109
- let engine: ReturnType<typeof makeFakeEngine>;
110
- let email: ReturnType<typeof makeFakeEmail>;
111
- let svc: ReportService;
112
- const now = new Date('2026-01-15T10:00:00Z');
113
-
114
- beforeEach(() => {
115
- engine = makeFakeEngine();
116
- email = makeFakeEmail();
117
- svc = new ReportService({
118
- engine: engine as any,
119
- email,
120
- clock: { now: () => now },
121
- maxRows: 5000,
122
- // Scheduled runs execute under the owner's resolved context (#2980).
123
- // The fake engine ignores context, so this just lets dispatch proceed
124
- // under a non-elevated identity instead of failing closed.
125
- resolveOwnerContext: async (ownerId: string) =>
126
- ownerId ? { userId: ownerId, tenantId: 't1', positions: [], permissions: [] } : null,
127
- });
128
- // seed the underlying object the report will query.
129
- engine._tables['lead'] = [
130
- { id: 'l1', name: 'Acme', status: 'open' },
131
- { id: 'l2', name: 'Beta', status: 'open' },
132
- { id: 'l3', name: 'Gamma', status: 'closed' },
133
- ];
134
- });
135
-
136
- it('saveReport: creates with a generated id and serialises query', async () => {
137
- const r = await svc.saveReport({
138
- name: 'Open leads',
139
- object: 'lead',
140
- query: { filter: { status: 'open' } },
141
- format: 'csv',
142
- }, CTX);
143
- expect(r.id).toMatch(/^rpt_/);
144
- expect(r.name).toBe('Open leads');
145
- expect(r.object_name).toBe('lead');
146
- const stored = engine._tables['sys_saved_report']?.[0];
147
- expect(stored?.query_json).toBe(JSON.stringify({ filter: { status: 'open' } }));
148
- expect(stored?.owner_id).toBe('u1');
149
- });
150
-
151
- it('saveReport: upserts when id matches existing row', async () => {
152
- const a = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
153
- const b = await svc.saveReport({ id: a.id, name: 'A-renamed', object: 'lead', query: {} }, CTX);
154
- expect(b.id).toBe(a.id);
155
- expect(b.name).toBe('A-renamed');
156
- expect(engine._tables['sys_saved_report'].length).toBe(1);
157
- });
158
-
159
- it('saveReport: rejects missing required fields', async () => {
160
- await expect(svc.saveReport({ name: '', object: 'lead', query: {} }, CTX))
161
- .rejects.toThrow(/VALIDATION_FAILED/);
162
- await expect(svc.saveReport({ name: 'x', object: '', query: {} }, CTX))
163
- .rejects.toThrow(/VALIDATION_FAILED/);
164
- });
165
-
166
- it('listReports: filters by object + owner', async () => {
167
- await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
168
- await svc.saveReport({ name: 'B', object: 'account', query: {} }, CTX);
169
- const leads = await svc.listReports({ object: 'lead' }, CTX);
170
- expect(leads.length).toBe(1);
171
- expect(leads[0].name).toBe('A');
172
- });
173
-
174
- it('listReports: most recently updated first', async () => {
175
- // Regression: the query sorted with the non-canonical `direction: 'desc'`
176
- // key, which SortNode strips — so it sorted ascending (oldest first).
177
- engine._tables['sys_saved_report'] = [
178
- { id: 'r_old', name: 'Old', object_name: 'lead', query_json: '{}', owner_id: 'u1', updated_at: '2026-01-01T00:00:00Z' },
179
- { id: 'r_new', name: 'New', object_name: 'lead', query_json: '{}', owner_id: 'u1', updated_at: '2026-03-01T00:00:00Z' },
180
- { id: 'r_mid', name: 'Mid', object_name: 'lead', query_json: '{}', owner_id: 'u1', updated_at: '2026-02-01T00:00:00Z' },
181
- ];
182
- const rows = await svc.listReports({ object: 'lead' }, CTX);
183
- expect(rows.map(r => r.id)).toEqual(['r_new', 'r_mid', 'r_old']);
184
- });
185
-
186
- it('getReport: returns null on miss', async () => {
187
- expect(await svc.getReport('nope', CTX)).toBeNull();
188
- });
189
-
190
- it('deleteReport: cascades to schedules', async () => {
191
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
192
- await svc.scheduleReport({ reportId: r.id, recipients: ['x@test'] }, CTX);
193
- expect(engine._tables['sys_report_schedule'].length).toBe(1);
194
- await svc.deleteReport(r.id, CTX);
195
- expect(engine._tables['sys_saved_report'].length).toBe(0);
196
- expect(engine._tables['sys_report_schedule'].length).toBe(0);
197
- });
198
-
199
- it('run: executes query and stamps last_run_at/last_row_count', async () => {
200
- const r = await svc.saveReport({
201
- name: 'Open', object: 'lead', query: { filter: { status: 'open' } }, format: 'csv',
202
- }, CTX);
203
- const result = await svc.run(r.id, CTX);
204
- expect(result.rowCount).toBe(2);
205
- expect(result.format).toBe('csv');
206
- expect(result.body).toContain('Acme');
207
- const stored = engine._tables['sys_saved_report'][0];
208
- expect(stored.last_row_count).toBe(2);
209
- expect(stored.last_run_at).toBe(now.toISOString());
210
- });
211
-
212
- it('run: throws REPORT_NOT_FOUND for unknown id', async () => {
213
- await expect(svc.run('nope', CTX)).rejects.toThrow(/REPORT_NOT_FOUND/);
214
- });
215
-
216
- it('runAdHoc: executes without stamping any row', async () => {
217
- const result = await svc.runAdHoc({
218
- name: 'temp', object: 'lead', query: {}, format: 'json',
219
- }, CTX);
220
- expect(result.rowCount).toBe(3);
221
- expect(engine._tables['sys_saved_report']).toBeUndefined();
222
- });
223
-
224
- it('scheduleReport: requires non-empty recipients', async () => {
225
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
226
- await expect(svc.scheduleReport({ reportId: r.id, recipients: [] }, CTX))
227
- .rejects.toThrow(/VALIDATION_FAILED/);
228
- });
229
-
230
- it('scheduleReport: rejects unknown report', async () => {
231
- await expect(svc.scheduleReport({ reportId: 'nope', recipients: ['x@t'] }, CTX))
232
- .rejects.toThrow(/REPORT_NOT_FOUND/);
233
- });
234
-
235
- it('scheduleReport: computes next_run_at from interval', async () => {
236
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
237
- const s = await svc.scheduleReport({
238
- reportId: r.id, recipients: ['x@t'], intervalMinutes: 60, format: 'csv',
239
- }, CTX);
240
- const expected = new Date(now.getTime() + 60 * 60_000).toISOString();
241
- expect(s.next_run_at).toBe(expected);
242
- expect(s.recipients).toBe('x@t');
243
- });
244
-
245
- it('scheduleReport: cron_expression drives next_run_at and overrides interval (#1983)', async () => {
246
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
247
- // now = 2026-01-15T10:00:00Z (past 09:00); daily-9am cron → tomorrow 09:00Z.
248
- const s = await svc.scheduleReport({
249
- reportId: r.id, recipients: ['x@t'], intervalMinutes: 60, cronExpression: '0 9 * * *',
250
- }, CTX);
251
- expect(s.cron_expression).toBe('0 9 * * *');
252
- expect(s.next_run_at).toBe('2026-01-16T09:00:00.000Z');
253
- // …not the interval's now + 60m.
254
- expect(s.next_run_at).not.toBe(new Date(now.getTime() + 60 * 60_000).toISOString());
255
- });
256
-
257
- it('scheduleReport: cron honors the schedule timezone (#1983)', async () => {
258
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
259
- // 09:00 America/New_York (UTC-5 in January) = 14:00Z; now=05:00 ET → same day.
260
- const s = await svc.scheduleReport({
261
- reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', timezone: 'America/New_York',
262
- }, CTX);
263
- expect(s.next_run_at).toBe('2026-01-15T14:00:00.000Z');
264
- });
265
-
266
- it('scheduleReport: rejects an invalid cron_expression (#1983)', async () => {
267
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
268
- await expect(svc.scheduleReport({
269
- reportId: r.id, recipients: ['x@t'], cronExpression: 'not a cron',
270
- }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
271
- });
272
-
273
- it('dispatchDue: advances a cron schedule to the next cron occurrence (#1983)', async () => {
274
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
275
- await svc.scheduleReport({
276
- reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', format: 'csv',
277
- }, CTX);
278
- // Force due, then dispatch at `now`.
279
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
280
- const result = await svc.dispatchDue();
281
- expect(result.fired).toBe(1);
282
- const advanced = engine._tables['sys_report_schedule'][0];
283
- expect(advanced.last_status).toBe('ok');
284
- // Advanced via cron (tomorrow 09:00Z), not now + interval.
285
- expect(advanced.next_run_at).toBe('2026-01-16T09:00:00.000Z');
286
- });
287
-
288
- it('listSchedules + unscheduleReport', async () => {
289
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
290
- const s = await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
291
- expect((await svc.listSchedules({ reportId: r.id }, CTX)).length).toBe(1);
292
- await svc.unscheduleReport(s.id, CTX);
293
- expect((await svc.listSchedules({ reportId: r.id }, CTX)).length).toBe(0);
294
- });
295
-
296
- it('dispatchDue: fires HTML schedule and emails inline html', async () => {
297
- const r = await svc.saveReport({
298
- name: 'Open leads', object: 'lead', query: { filter: { status: 'open' } },
299
- }, CTX);
300
- const s = await svc.scheduleReport({
301
- reportId: r.id, recipients: ['ops@t'], intervalMinutes: 60, format: 'html_table',
302
- subjectTemplate: '{{name}}: {{rows}} on {{date}}',
303
- }, CTX);
304
- // Force the schedule due.
305
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
306
-
307
- const result = await svc.dispatchDue();
308
- expect(result).toEqual({ fired: 1, failed: 0, skipped: 0 });
309
- expect(email._sent.length).toBe(1);
310
- expect(email._sent[0].subject).toBe('Open leads: 2 on 2026-01-15');
311
- expect(email._sent[0].html).toContain('<table');
312
- expect(email._sent[0].relatedObject).toBe('sys_report_schedule');
313
- expect(email._sent[0].relatedId).toBe(s.id);
314
-
315
- const advanced = engine._tables['sys_report_schedule'][0];
316
- expect(advanced.last_status).toBe('ok');
317
- expect(advanced.last_sent_at).toBe(now.toISOString());
318
- expect(advanced.next_run_at).toBe(new Date(now.getTime() + 60 * 60_000).toISOString());
319
- });
320
-
321
- it('dispatchDue: csv schedule attaches a file', async () => {
322
- const r = await svc.saveReport({ name: 'Open', object: 'lead', query: {} }, CTX);
323
- await svc.scheduleReport({
324
- reportId: r.id, recipients: ['ops@t'], intervalMinutes: 60, format: 'csv',
325
- }, CTX);
326
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
327
-
328
- await svc.dispatchDue();
329
- expect(email._sent[0].attachments?.[0].filename).toMatch(/\.csv$/);
330
- expect(email._sent[0].attachments?.[0].contentType).toBe('text/csv');
331
- expect(email._sent[0].attachments?.[0].content).toContain('Acme');
332
- });
333
-
334
- it('dispatchDue: marks skipped when report disappeared', async () => {
335
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
336
- await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
337
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
338
- // Nuke the report row out of band.
339
- engine._tables['sys_saved_report'] = [];
340
-
341
- const result = await svc.dispatchDue();
342
- expect(result.skipped).toBe(1);
343
- expect(result.fired).toBe(0);
344
- expect(engine._tables['sys_report_schedule'][0].last_status).toBe('skipped');
345
- expect(email._sent.length).toBe(0);
346
- });
347
-
348
- it('dispatchDue: skips schedules not yet due', async () => {
349
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
350
- await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'], intervalMinutes: 60 }, CTX);
351
- // next_run_at is now + 1h, so dispatching at `now` should skip it.
352
- const result = await svc.dispatchDue();
353
- expect(result.fired).toBe(0);
354
- expect(email._sent.length).toBe(0);
355
- });
356
-
357
- it('dispatchDue: marks failed when engine.find throws on the target', async () => {
358
- const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
359
- await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
360
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
361
-
362
- // Patch find to throw only for the lead object.
363
- const originalFind = engine.find.bind(engine);
364
- engine.find = vi.fn(async (object: string, opts: any) => {
365
- if (object === 'lead') throw new Error('boom');
366
- return originalFind(object, opts);
367
- }) as any;
368
-
369
- const result = await svc.dispatchDue();
370
- expect(result.failed).toBe(1);
371
- expect(engine._tables['sys_report_schedule'][0].last_status).toBe('failed');
372
- expect(engine._tables['sys_report_schedule'][0].last_error).toContain('boom');
373
- });
374
-
375
- it('dispatchDue: still runs (no mail) when email service absent', async () => {
376
- const svcNoMail = new ReportService({
377
- engine: engine as any,
378
- clock: { now: () => now },
379
- resolveOwnerContext: async (ownerId: string) => ({ userId: ownerId, positions: [], permissions: [] }),
380
- });
381
- const r = await svcNoMail.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
382
- await svcNoMail.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
383
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
384
-
385
- const result = await svcNoMail.dispatchDue();
386
- expect(result.fired).toBe(1);
387
- expect(engine._tables['sys_report_schedule'][0].last_status).toBe('ok');
388
- });
389
-
390
- // ─── Authorization (#2980) ──────────────────────────────────────
391
- describe('access control', () => {
392
- const OTHER = { userId: 'u2', tenantId: 't1', positions: [], permissions: [] };
393
-
394
- it('getReport: a non-owner cannot read another user\'s report (not-found)', async () => {
395
- const r = await svc.saveReport({ name: 'Mine', object: 'lead', query: {} }, CTX);
396
- expect(await svc.getReport(r.id, CTX)).not.toBeNull(); // owner sees it
397
- expect(await svc.getReport(r.id, OTHER)).toBeNull(); // stranger cannot
398
- });
399
-
400
- it('deleteReport: a non-owner cannot delete another user\'s report', async () => {
401
- const r = await svc.saveReport({ name: 'Mine', object: 'lead', query: {} }, CTX);
402
- await expect(svc.deleteReport(r.id, OTHER)).rejects.toThrow(/REPORT_NOT_FOUND/);
403
- expect(engine._tables['sys_saved_report'].length).toBe(1); // still there
404
- await svc.deleteReport(r.id, CTX); // owner can
405
- expect(engine._tables['sys_saved_report'].length).toBe(0);
406
- });
407
-
408
- it('saveReport: a non-owner cannot overwrite another user\'s report by id', async () => {
409
- const r = await svc.saveReport({ name: 'Mine', object: 'lead', query: {} }, CTX);
410
- await expect(
411
- svc.saveReport({ id: r.id, name: 'Hijacked', object: 'lead', query: {} }, OTHER),
412
- ).rejects.toThrow(/REPORT_NOT_FOUND/);
413
- expect(engine._tables['sys_saved_report'][0].name).toBe('Mine');
414
- });
415
-
416
- it('saveReport: a caller cannot assign ownership to someone else on create', async () => {
417
- const r = await svc.saveReport(
418
- { name: 'X', object: 'lead', query: {}, ownerId: 'victim' } as any,
419
- CTX,
420
- );
421
- expect(r.owner_id).toBe('u1');
422
- });
423
-
424
- it('listReports: only the caller\'s own reports are returned', async () => {
425
- await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX); // u1
426
- await svc.saveReport({ name: 'B', object: 'lead', query: {} }, OTHER); // u2
427
- const mine = await svc.listReports({}, CTX);
428
- expect(mine.map(r => r.name)).toEqual(['A']);
429
- const theirs = await svc.listReports({}, OTHER);
430
- expect(theirs.map(r => r.name)).toEqual(['B']);
431
- });
432
-
433
- it('listReports: a caller-supplied ownerId cannot widen past the caller', async () => {
434
- await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
435
- await svc.saveReport({ name: 'B', object: 'lead', query: {} }, OTHER);
436
- expect(await svc.listReports({ ownerId: 'u2' }, CTX)).toEqual([]); // u1 asking for u2 ⇒ nothing
437
- });
438
-
439
- it('system context sees all reports (scheduler / tooling path)', async () => {
440
- await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
441
- await svc.saveReport({ name: 'B', object: 'lead', query: {} }, OTHER);
442
- const all = await svc.listReports({}, { isSystem: true } as any);
443
- expect(all.length).toBe(2);
444
- });
445
-
446
- it('dispatchDue: fails closed (no RLS bypass) when no owner resolver is configured', async () => {
447
- const noResolver = new ReportService({ engine: engine as any, email, clock: { now: () => now } });
448
- const r = await noResolver.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
449
- await noResolver.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
450
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
451
-
452
- const result = await noResolver.dispatchDue();
453
- expect(result.fired).toBe(0);
454
- expect(result.failed).toBe(1);
455
- expect(email._sent.length).toBe(0); // nothing emailed — no elevated run
456
- expect(engine._tables['sys_report_schedule'][0].last_status).toBe('failed');
457
- expect(engine._tables['sys_report_schedule'][0].last_error).toMatch(/RLS bypassed/);
458
- });
459
-
460
- it('dispatchDue: runs under the owner context the resolver returns', async () => {
461
- const seen: Array<string | undefined> = [];
462
- const spySvc = new ReportService({
463
- engine: engine as any, email, clock: { now: () => now },
464
- resolveOwnerContext: async (ownerId) => { seen.push(ownerId); return { userId: ownerId, positions: [], permissions: [] }; },
465
- });
466
- const r = await spySvc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
467
- await spySvc.scheduleReport({ reportId: r.id, recipients: ['x@t'], format: 'csv' }, CTX);
468
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
469
-
470
- const result = await spySvc.dispatchDue();
471
- expect(result.fired).toBe(1);
472
- expect(seen).toEqual(['u1']); // resolved the owner, not a system context
473
- });
474
- });
475
- });