@friggframework/admin-scripts 2.0.0--canary.517.41839c5.0
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/LICENSE.md +9 -0
- package/index.js +66 -0
- package/package.json +53 -0
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +322 -0
- package/src/adapters/__tests__/local-scheduler-adapter.test.js +325 -0
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +257 -0
- package/src/adapters/__tests__/scheduler-adapter.test.js +103 -0
- package/src/adapters/aws-scheduler-adapter.js +138 -0
- package/src/adapters/local-scheduler-adapter.js +103 -0
- package/src/adapters/scheduler-adapter-factory.js +69 -0
- package/src/adapters/scheduler-adapter.js +64 -0
- package/src/application/__tests__/admin-frigg-commands.test.js +643 -0
- package/src/application/__tests__/admin-script-base.test.js +273 -0
- package/src/application/__tests__/dry-run-http-interceptor.test.js +313 -0
- package/src/application/__tests__/dry-run-repository-wrapper.test.js +257 -0
- package/src/application/__tests__/schedule-management-use-case.test.js +276 -0
- package/src/application/__tests__/script-factory.test.js +381 -0
- package/src/application/__tests__/script-runner.test.js +202 -0
- package/src/application/admin-frigg-commands.js +242 -0
- package/src/application/admin-script-base.js +138 -0
- package/src/application/dry-run-http-interceptor.js +296 -0
- package/src/application/dry-run-repository-wrapper.js +261 -0
- package/src/application/schedule-management-use-case.js +230 -0
- package/src/application/script-factory.js +161 -0
- package/src/application/script-runner.js +254 -0
- package/src/builtins/__tests__/integration-health-check.test.js +598 -0
- package/src/builtins/__tests__/oauth-token-refresh.test.js +344 -0
- package/src/builtins/index.js +28 -0
- package/src/builtins/integration-health-check.js +279 -0
- package/src/builtins/oauth-token-refresh.js +221 -0
- package/src/infrastructure/__tests__/admin-auth-middleware.test.js +148 -0
- package/src/infrastructure/__tests__/admin-script-router.test.js +701 -0
- package/src/infrastructure/admin-auth-middleware.js +49 -0
- package/src/infrastructure/admin-script-router.js +311 -0
- package/src/infrastructure/script-executor-handler.js +75 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { app } = require('../admin-script-router');
|
|
3
|
+
const { AdminScriptBase } = require('../../application/admin-script-base');
|
|
4
|
+
|
|
5
|
+
// Mock dependencies
|
|
6
|
+
jest.mock('../admin-auth-middleware', () => ({
|
|
7
|
+
adminAuthMiddleware: (req, res, next) => {
|
|
8
|
+
// Mock auth - attach admin audit info
|
|
9
|
+
req.adminAudit = {
|
|
10
|
+
apiKeyName: 'test-key',
|
|
11
|
+
apiKeyLast4: '1234',
|
|
12
|
+
ipAddress: '127.0.0.1',
|
|
13
|
+
};
|
|
14
|
+
next();
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
jest.mock('../../application/script-factory');
|
|
19
|
+
jest.mock('../../application/script-runner');
|
|
20
|
+
jest.mock('@friggframework/core/application/commands/admin-script-commands');
|
|
21
|
+
jest.mock('@friggframework/core/queues');
|
|
22
|
+
jest.mock('../../adapters/scheduler-adapter-factory');
|
|
23
|
+
|
|
24
|
+
const { getScriptFactory } = require('../../application/script-factory');
|
|
25
|
+
const { createScriptRunner } = require('../../application/script-runner');
|
|
26
|
+
const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
|
|
27
|
+
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
28
|
+
const { createSchedulerAdapter } = require('../../adapters/scheduler-adapter-factory');
|
|
29
|
+
|
|
30
|
+
describe('Admin Script Router', () => {
|
|
31
|
+
let mockFactory;
|
|
32
|
+
let mockRunner;
|
|
33
|
+
let mockCommands;
|
|
34
|
+
let mockSchedulerAdapter;
|
|
35
|
+
|
|
36
|
+
class TestScript extends AdminScriptBase {
|
|
37
|
+
static Definition = {
|
|
38
|
+
name: 'test-script',
|
|
39
|
+
version: '1.0.0',
|
|
40
|
+
description: 'Test script',
|
|
41
|
+
config: { timeout: 300000 },
|
|
42
|
+
display: { category: 'test' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async execute(frigg, params) {
|
|
46
|
+
return { success: true, params };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mockFactory = {
|
|
52
|
+
getAll: jest.fn(),
|
|
53
|
+
has: jest.fn(),
|
|
54
|
+
get: jest.fn(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
mockRunner = {
|
|
58
|
+
execute: jest.fn(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
mockCommands = {
|
|
62
|
+
createScriptExecution: jest.fn(),
|
|
63
|
+
findScriptExecutionById: jest.fn(),
|
|
64
|
+
findRecentExecutions: jest.fn(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
mockSchedulerAdapter = {
|
|
68
|
+
createSchedule: jest.fn(),
|
|
69
|
+
deleteSchedule: jest.fn(),
|
|
70
|
+
setScheduleEnabled: jest.fn(),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
getScriptFactory.mockReturnValue(mockFactory);
|
|
74
|
+
createScriptRunner.mockReturnValue(mockRunner);
|
|
75
|
+
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
76
|
+
createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter);
|
|
77
|
+
QueuerUtil.send = jest.fn().mockResolvedValue({});
|
|
78
|
+
|
|
79
|
+
// Default mock implementations
|
|
80
|
+
mockFactory.getAll.mockReturnValue([
|
|
81
|
+
{
|
|
82
|
+
name: 'test-script',
|
|
83
|
+
definition: TestScript.Definition,
|
|
84
|
+
class: TestScript,
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
mockFactory.has.mockReturnValue(true);
|
|
89
|
+
mockFactory.get.mockReturnValue(TestScript);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
jest.clearAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('GET /admin/scripts', () => {
|
|
97
|
+
it('should list all registered scripts', async () => {
|
|
98
|
+
const response = await request(app).get('/admin/scripts');
|
|
99
|
+
|
|
100
|
+
expect(response.status).toBe(200);
|
|
101
|
+
expect(response.body.scripts).toHaveLength(1);
|
|
102
|
+
expect(response.body.scripts[0]).toEqual({
|
|
103
|
+
name: 'test-script',
|
|
104
|
+
version: '1.0.0',
|
|
105
|
+
description: 'Test script',
|
|
106
|
+
category: 'test',
|
|
107
|
+
requiresIntegrationFactory: false,
|
|
108
|
+
schedule: null,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should handle errors gracefully', async () => {
|
|
113
|
+
mockFactory.getAll.mockImplementation(() => {
|
|
114
|
+
throw new Error('Factory error');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const response = await request(app).get('/admin/scripts');
|
|
118
|
+
|
|
119
|
+
expect(response.status).toBe(500);
|
|
120
|
+
expect(response.body.error).toBe('Failed to list scripts');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('GET /admin/scripts/:scriptName', () => {
|
|
125
|
+
it('should return script details', async () => {
|
|
126
|
+
const response = await request(app).get('/admin/scripts/test-script');
|
|
127
|
+
|
|
128
|
+
expect(response.status).toBe(200);
|
|
129
|
+
expect(response.body.name).toBe('test-script');
|
|
130
|
+
expect(response.body.version).toBe('1.0.0');
|
|
131
|
+
expect(response.body.description).toBe('Test script');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return 404 for non-existent script', async () => {
|
|
135
|
+
mockFactory.has.mockReturnValue(false);
|
|
136
|
+
|
|
137
|
+
const response = await request(app).get(
|
|
138
|
+
'/admin/scripts/non-existent-script'
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(response.status).toBe(404);
|
|
142
|
+
expect(response.body.code).toBe('SCRIPT_NOT_FOUND');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('POST /admin/scripts/:scriptName/execute', () => {
|
|
147
|
+
it('should execute script synchronously', async () => {
|
|
148
|
+
mockRunner.execute.mockResolvedValue({
|
|
149
|
+
executionId: 'exec-123',
|
|
150
|
+
status: 'COMPLETED',
|
|
151
|
+
scriptName: 'test-script',
|
|
152
|
+
output: { success: true },
|
|
153
|
+
metrics: { durationMs: 100 },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const response = await request(app)
|
|
157
|
+
.post('/admin/scripts/test-script/execute')
|
|
158
|
+
.send({
|
|
159
|
+
params: { foo: 'bar' },
|
|
160
|
+
mode: 'sync',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(response.status).toBe(200);
|
|
164
|
+
expect(response.body.status).toBe('COMPLETED');
|
|
165
|
+
expect(response.body.executionId).toBe('exec-123');
|
|
166
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
167
|
+
'test-script',
|
|
168
|
+
{ foo: 'bar' },
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
trigger: 'MANUAL',
|
|
171
|
+
mode: 'sync',
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should queue script for async execution', async () => {
|
|
177
|
+
mockCommands.createScriptExecution.mockResolvedValue({
|
|
178
|
+
id: 'exec-456',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const response = await request(app)
|
|
182
|
+
.post('/admin/scripts/test-script/execute')
|
|
183
|
+
.send({
|
|
184
|
+
params: { foo: 'bar' },
|
|
185
|
+
mode: 'async',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(response.status).toBe(202);
|
|
189
|
+
expect(response.body.status).toBe('PENDING');
|
|
190
|
+
expect(response.body.executionId).toBe('exec-456');
|
|
191
|
+
expect(QueuerUtil.send).toHaveBeenCalledWith(
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
scriptName: 'test-script',
|
|
194
|
+
executionId: 'exec-456',
|
|
195
|
+
}),
|
|
196
|
+
process.env.ADMIN_SCRIPT_QUEUE_URL
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should default to async mode', async () => {
|
|
201
|
+
mockCommands.createScriptExecution.mockResolvedValue({
|
|
202
|
+
id: 'exec-789',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const response = await request(app)
|
|
206
|
+
.post('/admin/scripts/test-script/execute')
|
|
207
|
+
.send({
|
|
208
|
+
params: { foo: 'bar' },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(response.status).toBe(202);
|
|
212
|
+
expect(response.body.status).toBe('PENDING');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should return 404 for non-existent script', async () => {
|
|
216
|
+
mockFactory.has.mockReturnValue(false);
|
|
217
|
+
|
|
218
|
+
const response = await request(app)
|
|
219
|
+
.post('/admin/scripts/non-existent/execute')
|
|
220
|
+
.send({
|
|
221
|
+
params: {},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(response.status).toBe(404);
|
|
225
|
+
expect(response.body.code).toBe('SCRIPT_NOT_FOUND');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('GET /admin/executions/:executionId', () => {
|
|
230
|
+
it('should return execution details', async () => {
|
|
231
|
+
mockCommands.findScriptExecutionById.mockResolvedValue({
|
|
232
|
+
id: 'exec-123',
|
|
233
|
+
scriptName: 'test-script',
|
|
234
|
+
status: 'COMPLETED',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const response = await request(app).get('/admin/executions/exec-123');
|
|
238
|
+
|
|
239
|
+
expect(response.status).toBe(200);
|
|
240
|
+
expect(response.body.id).toBe('exec-123');
|
|
241
|
+
expect(response.body.scriptName).toBe('test-script');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should return 404 for non-existent execution', async () => {
|
|
245
|
+
mockCommands.findScriptExecutionById.mockResolvedValue({
|
|
246
|
+
error: 404,
|
|
247
|
+
reason: 'Execution not found',
|
|
248
|
+
code: 'EXECUTION_NOT_FOUND',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const response = await request(app).get(
|
|
252
|
+
'/admin/executions/non-existent'
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(response.status).toBe(404);
|
|
256
|
+
expect(response.body.code).toBe('EXECUTION_NOT_FOUND');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('GET /admin/executions', () => {
|
|
261
|
+
it('should list recent executions', async () => {
|
|
262
|
+
mockCommands.findRecentExecutions.mockResolvedValue([
|
|
263
|
+
{ id: 'exec-1', scriptName: 'test-script', status: 'COMPLETED' },
|
|
264
|
+
{ id: 'exec-2', scriptName: 'test-script', status: 'RUNNING' },
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
const response = await request(app).get('/admin/executions');
|
|
268
|
+
|
|
269
|
+
expect(response.status).toBe(200);
|
|
270
|
+
expect(response.body.executions).toHaveLength(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should accept query parameters', async () => {
|
|
274
|
+
mockCommands.findRecentExecutions.mockResolvedValue([]);
|
|
275
|
+
|
|
276
|
+
await request(app).get(
|
|
277
|
+
'/admin/executions?scriptName=test-script&status=COMPLETED&limit=10'
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({
|
|
281
|
+
scriptName: 'test-script',
|
|
282
|
+
status: 'COMPLETED',
|
|
283
|
+
limit: 10,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('GET /admin/scripts/:scriptName/schedule', () => {
|
|
289
|
+
it('should return database schedule when override exists', async () => {
|
|
290
|
+
const dbSchedule = {
|
|
291
|
+
scriptName: 'test-script',
|
|
292
|
+
enabled: true,
|
|
293
|
+
cronExpression: '0 9 * * *',
|
|
294
|
+
timezone: 'America/New_York',
|
|
295
|
+
lastTriggeredAt: new Date('2025-01-01T09:00:00Z'),
|
|
296
|
+
nextTriggerAt: new Date('2025-01-02T09:00:00Z'),
|
|
297
|
+
awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test',
|
|
298
|
+
awsScheduleName: 'test-script-schedule',
|
|
299
|
+
createdAt: new Date('2025-01-01T00:00:00Z'),
|
|
300
|
+
updatedAt: new Date('2025-01-01T00:00:00Z'),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(dbSchedule);
|
|
304
|
+
|
|
305
|
+
const response = await request(app).get('/admin/scripts/test-script/schedule');
|
|
306
|
+
|
|
307
|
+
expect(response.status).toBe(200);
|
|
308
|
+
expect(response.body.source).toBe('database');
|
|
309
|
+
expect(response.body.enabled).toBe(true);
|
|
310
|
+
expect(response.body.cronExpression).toBe('0 9 * * *');
|
|
311
|
+
expect(response.body.timezone).toBe('America/New_York');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should return definition schedule when no database override', async () => {
|
|
315
|
+
mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(null);
|
|
316
|
+
|
|
317
|
+
// Update test script to include schedule
|
|
318
|
+
class ScheduledTestScript extends TestScript {
|
|
319
|
+
static Definition = {
|
|
320
|
+
...TestScript.Definition,
|
|
321
|
+
schedule: {
|
|
322
|
+
enabled: true,
|
|
323
|
+
cronExpression: '0 0 * * *',
|
|
324
|
+
timezone: 'UTC',
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
mockFactory.get.mockReturnValue(ScheduledTestScript);
|
|
330
|
+
|
|
331
|
+
const response = await request(app).get('/admin/scripts/test-script/schedule');
|
|
332
|
+
|
|
333
|
+
expect(response.status).toBe(200);
|
|
334
|
+
expect(response.body.source).toBe('definition');
|
|
335
|
+
expect(response.body.enabled).toBe(true);
|
|
336
|
+
expect(response.body.cronExpression).toBe('0 0 * * *');
|
|
337
|
+
expect(response.body.timezone).toBe('UTC');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should return none when no schedule configured', async () => {
|
|
341
|
+
mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(null);
|
|
342
|
+
|
|
343
|
+
const response = await request(app).get('/admin/scripts/test-script/schedule');
|
|
344
|
+
|
|
345
|
+
expect(response.status).toBe(200);
|
|
346
|
+
expect(response.body.source).toBe('none');
|
|
347
|
+
expect(response.body.enabled).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should return 404 for non-existent script', async () => {
|
|
351
|
+
mockFactory.has.mockReturnValue(false);
|
|
352
|
+
|
|
353
|
+
const response = await request(app).get(
|
|
354
|
+
'/admin/scripts/non-existent/schedule'
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(response.status).toBe(404);
|
|
358
|
+
expect(response.body.code).toBe('SCRIPT_NOT_FOUND');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('PUT /admin/scripts/:scriptName/schedule', () => {
|
|
363
|
+
it('should create new schedule', async () => {
|
|
364
|
+
const newSchedule = {
|
|
365
|
+
scriptName: 'test-script',
|
|
366
|
+
enabled: true,
|
|
367
|
+
cronExpression: '0 12 * * *',
|
|
368
|
+
timezone: 'America/Los_Angeles',
|
|
369
|
+
lastTriggeredAt: null,
|
|
370
|
+
nextTriggerAt: null,
|
|
371
|
+
createdAt: new Date(),
|
|
372
|
+
updatedAt: new Date(),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule);
|
|
376
|
+
|
|
377
|
+
const response = await request(app)
|
|
378
|
+
.put('/admin/scripts/test-script/schedule')
|
|
379
|
+
.send({
|
|
380
|
+
enabled: true,
|
|
381
|
+
cronExpression: '0 12 * * *',
|
|
382
|
+
timezone: 'America/Los_Angeles',
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(response.status).toBe(200);
|
|
386
|
+
expect(response.body.success).toBe(true);
|
|
387
|
+
expect(response.body.schedule.source).toBe('database');
|
|
388
|
+
expect(response.body.schedule.enabled).toBe(true);
|
|
389
|
+
expect(response.body.schedule.cronExpression).toBe('0 12 * * *');
|
|
390
|
+
expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({
|
|
391
|
+
scriptName: 'test-script',
|
|
392
|
+
enabled: true,
|
|
393
|
+
cronExpression: '0 12 * * *',
|
|
394
|
+
timezone: 'America/Los_Angeles',
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should update existing schedule', async () => {
|
|
399
|
+
const updatedSchedule = {
|
|
400
|
+
scriptName: 'test-script',
|
|
401
|
+
enabled: false,
|
|
402
|
+
cronExpression: null,
|
|
403
|
+
timezone: 'UTC',
|
|
404
|
+
lastTriggeredAt: new Date('2025-01-01T09:00:00Z'),
|
|
405
|
+
nextTriggerAt: null,
|
|
406
|
+
createdAt: new Date('2025-01-01T00:00:00Z'),
|
|
407
|
+
updatedAt: new Date(),
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
mockCommands.upsertSchedule = jest.fn().mockResolvedValue(updatedSchedule);
|
|
411
|
+
|
|
412
|
+
const response = await request(app)
|
|
413
|
+
.put('/admin/scripts/test-script/schedule')
|
|
414
|
+
.send({
|
|
415
|
+
enabled: false,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(response.status).toBe(200);
|
|
419
|
+
expect(response.body.success).toBe(true);
|
|
420
|
+
expect(response.body.schedule.enabled).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should require enabled field', async () => {
|
|
424
|
+
const response = await request(app)
|
|
425
|
+
.put('/admin/scripts/test-script/schedule')
|
|
426
|
+
.send({
|
|
427
|
+
cronExpression: '0 12 * * *',
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(response.status).toBe(400);
|
|
431
|
+
expect(response.body.code).toBe('INVALID_INPUT');
|
|
432
|
+
expect(response.body.error).toContain('enabled');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should require cronExpression when enabled is true', async () => {
|
|
436
|
+
const response = await request(app)
|
|
437
|
+
.put('/admin/scripts/test-script/schedule')
|
|
438
|
+
.send({
|
|
439
|
+
enabled: true,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(response.status).toBe(400);
|
|
443
|
+
expect(response.body.code).toBe('INVALID_INPUT');
|
|
444
|
+
expect(response.body.error).toContain('cronExpression');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should return 404 for non-existent script', async () => {
|
|
448
|
+
mockFactory.has.mockReturnValue(false);
|
|
449
|
+
|
|
450
|
+
const response = await request(app)
|
|
451
|
+
.put('/admin/scripts/non-existent/schedule')
|
|
452
|
+
.send({
|
|
453
|
+
enabled: true,
|
|
454
|
+
cronExpression: '0 12 * * *',
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(response.status).toBe(404);
|
|
458
|
+
expect(response.body.code).toBe('SCRIPT_NOT_FOUND');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('should provision EventBridge schedule when enabled', async () => {
|
|
462
|
+
const newSchedule = {
|
|
463
|
+
scriptName: 'test-script',
|
|
464
|
+
enabled: true,
|
|
465
|
+
cronExpression: '0 12 * * *',
|
|
466
|
+
timezone: 'America/Los_Angeles',
|
|
467
|
+
lastTriggeredAt: null,
|
|
468
|
+
nextTriggerAt: null,
|
|
469
|
+
createdAt: new Date(),
|
|
470
|
+
updatedAt: new Date(),
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule);
|
|
474
|
+
mockCommands.updateScheduleAwsInfo = jest.fn().mockResolvedValue(newSchedule);
|
|
475
|
+
mockSchedulerAdapter.createSchedule.mockResolvedValue({
|
|
476
|
+
scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
477
|
+
scheduleName: 'frigg-script-test-script',
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const response = await request(app)
|
|
481
|
+
.put('/admin/scripts/test-script/schedule')
|
|
482
|
+
.send({
|
|
483
|
+
enabled: true,
|
|
484
|
+
cronExpression: '0 12 * * *',
|
|
485
|
+
timezone: 'America/Los_Angeles',
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
expect(response.status).toBe(200);
|
|
489
|
+
expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({
|
|
490
|
+
scriptName: 'test-script',
|
|
491
|
+
cronExpression: '0 12 * * *',
|
|
492
|
+
timezone: 'America/Los_Angeles',
|
|
493
|
+
});
|
|
494
|
+
expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalledWith('test-script', {
|
|
495
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
496
|
+
awsScheduleName: 'frigg-script-test-script',
|
|
497
|
+
});
|
|
498
|
+
expect(response.body.schedule.awsScheduleArn).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should delete EventBridge schedule when disabling existing schedule', async () => {
|
|
502
|
+
const existingSchedule = {
|
|
503
|
+
scriptName: 'test-script',
|
|
504
|
+
enabled: false,
|
|
505
|
+
cronExpression: null,
|
|
506
|
+
timezone: 'UTC',
|
|
507
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
508
|
+
awsScheduleName: 'frigg-script-test-script',
|
|
509
|
+
createdAt: new Date(),
|
|
510
|
+
updatedAt: new Date(),
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
mockCommands.upsertSchedule = jest.fn().mockResolvedValue(existingSchedule);
|
|
514
|
+
mockCommands.updateScheduleAwsInfo = jest.fn().mockResolvedValue(existingSchedule);
|
|
515
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
516
|
+
|
|
517
|
+
const response = await request(app)
|
|
518
|
+
.put('/admin/scripts/test-script/schedule')
|
|
519
|
+
.send({
|
|
520
|
+
enabled: false,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
expect(response.status).toBe(200);
|
|
524
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
525
|
+
expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalledWith('test-script', {
|
|
526
|
+
awsScheduleArn: null,
|
|
527
|
+
awsScheduleName: null,
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should handle scheduler errors gracefully (non-fatal)', async () => {
|
|
532
|
+
const newSchedule = {
|
|
533
|
+
scriptName: 'test-script',
|
|
534
|
+
enabled: true,
|
|
535
|
+
cronExpression: '0 12 * * *',
|
|
536
|
+
timezone: 'UTC',
|
|
537
|
+
createdAt: new Date(),
|
|
538
|
+
updatedAt: new Date(),
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule);
|
|
542
|
+
mockSchedulerAdapter.createSchedule.mockRejectedValue(new Error('AWS Scheduler API error'));
|
|
543
|
+
|
|
544
|
+
const response = await request(app)
|
|
545
|
+
.put('/admin/scripts/test-script/schedule')
|
|
546
|
+
.send({
|
|
547
|
+
enabled: true,
|
|
548
|
+
cronExpression: '0 12 * * *',
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Request should succeed despite scheduler error
|
|
552
|
+
expect(response.status).toBe(200);
|
|
553
|
+
expect(response.body.success).toBe(true);
|
|
554
|
+
expect(response.body.schedulerWarning).toBe('AWS Scheduler API error');
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe('DELETE /admin/scripts/:scriptName/schedule', () => {
|
|
559
|
+
it('should delete schedule override', async () => {
|
|
560
|
+
mockCommands.deleteSchedule = jest.fn().mockResolvedValue({
|
|
561
|
+
acknowledged: true,
|
|
562
|
+
deletedCount: 1,
|
|
563
|
+
deleted: {
|
|
564
|
+
scriptName: 'test-script',
|
|
565
|
+
enabled: true,
|
|
566
|
+
cronExpression: '0 12 * * *',
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const response = await request(app).delete(
|
|
571
|
+
'/admin/scripts/test-script/schedule'
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
expect(response.status).toBe(200);
|
|
575
|
+
expect(response.body.success).toBe(true);
|
|
576
|
+
expect(response.body.deletedCount).toBe(1);
|
|
577
|
+
expect(response.body.message).toContain('removed');
|
|
578
|
+
expect(mockCommands.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('should return definition schedule after deleting override', async () => {
|
|
582
|
+
mockCommands.deleteSchedule = jest.fn().mockResolvedValue({
|
|
583
|
+
acknowledged: true,
|
|
584
|
+
deletedCount: 1,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Update test script to include schedule
|
|
588
|
+
class ScheduledTestScript extends TestScript {
|
|
589
|
+
static Definition = {
|
|
590
|
+
...TestScript.Definition,
|
|
591
|
+
schedule: {
|
|
592
|
+
enabled: true,
|
|
593
|
+
cronExpression: '0 0 * * *',
|
|
594
|
+
timezone: 'UTC',
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
mockFactory.get.mockReturnValue(ScheduledTestScript);
|
|
600
|
+
|
|
601
|
+
const response = await request(app).delete(
|
|
602
|
+
'/admin/scripts/test-script/schedule'
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
expect(response.status).toBe(200);
|
|
606
|
+
expect(response.body.effectiveSchedule.source).toBe('definition');
|
|
607
|
+
expect(response.body.effectiveSchedule.enabled).toBe(true);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('should handle no schedule found', async () => {
|
|
611
|
+
mockCommands.deleteSchedule = jest.fn().mockResolvedValue({
|
|
612
|
+
acknowledged: true,
|
|
613
|
+
deletedCount: 0,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const response = await request(app).delete(
|
|
617
|
+
'/admin/scripts/test-script/schedule'
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
expect(response.status).toBe(200);
|
|
621
|
+
expect(response.body.deletedCount).toBe(0);
|
|
622
|
+
expect(response.body.message).toContain('No schedule override found');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('should return 404 for non-existent script', async () => {
|
|
626
|
+
mockFactory.has.mockReturnValue(false);
|
|
627
|
+
|
|
628
|
+
const response = await request(app).delete(
|
|
629
|
+
'/admin/scripts/non-existent/schedule'
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
expect(response.status).toBe(404);
|
|
633
|
+
expect(response.body.code).toBe('SCRIPT_NOT_FOUND');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('should delete EventBridge schedule when AWS rule exists', async () => {
|
|
637
|
+
mockCommands.deleteSchedule = jest.fn().mockResolvedValue({
|
|
638
|
+
acknowledged: true,
|
|
639
|
+
deletedCount: 1,
|
|
640
|
+
deleted: {
|
|
641
|
+
scriptName: 'test-script',
|
|
642
|
+
enabled: true,
|
|
643
|
+
cronExpression: '0 12 * * *',
|
|
644
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
645
|
+
awsScheduleName: 'frigg-script-test-script',
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
649
|
+
|
|
650
|
+
const response = await request(app).delete(
|
|
651
|
+
'/admin/scripts/test-script/schedule'
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
expect(response.status).toBe(200);
|
|
655
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should not call scheduler when no AWS rule exists', async () => {
|
|
659
|
+
mockCommands.deleteSchedule = jest.fn().mockResolvedValue({
|
|
660
|
+
acknowledged: true,
|
|
661
|
+
deletedCount: 1,
|
|
662
|
+
deleted: {
|
|
663
|
+
scriptName: 'test-script',
|
|
664
|
+
enabled: true,
|
|
665
|
+
cronExpression: '0 12 * * *',
|
|
666
|
+
// No awsScheduleArn
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const response = await request(app).delete(
|
|
671
|
+
'/admin/scripts/test-script/schedule'
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
expect(response.status).toBe(200);
|
|
675
|
+
expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should handle scheduler delete errors gracefully (non-fatal)', async () => {
|
|
679
|
+
mockCommands.deleteSchedule = jest.fn().mockResolvedValue({
|
|
680
|
+
acknowledged: true,
|
|
681
|
+
deletedCount: 1,
|
|
682
|
+
deleted: {
|
|
683
|
+
scriptName: 'test-script',
|
|
684
|
+
enabled: true,
|
|
685
|
+
cronExpression: '0 12 * * *',
|
|
686
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
mockSchedulerAdapter.deleteSchedule.mockRejectedValue(new Error('AWS Scheduler delete failed'));
|
|
690
|
+
|
|
691
|
+
const response = await request(app).delete(
|
|
692
|
+
'/admin/scripts/test-script/schedule'
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// Request should succeed despite scheduler error
|
|
696
|
+
expect(response.status).toBe(200);
|
|
697
|
+
expect(response.body.success).toBe(true);
|
|
698
|
+
expect(response.body.schedulerWarning).toBe('AWS Scheduler delete failed');
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
});
|