@friggframework/admin-scripts 2.0.0-next.103 → 2.0.0-next.105
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/index.js +14 -0
- package/package.json +6 -6
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +54 -0
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +49 -0
- package/src/adapters/aws-scheduler-adapter.js +21 -9
- package/src/adapters/scheduler-adapter-factory.js +40 -0
- package/src/application/__tests__/report-runner.test.js +428 -0
- package/src/application/admin-script-context.js +12 -0
- package/src/application/report-runner.js +273 -0
- package/src/infrastructure/__tests__/report-executor-handler.test.js +314 -0
- package/src/infrastructure/__tests__/report-router.test.js +525 -0
- package/src/infrastructure/bootstrap.js +95 -15
- package/src/infrastructure/report-executor-handler.js +202 -0
- package/src/infrastructure/report-router.js +421 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
|
|
3
|
+
jest.mock('../admin-auth-middleware', () => ({
|
|
4
|
+
validateAdminApiKey: (req, res, next) => next(),
|
|
5
|
+
}));
|
|
6
|
+
jest.mock('../bootstrap');
|
|
7
|
+
jest.mock('@friggframework/core/queues', () => ({
|
|
8
|
+
QueuerUtil: { send: jest.fn().mockResolvedValue({}) },
|
|
9
|
+
}));
|
|
10
|
+
jest.mock('@friggframework/core/application/commands/admin-script-commands');
|
|
11
|
+
jest.mock('../../adapters/scheduler-adapter-factory');
|
|
12
|
+
|
|
13
|
+
const { app } = require('../report-router');
|
|
14
|
+
const { bootstrapAdminScripts } = require('../bootstrap');
|
|
15
|
+
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
16
|
+
const {
|
|
17
|
+
createAdminScriptCommands,
|
|
18
|
+
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
19
|
+
const {
|
|
20
|
+
createReportSchedulerAdapterFromEnv,
|
|
21
|
+
} = require('../../adapters/scheduler-adapter-factory');
|
|
22
|
+
const { ScriptFactory } = require('../../application/script-factory');
|
|
23
|
+
|
|
24
|
+
class DemoReport {
|
|
25
|
+
static Definition = {
|
|
26
|
+
name: 'demo',
|
|
27
|
+
version: '1.0.0',
|
|
28
|
+
description: 'demo report',
|
|
29
|
+
runModes: ['live', 'recorded', 'snapshot'],
|
|
30
|
+
output: { format: 'json' },
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: { n: { type: 'integer' } },
|
|
34
|
+
},
|
|
35
|
+
display: { category: 'reporting' },
|
|
36
|
+
};
|
|
37
|
+
async execute(frigg, params) {
|
|
38
|
+
return { ok: true, n: params.n ?? 0 };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class IntegrationsReport {
|
|
43
|
+
static Definition = {
|
|
44
|
+
name: 'integrations',
|
|
45
|
+
version: '1.0.0',
|
|
46
|
+
description: 'integrations report',
|
|
47
|
+
runModes: ['live', 'recorded', 'snapshot'],
|
|
48
|
+
output: { format: 'json' },
|
|
49
|
+
display: { category: 'reporting' },
|
|
50
|
+
};
|
|
51
|
+
async execute(frigg, params) {
|
|
52
|
+
return { schemaVersion: 1, filters: params };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class ScheduledReport {
|
|
57
|
+
static Definition = {
|
|
58
|
+
name: 'scheduled',
|
|
59
|
+
version: '2.0.0',
|
|
60
|
+
runModes: ['recorded', 'snapshot'],
|
|
61
|
+
output: { format: 'json' },
|
|
62
|
+
// Declares a preferred scheduled run mode.
|
|
63
|
+
schedule: { mode: 'recorded' },
|
|
64
|
+
};
|
|
65
|
+
async execute() {
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('Report Router', () => {
|
|
71
|
+
let server;
|
|
72
|
+
let mockReportCommands;
|
|
73
|
+
let mockScheduleCommands;
|
|
74
|
+
let mockSchedulerAdapter;
|
|
75
|
+
|
|
76
|
+
beforeAll((done) => {
|
|
77
|
+
server = app.listen(0, done);
|
|
78
|
+
});
|
|
79
|
+
afterAll((done) => {
|
|
80
|
+
server.close(done);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
process.env.REPORT_QUEUE_URL = 'https://sqs.local/report-queue';
|
|
85
|
+
mockReportCommands = {
|
|
86
|
+
createExecution: jest
|
|
87
|
+
.fn()
|
|
88
|
+
.mockResolvedValue({ id: 'report-exec-1' }),
|
|
89
|
+
completeExecution: jest.fn().mockResolvedValue({ success: true }),
|
|
90
|
+
findExecutionById: jest.fn(),
|
|
91
|
+
findSnapshotSeries: jest.fn(),
|
|
92
|
+
};
|
|
93
|
+
bootstrapAdminScripts.mockReturnValue({
|
|
94
|
+
reportFactory: new ScriptFactory([
|
|
95
|
+
DemoReport,
|
|
96
|
+
IntegrationsReport,
|
|
97
|
+
ScheduledReport,
|
|
98
|
+
]),
|
|
99
|
+
reportFriggCommands: {},
|
|
100
|
+
reportCommands: mockReportCommands,
|
|
101
|
+
integrationFactory: null,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
mockScheduleCommands = {
|
|
105
|
+
getScheduleByScriptName: jest.fn().mockResolvedValue(null),
|
|
106
|
+
upsertSchedule: jest.fn(),
|
|
107
|
+
deleteSchedule: jest.fn(),
|
|
108
|
+
updateScheduleExternalInfo: jest.fn().mockResolvedValue({}),
|
|
109
|
+
};
|
|
110
|
+
mockSchedulerAdapter = {
|
|
111
|
+
createSchedule: jest.fn(),
|
|
112
|
+
deleteSchedule: jest.fn(),
|
|
113
|
+
};
|
|
114
|
+
createAdminScriptCommands.mockReturnValue(mockScheduleCommands);
|
|
115
|
+
createReportSchedulerAdapterFromEnv.mockReturnValue(
|
|
116
|
+
mockSchedulerAdapter
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
QueuerUtil.send.mockClear();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
delete process.env.REPORT_QUEUE_URL;
|
|
124
|
+
jest.clearAllMocks();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('GET /api/v2/reports lists report definitions', async () => {
|
|
128
|
+
const res = await request(server).get('/api/v2/reports');
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
expect(res.body.service).toBe('frigg-core-api');
|
|
131
|
+
const names = res.body.reports.map((r) => r.name).sort();
|
|
132
|
+
expect(names).toEqual(['demo', 'integrations', 'scheduled']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('GET /api/v2/reports/:name returns the definition detail', async () => {
|
|
136
|
+
const res = await request(server).get('/api/v2/reports/demo');
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
expect(res.body).toMatchObject({
|
|
139
|
+
name: 'demo',
|
|
140
|
+
runModes: ['live', 'recorded', 'snapshot'],
|
|
141
|
+
output: { format: 'json' },
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('GET /api/v2/reports/:name 404s an unknown report', async () => {
|
|
146
|
+
const res = await request(server).get('/api/v2/reports/nope');
|
|
147
|
+
expect(res.status).toBe(404);
|
|
148
|
+
expect(res.body.code).toBe('REPORT_NOT_FOUND');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('POST /:name/run live returns the runner envelope with inline output', async () => {
|
|
152
|
+
const res = await request(server)
|
|
153
|
+
.post('/api/v2/reports/demo/run')
|
|
154
|
+
.send({ mode: 'live', params: { n: 3 } });
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
expect(res.body).toMatchObject({
|
|
157
|
+
status: 'COMPLETED',
|
|
158
|
+
mode: 'live',
|
|
159
|
+
output: { ok: true, n: 3 },
|
|
160
|
+
});
|
|
161
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('POST /:name/run 400s invalid input', async () => {
|
|
165
|
+
const res = await request(server)
|
|
166
|
+
.post('/api/v2/reports/demo/run')
|
|
167
|
+
.send({ mode: 'live', params: { n: 'not-a-number' } });
|
|
168
|
+
expect(res.status).toBe(400);
|
|
169
|
+
expect(res.body.code).toBe('INVALID_INPUT');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('POST /:name/run recorded 400s invalid input WITHOUT creating a record or enqueueing', async () => {
|
|
173
|
+
const res = await request(server)
|
|
174
|
+
.post('/api/v2/reports/demo/run')
|
|
175
|
+
.send({ mode: 'recorded', params: { n: 'not-a-number' } });
|
|
176
|
+
|
|
177
|
+
expect(res.status).toBe(400);
|
|
178
|
+
expect(res.body.code).toBe('INVALID_INPUT');
|
|
179
|
+
// The bad request must be rejected up front, not persisted + queued.
|
|
180
|
+
expect(mockReportCommands.createExecution).not.toHaveBeenCalled();
|
|
181
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('POST /:name/run 400s an unsupported mode WITHOUT creating a record', async () => {
|
|
185
|
+
const res = await request(server)
|
|
186
|
+
.post('/api/v2/reports/demo/run')
|
|
187
|
+
.send({ mode: 'bogus', params: {} });
|
|
188
|
+
|
|
189
|
+
expect(res.status).toBe(400);
|
|
190
|
+
expect(res.body.code).toBe('INVALID_MODE');
|
|
191
|
+
expect(mockReportCommands.createExecution).not.toHaveBeenCalled();
|
|
192
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('POST /:name/run recorded creates a record and enqueues it (202)', async () => {
|
|
196
|
+
const res = await request(server)
|
|
197
|
+
.post('/api/v2/reports/demo/run')
|
|
198
|
+
.send({ mode: 'recorded', params: { n: 5 } });
|
|
199
|
+
|
|
200
|
+
expect(res.status).toBe(202);
|
|
201
|
+
expect(res.body).toEqual({
|
|
202
|
+
executionId: 'report-exec-1',
|
|
203
|
+
status: 'QUEUED',
|
|
204
|
+
reportName: 'demo',
|
|
205
|
+
});
|
|
206
|
+
expect(mockReportCommands.createExecution).toHaveBeenCalledWith(
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
reportName: 'demo',
|
|
209
|
+
reportVersion: '1.0.0',
|
|
210
|
+
trigger: 'MANUAL',
|
|
211
|
+
mode: 'recorded',
|
|
212
|
+
input: { n: 5 },
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
expect(QueuerUtil.send).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
reportName: 'demo',
|
|
218
|
+
executionId: 'report-exec-1',
|
|
219
|
+
mode: 'recorded',
|
|
220
|
+
trigger: 'MANUAL',
|
|
221
|
+
params: { n: 5 },
|
|
222
|
+
}),
|
|
223
|
+
'https://sqs.local/report-queue'
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('POST /:name/run snapshot forwards the seriesName', async () => {
|
|
228
|
+
const res = await request(server)
|
|
229
|
+
.post('/api/v2/reports/demo/run')
|
|
230
|
+
.send({ mode: 'snapshot', params: {}, seriesName: 'daily' });
|
|
231
|
+
|
|
232
|
+
expect(res.status).toBe(202);
|
|
233
|
+
expect(mockReportCommands.createExecution).toHaveBeenCalledWith(
|
|
234
|
+
expect.objectContaining({ mode: 'snapshot', seriesName: 'daily' })
|
|
235
|
+
);
|
|
236
|
+
expect(QueuerUtil.send).toHaveBeenCalledWith(
|
|
237
|
+
expect.objectContaining({ mode: 'snapshot', seriesName: 'daily' }),
|
|
238
|
+
'https://sqs.local/report-queue'
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('POST /:name/run 503s when REPORT_QUEUE_URL is not configured', async () => {
|
|
243
|
+
delete process.env.REPORT_QUEUE_URL;
|
|
244
|
+
const res = await request(server)
|
|
245
|
+
.post('/api/v2/reports/demo/run')
|
|
246
|
+
.send({ mode: 'recorded', params: {} });
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(503);
|
|
249
|
+
expect(res.body.code).toBe('QUEUE_NOT_CONFIGURED');
|
|
250
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('POST /:name/run surfaces a createExecution error and does not enqueue', async () => {
|
|
254
|
+
mockReportCommands.createExecution.mockResolvedValue({
|
|
255
|
+
error: 500,
|
|
256
|
+
reason: 'db down',
|
|
257
|
+
code: 'DB_ERROR',
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const res = await request(server)
|
|
261
|
+
.post('/api/v2/reports/demo/run')
|
|
262
|
+
.send({ mode: 'recorded', params: {} });
|
|
263
|
+
|
|
264
|
+
expect(res.status).toBe(500);
|
|
265
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('POST /:name/run marks the record FAILED if enqueue throws after createExecution', async () => {
|
|
269
|
+
QueuerUtil.send.mockRejectedValueOnce(new Error('sqs down'));
|
|
270
|
+
|
|
271
|
+
const res = await request(server)
|
|
272
|
+
.post('/api/v2/reports/demo/run')
|
|
273
|
+
.send({ mode: 'recorded', params: {} });
|
|
274
|
+
|
|
275
|
+
expect(res.status).toBe(500);
|
|
276
|
+
// The persisted record must be compensated, not left non-terminal.
|
|
277
|
+
expect(mockReportCommands.completeExecution).toHaveBeenCalledWith(
|
|
278
|
+
'report-exec-1',
|
|
279
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('GET /api/v2/reports/executions/:id returns the report execution', async () => {
|
|
284
|
+
mockReportCommands.findExecutionById.mockResolvedValue({
|
|
285
|
+
id: 'report-exec-1',
|
|
286
|
+
type: 'REPORT',
|
|
287
|
+
state: 'COMPLETED',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const res = await request(server).get(
|
|
291
|
+
'/api/v2/reports/executions/report-exec-1'
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(res.status).toBe(200);
|
|
295
|
+
expect(res.body).toMatchObject({
|
|
296
|
+
id: 'report-exec-1',
|
|
297
|
+
state: 'COMPLETED',
|
|
298
|
+
});
|
|
299
|
+
expect(mockReportCommands.findExecutionById).toHaveBeenCalledWith(
|
|
300
|
+
'report-exec-1'
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('GET /api/v2/reports/executions/:id 404s a non-report execution', async () => {
|
|
305
|
+
mockReportCommands.findExecutionById.mockResolvedValue({
|
|
306
|
+
error: 404,
|
|
307
|
+
reason: 'Report execution x not found',
|
|
308
|
+
code: 'EXECUTION_NOT_FOUND',
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const res = await request(server).get(
|
|
312
|
+
'/api/v2/reports/executions/x'
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(res.status).toBe(404);
|
|
316
|
+
expect(res.body.code).toBe('EXECUTION_NOT_FOUND');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('GET /api/v2/reports/:name/snapshots returns the series', async () => {
|
|
320
|
+
mockReportCommands.findSnapshotSeries.mockResolvedValue([
|
|
321
|
+
{ executionId: 'e1', capturedAt: '2026-01-01', summary: null },
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
const res = await request(server)
|
|
325
|
+
.get('/api/v2/reports/demo/snapshots')
|
|
326
|
+
.query({ from: '2026-01-01', to: '2026-02-01' });
|
|
327
|
+
|
|
328
|
+
expect(res.status).toBe(200);
|
|
329
|
+
expect(res.body).toEqual({
|
|
330
|
+
snapshots: [
|
|
331
|
+
{ executionId: 'e1', capturedAt: '2026-01-01', summary: null },
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
expect(mockReportCommands.findSnapshotSeries).toHaveBeenCalledWith(
|
|
335
|
+
'demo',
|
|
336
|
+
{ from: '2026-01-01', to: '2026-02-01' }
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('GET /api/v2/reports/:name/snapshots 404s an unknown report', async () => {
|
|
341
|
+
const res = await request(server).get(
|
|
342
|
+
'/api/v2/reports/nope/snapshots'
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(res.status).toBe(404);
|
|
346
|
+
expect(res.body.code).toBe('REPORT_NOT_FOUND');
|
|
347
|
+
expect(mockReportCommands.findSnapshotSeries).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('GET /api/v2/reports/integrations (back-compat) returns the payload directly', async () => {
|
|
351
|
+
const res = await request(server)
|
|
352
|
+
.get('/api/v2/reports/integrations')
|
|
353
|
+
.query({ status: 'ENABLED' });
|
|
354
|
+
expect(res.status).toBe(200);
|
|
355
|
+
// payload directly, not the runner envelope
|
|
356
|
+
expect(res.body).toMatchObject({
|
|
357
|
+
schemaVersion: 1,
|
|
358
|
+
filters: { status: 'ENABLED' },
|
|
359
|
+
});
|
|
360
|
+
expect(res.body.status).toBeUndefined();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('schedule routes', () => {
|
|
364
|
+
it('GET /:name/schedule returns the effective schedule (none when no override)', async () => {
|
|
365
|
+
mockScheduleCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
366
|
+
|
|
367
|
+
const res = await request(server).get(
|
|
368
|
+
'/api/v2/reports/demo/schedule'
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(res.status).toBe(200);
|
|
372
|
+
expect(res.body).toMatchObject({
|
|
373
|
+
source: 'none',
|
|
374
|
+
reportName: 'demo',
|
|
375
|
+
enabled: false,
|
|
376
|
+
});
|
|
377
|
+
expect(
|
|
378
|
+
mockScheduleCommands.getScheduleByScriptName
|
|
379
|
+
).toHaveBeenCalledWith('demo');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('GET /:name/schedule returns the DB override when present', async () => {
|
|
383
|
+
mockScheduleCommands.getScheduleByScriptName.mockResolvedValue({
|
|
384
|
+
scriptName: 'demo',
|
|
385
|
+
enabled: true,
|
|
386
|
+
cronExpression: '0 9 * * *',
|
|
387
|
+
timezone: 'UTC',
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const res = await request(server).get(
|
|
391
|
+
'/api/v2/reports/demo/schedule'
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(res.status).toBe(200);
|
|
395
|
+
expect(res.body).toMatchObject({
|
|
396
|
+
source: 'database',
|
|
397
|
+
reportName: 'demo',
|
|
398
|
+
enabled: true,
|
|
399
|
+
cronExpression: '0 9 * * *',
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('GET /:name/schedule 404s an unknown report', async () => {
|
|
404
|
+
const res = await request(server).get(
|
|
405
|
+
'/api/v2/reports/nope/schedule'
|
|
406
|
+
);
|
|
407
|
+
expect(res.status).toBe(404);
|
|
408
|
+
expect(res.body.code).toBe('REPORT_NOT_FOUND');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('PUT /:name/schedule creates the override and provisions the report scheduler', async () => {
|
|
412
|
+
mockScheduleCommands.upsertSchedule.mockResolvedValue({
|
|
413
|
+
scriptName: 'demo',
|
|
414
|
+
enabled: true,
|
|
415
|
+
cronExpression: '0 12 * * *',
|
|
416
|
+
timezone: 'UTC',
|
|
417
|
+
});
|
|
418
|
+
mockSchedulerAdapter.createSchedule.mockResolvedValue({
|
|
419
|
+
scheduleArn:
|
|
420
|
+
'arn:aws:scheduler:us-east-1:123:schedule/g/frigg-report-demo',
|
|
421
|
+
scheduleName: 'frigg-report-demo',
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const res = await request(server)
|
|
425
|
+
.put('/api/v2/reports/demo/schedule')
|
|
426
|
+
.send({ enabled: true, cronExpression: '0 12 * * *' });
|
|
427
|
+
|
|
428
|
+
expect(res.status).toBe(200);
|
|
429
|
+
expect(res.body.success).toBe(true);
|
|
430
|
+
expect(res.body.schedule).toMatchObject({
|
|
431
|
+
source: 'database',
|
|
432
|
+
enabled: true,
|
|
433
|
+
cronExpression: '0 12 * * *',
|
|
434
|
+
});
|
|
435
|
+
// Default scheduled mode is snapshot (no schedule.mode on demo).
|
|
436
|
+
expect(createReportSchedulerAdapterFromEnv).toHaveBeenCalledWith({
|
|
437
|
+
reportName: 'demo',
|
|
438
|
+
mode: 'snapshot',
|
|
439
|
+
});
|
|
440
|
+
expect(mockScheduleCommands.upsertSchedule).toHaveBeenCalledWith({
|
|
441
|
+
scriptName: 'demo',
|
|
442
|
+
enabled: true,
|
|
443
|
+
cronExpression: '0 12 * * *',
|
|
444
|
+
timezone: 'UTC',
|
|
445
|
+
});
|
|
446
|
+
expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({
|
|
447
|
+
scriptName: 'demo',
|
|
448
|
+
cronExpression: '0 12 * * *',
|
|
449
|
+
timezone: 'UTC',
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('PUT /:name/schedule uses the report Definition.schedule.mode when declared', async () => {
|
|
454
|
+
mockScheduleCommands.upsertSchedule.mockResolvedValue({
|
|
455
|
+
scriptName: 'scheduled',
|
|
456
|
+
enabled: true,
|
|
457
|
+
cronExpression: '0 6 * * *',
|
|
458
|
+
timezone: 'UTC',
|
|
459
|
+
});
|
|
460
|
+
mockSchedulerAdapter.createSchedule.mockResolvedValue({
|
|
461
|
+
scheduleArn: 'arn:...:frigg-report-scheduled',
|
|
462
|
+
scheduleName: 'frigg-report-scheduled',
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const res = await request(server)
|
|
466
|
+
.put('/api/v2/reports/scheduled/schedule')
|
|
467
|
+
.send({ enabled: true, cronExpression: '0 6 * * *' });
|
|
468
|
+
|
|
469
|
+
expect(res.status).toBe(200);
|
|
470
|
+
expect(createReportSchedulerAdapterFromEnv).toHaveBeenCalledWith({
|
|
471
|
+
reportName: 'scheduled',
|
|
472
|
+
mode: 'recorded',
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('PUT /:name/schedule 400s when enabled without a cronExpression', async () => {
|
|
477
|
+
const res = await request(server)
|
|
478
|
+
.put('/api/v2/reports/demo/schedule')
|
|
479
|
+
.send({ enabled: true });
|
|
480
|
+
|
|
481
|
+
expect(res.status).toBe(400);
|
|
482
|
+
expect(mockScheduleCommands.upsertSchedule).not.toHaveBeenCalled();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('PUT /:name/schedule 404s an unknown report', async () => {
|
|
486
|
+
const res = await request(server)
|
|
487
|
+
.put('/api/v2/reports/nope/schedule')
|
|
488
|
+
.send({ enabled: false });
|
|
489
|
+
expect(res.status).toBe(404);
|
|
490
|
+
expect(res.body.code).toBe('REPORT_NOT_FOUND');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('DELETE /:name/schedule removes the override and tears down the scheduler', async () => {
|
|
494
|
+
mockScheduleCommands.deleteSchedule.mockResolvedValue({
|
|
495
|
+
deletedCount: 1,
|
|
496
|
+
deleted: {
|
|
497
|
+
externalScheduleId: 'arn:...:frigg-report-demo',
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
501
|
+
|
|
502
|
+
const res = await request(server).delete(
|
|
503
|
+
'/api/v2/reports/demo/schedule'
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
expect(res.status).toBe(200);
|
|
507
|
+
expect(res.body.success).toBe(true);
|
|
508
|
+
expect(res.body.deletedCount).toBe(1);
|
|
509
|
+
expect(mockScheduleCommands.deleteSchedule).toHaveBeenCalledWith(
|
|
510
|
+
'demo'
|
|
511
|
+
);
|
|
512
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith(
|
|
513
|
+
'demo'
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('DELETE /:name/schedule 404s an unknown report', async () => {
|
|
518
|
+
const res = await request(server).delete(
|
|
519
|
+
'/api/v2/reports/nope/schedule'
|
|
520
|
+
);
|
|
521
|
+
expect(res.status).toBe(404);
|
|
522
|
+
expect(res.body.code).toBe('REPORT_NOT_FOUND');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
});
|