@objectstack/plugin-reports 4.0.1

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.
@@ -0,0 +1,322 @@
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
+ const { field, direction } = options.orderBy[0];
28
+ rows.sort((a, b) => {
29
+ const av = a[field]; const bv = b[field];
30
+ if (av === bv) return 0;
31
+ const cmp = av > bv ? 1 : -1;
32
+ return direction === 'desc' ? -cmp : cmp;
33
+ });
34
+ }
35
+ return rows.slice(0, options?.limit ?? 1000);
36
+ },
37
+ async insert(object: string, data: any) {
38
+ ensure(object).push({ ...data });
39
+ return { ...data };
40
+ },
41
+ async update(object: string, idOrData: any, _opts?: any) {
42
+ const data = typeof idOrData === 'object' ? idOrData : _opts;
43
+ const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
44
+ const table = ensure(object);
45
+ const i = table.findIndex(r => r.id === id);
46
+ if (i >= 0) table[i] = { ...table[i], ...data };
47
+ return table[i];
48
+ },
49
+ async delete(object: string, options?: any) {
50
+ const table = ensure(object);
51
+ const id = options?.where?.id ?? options?.id;
52
+ const i = table.findIndex(r => r.id === id);
53
+ if (i >= 0) table.splice(i, 1);
54
+ return { id };
55
+ },
56
+ };
57
+ }
58
+
59
+ function makeFakeEmail() {
60
+ const sent: any[] = [];
61
+ const email: ReportEmail & { _sent: any[] } = {
62
+ _sent: sent,
63
+ async send(input) { sent.push(input); return { status: 'sent' }; },
64
+ };
65
+ return email;
66
+ }
67
+
68
+ const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] };
69
+
70
+ // ─── Rendering ─────────────────────────────────────────────────────
71
+
72
+ describe('renderReport', () => {
73
+ it('csv: escapes quotes / commas / newlines per RFC 4180', () => {
74
+ const out = renderReport(
75
+ [{ a: 'x,y', b: 'has "q"', c: 'line\n2' }],
76
+ 'csv',
77
+ ['a', 'b', 'c'],
78
+ );
79
+ expect(out).toBe('a,b,c\r\n"x,y","has ""q""","line\n2"');
80
+ });
81
+
82
+ it('csv: header-only when no rows', () => {
83
+ expect(renderReport([], 'csv', ['a', 'b'])).toBe('a,b');
84
+ });
85
+
86
+ it('html_table: escapes HTML entities', () => {
87
+ const out = renderReport([{ name: '<script>' }], 'html_table', ['name']);
88
+ expect(out).toContain('&lt;script&gt;');
89
+ expect(out).not.toContain('<script>');
90
+ });
91
+
92
+ it('json: pretty-prints rows', () => {
93
+ const out = renderReport([{ a: 1 }], 'json');
94
+ expect(JSON.parse(out)).toEqual([{ a: 1 }]);
95
+ });
96
+
97
+ it('auto-detects fields from first 50 rows when none specified', () => {
98
+ const out = renderReport([{ a: 1 }, { b: 2 }], 'csv');
99
+ expect(out.split('\r\n')[0]).toMatch(/a|b/);
100
+ });
101
+ });
102
+
103
+ // ─── Service ──────────────────────────────────────────────────────
104
+
105
+ describe('ReportService', () => {
106
+ let engine: ReturnType<typeof makeFakeEngine>;
107
+ let email: ReturnType<typeof makeFakeEmail>;
108
+ let svc: ReportService;
109
+ const now = new Date('2026-01-15T10:00:00Z');
110
+
111
+ beforeEach(() => {
112
+ engine = makeFakeEngine();
113
+ email = makeFakeEmail();
114
+ svc = new ReportService({
115
+ engine: engine as any,
116
+ email,
117
+ clock: { now: () => now },
118
+ maxRows: 5000,
119
+ });
120
+ // seed the underlying object the report will query.
121
+ engine._tables['lead'] = [
122
+ { id: 'l1', name: 'Acme', status: 'open' },
123
+ { id: 'l2', name: 'Beta', status: 'open' },
124
+ { id: 'l3', name: 'Gamma', status: 'closed' },
125
+ ];
126
+ });
127
+
128
+ it('saveReport: creates with a generated id and serialises query', async () => {
129
+ const r = await svc.saveReport({
130
+ name: 'Open leads',
131
+ object: 'lead',
132
+ query: { filter: { status: 'open' } },
133
+ format: 'csv',
134
+ }, CTX);
135
+ expect(r.id).toMatch(/^rpt_/);
136
+ expect(r.name).toBe('Open leads');
137
+ expect(r.object_name).toBe('lead');
138
+ const stored = engine._tables['sys_saved_report']?.[0];
139
+ expect(stored?.query_json).toBe(JSON.stringify({ filter: { status: 'open' } }));
140
+ expect(stored?.owner_id).toBe('u1');
141
+ });
142
+
143
+ it('saveReport: upserts when id matches existing row', async () => {
144
+ const a = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
145
+ const b = await svc.saveReport({ id: a.id, name: 'A-renamed', object: 'lead', query: {} }, CTX);
146
+ expect(b.id).toBe(a.id);
147
+ expect(b.name).toBe('A-renamed');
148
+ expect(engine._tables['sys_saved_report'].length).toBe(1);
149
+ });
150
+
151
+ it('saveReport: rejects missing required fields', async () => {
152
+ await expect(svc.saveReport({ name: '', object: 'lead', query: {} }, CTX))
153
+ .rejects.toThrow(/VALIDATION_FAILED/);
154
+ await expect(svc.saveReport({ name: 'x', object: '', query: {} }, CTX))
155
+ .rejects.toThrow(/VALIDATION_FAILED/);
156
+ });
157
+
158
+ it('listReports: filters by object + owner', async () => {
159
+ await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
160
+ await svc.saveReport({ name: 'B', object: 'account', query: {} }, CTX);
161
+ const leads = await svc.listReports({ object: 'lead' }, CTX);
162
+ expect(leads.length).toBe(1);
163
+ expect(leads[0].name).toBe('A');
164
+ });
165
+
166
+ it('getReport: returns null on miss', async () => {
167
+ expect(await svc.getReport('nope', CTX)).toBeNull();
168
+ });
169
+
170
+ it('deleteReport: cascades to schedules', async () => {
171
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
172
+ await svc.scheduleReport({ reportId: r.id, recipients: ['x@test'] }, CTX);
173
+ expect(engine._tables['sys_report_schedule'].length).toBe(1);
174
+ await svc.deleteReport(r.id, CTX);
175
+ expect(engine._tables['sys_saved_report'].length).toBe(0);
176
+ expect(engine._tables['sys_report_schedule'].length).toBe(0);
177
+ });
178
+
179
+ it('run: executes query and stamps last_run_at/last_row_count', async () => {
180
+ const r = await svc.saveReport({
181
+ name: 'Open', object: 'lead', query: { filter: { status: 'open' } }, format: 'csv',
182
+ }, CTX);
183
+ const result = await svc.run(r.id, CTX);
184
+ expect(result.rowCount).toBe(2);
185
+ expect(result.format).toBe('csv');
186
+ expect(result.body).toContain('Acme');
187
+ const stored = engine._tables['sys_saved_report'][0];
188
+ expect(stored.last_row_count).toBe(2);
189
+ expect(stored.last_run_at).toBe(now.toISOString());
190
+ });
191
+
192
+ it('run: throws REPORT_NOT_FOUND for unknown id', async () => {
193
+ await expect(svc.run('nope', CTX)).rejects.toThrow(/REPORT_NOT_FOUND/);
194
+ });
195
+
196
+ it('runAdHoc: executes without stamping any row', async () => {
197
+ const result = await svc.runAdHoc({
198
+ name: 'temp', object: 'lead', query: {}, format: 'json',
199
+ }, CTX);
200
+ expect(result.rowCount).toBe(3);
201
+ expect(engine._tables['sys_saved_report']).toBeUndefined();
202
+ });
203
+
204
+ it('scheduleReport: requires non-empty recipients', async () => {
205
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
206
+ await expect(svc.scheduleReport({ reportId: r.id, recipients: [] }, CTX))
207
+ .rejects.toThrow(/VALIDATION_FAILED/);
208
+ });
209
+
210
+ it('scheduleReport: rejects unknown report', async () => {
211
+ await expect(svc.scheduleReport({ reportId: 'nope', recipients: ['x@t'] }, CTX))
212
+ .rejects.toThrow(/REPORT_NOT_FOUND/);
213
+ });
214
+
215
+ it('scheduleReport: computes next_run_at from interval', async () => {
216
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
217
+ const s = await svc.scheduleReport({
218
+ reportId: r.id, recipients: ['x@t'], intervalMinutes: 60, format: 'csv',
219
+ }, CTX);
220
+ const expected = new Date(now.getTime() + 60 * 60_000).toISOString();
221
+ expect(s.next_run_at).toBe(expected);
222
+ expect(s.recipients).toBe('x@t');
223
+ });
224
+
225
+ it('listSchedules + unscheduleReport', async () => {
226
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
227
+ const s = await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
228
+ expect((await svc.listSchedules({ reportId: r.id }, CTX)).length).toBe(1);
229
+ await svc.unscheduleReport(s.id, CTX);
230
+ expect((await svc.listSchedules({ reportId: r.id }, CTX)).length).toBe(0);
231
+ });
232
+
233
+ it('dispatchDue: fires HTML schedule and emails inline html', async () => {
234
+ const r = await svc.saveReport({
235
+ name: 'Open leads', object: 'lead', query: { filter: { status: 'open' } },
236
+ }, CTX);
237
+ const s = await svc.scheduleReport({
238
+ reportId: r.id, recipients: ['ops@t'], intervalMinutes: 60, format: 'html_table',
239
+ subjectTemplate: '{{name}}: {{rows}} on {{date}}',
240
+ }, CTX);
241
+ // Force the schedule due.
242
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
243
+
244
+ const result = await svc.dispatchDue();
245
+ expect(result).toEqual({ fired: 1, failed: 0, skipped: 0 });
246
+ expect(email._sent.length).toBe(1);
247
+ expect(email._sent[0].subject).toBe('Open leads: 2 on 2026-01-15');
248
+ expect(email._sent[0].html).toContain('<table');
249
+ expect(email._sent[0].relatedObject).toBe('sys_report_schedule');
250
+ expect(email._sent[0].relatedId).toBe(s.id);
251
+
252
+ const advanced = engine._tables['sys_report_schedule'][0];
253
+ expect(advanced.last_status).toBe('ok');
254
+ expect(advanced.last_sent_at).toBe(now.toISOString());
255
+ expect(advanced.next_run_at).toBe(new Date(now.getTime() + 60 * 60_000).toISOString());
256
+ });
257
+
258
+ it('dispatchDue: csv schedule attaches a file', async () => {
259
+ const r = await svc.saveReport({ name: 'Open', object: 'lead', query: {} }, CTX);
260
+ await svc.scheduleReport({
261
+ reportId: r.id, recipients: ['ops@t'], intervalMinutes: 60, format: 'csv',
262
+ }, CTX);
263
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
264
+
265
+ await svc.dispatchDue();
266
+ expect(email._sent[0].attachments?.[0].filename).toMatch(/\.csv$/);
267
+ expect(email._sent[0].attachments?.[0].contentType).toBe('text/csv');
268
+ expect(email._sent[0].attachments?.[0].content).toContain('Acme');
269
+ });
270
+
271
+ it('dispatchDue: marks skipped when report disappeared', async () => {
272
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
273
+ await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
274
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
275
+ // Nuke the report row out of band.
276
+ engine._tables['sys_saved_report'] = [];
277
+
278
+ const result = await svc.dispatchDue();
279
+ expect(result.skipped).toBe(1);
280
+ expect(result.fired).toBe(0);
281
+ expect(engine._tables['sys_report_schedule'][0].last_status).toBe('skipped');
282
+ expect(email._sent.length).toBe(0);
283
+ });
284
+
285
+ it('dispatchDue: skips schedules not yet due', async () => {
286
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
287
+ await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'], intervalMinutes: 60 }, CTX);
288
+ // next_run_at is now + 1h, so dispatching at `now` should skip it.
289
+ const result = await svc.dispatchDue();
290
+ expect(result.fired).toBe(0);
291
+ expect(email._sent.length).toBe(0);
292
+ });
293
+
294
+ it('dispatchDue: marks failed when engine.find throws on the target', async () => {
295
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
296
+ await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
297
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
298
+
299
+ // Patch find to throw only for the lead object.
300
+ const originalFind = engine.find.bind(engine);
301
+ engine.find = vi.fn(async (object: string, opts: any) => {
302
+ if (object === 'lead') throw new Error('boom');
303
+ return originalFind(object, opts);
304
+ }) as any;
305
+
306
+ const result = await svc.dispatchDue();
307
+ expect(result.failed).toBe(1);
308
+ expect(engine._tables['sys_report_schedule'][0].last_status).toBe('failed');
309
+ expect(engine._tables['sys_report_schedule'][0].last_error).toContain('boom');
310
+ });
311
+
312
+ it('dispatchDue: still runs (no mail) when email service absent', async () => {
313
+ const svcNoMail = new ReportService({ engine: engine as any, clock: { now: () => now } });
314
+ const r = await svcNoMail.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
315
+ await svcNoMail.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
316
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1).toISOString();
317
+
318
+ const result = await svcNoMail.dispatchDue();
319
+ expect(result.fired).toBe(1);
320
+ expect(engine._tables['sys_report_schedule'][0].last_status).toBe('ok');
321
+ });
322
+ });