@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,257 @@
|
|
|
1
|
+
const { createDryRunWrapper, wrapAdminFriggCommandsForDryRun, sanitizeArgs } = require('../dry-run-repository-wrapper');
|
|
2
|
+
|
|
3
|
+
describe('Dry-Run Repository Wrapper', () => {
|
|
4
|
+
describe('createDryRunWrapper', () => {
|
|
5
|
+
let mockRepository;
|
|
6
|
+
let operationLog;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
operationLog = [];
|
|
10
|
+
mockRepository = {
|
|
11
|
+
// Read operations
|
|
12
|
+
findById: jest.fn(async (id) => ({ id, name: 'Test Entity' })),
|
|
13
|
+
findAll: jest.fn(async () => [{ id: '1' }, { id: '2' }]),
|
|
14
|
+
getStatus: jest.fn(() => 'active'),
|
|
15
|
+
|
|
16
|
+
// Write operations
|
|
17
|
+
create: jest.fn(async (data) => ({ id: 'new-id', ...data })),
|
|
18
|
+
update: jest.fn(async (id, data) => ({ id, ...data })),
|
|
19
|
+
delete: jest.fn(async (id) => ({ deletedCount: 1 })),
|
|
20
|
+
updateStatus: jest.fn(async (id, status) => ({ id, status })),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('should pass through read operations unchanged', async () => {
|
|
25
|
+
const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
|
|
26
|
+
|
|
27
|
+
// Call read operations
|
|
28
|
+
const byId = await wrapped.findById('123');
|
|
29
|
+
const all = await wrapped.findAll();
|
|
30
|
+
const status = wrapped.getStatus();
|
|
31
|
+
|
|
32
|
+
// Verify original methods were called
|
|
33
|
+
expect(mockRepository.findById).toHaveBeenCalledWith('123');
|
|
34
|
+
expect(mockRepository.findAll).toHaveBeenCalled();
|
|
35
|
+
expect(mockRepository.getStatus).toHaveBeenCalled();
|
|
36
|
+
|
|
37
|
+
// Verify results match
|
|
38
|
+
expect(byId).toEqual({ id: '123', name: 'Test Entity' });
|
|
39
|
+
expect(all).toHaveLength(2);
|
|
40
|
+
expect(status).toBe('active');
|
|
41
|
+
|
|
42
|
+
// No operations should be logged
|
|
43
|
+
expect(operationLog).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should intercept and log write operations', async () => {
|
|
47
|
+
const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
|
|
48
|
+
|
|
49
|
+
// Call write operations
|
|
50
|
+
await wrapped.create({ name: 'New Entity' });
|
|
51
|
+
await wrapped.update('123', { name: 'Updated' });
|
|
52
|
+
await wrapped.delete('456');
|
|
53
|
+
|
|
54
|
+
// Original write methods should NOT be called
|
|
55
|
+
expect(mockRepository.create).not.toHaveBeenCalled();
|
|
56
|
+
expect(mockRepository.update).not.toHaveBeenCalled();
|
|
57
|
+
expect(mockRepository.delete).not.toHaveBeenCalled();
|
|
58
|
+
|
|
59
|
+
// All operations should be logged
|
|
60
|
+
expect(operationLog).toHaveLength(3);
|
|
61
|
+
|
|
62
|
+
expect(operationLog[0]).toMatchObject({
|
|
63
|
+
operation: 'CREATE',
|
|
64
|
+
model: 'TestModel',
|
|
65
|
+
method: 'create',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(operationLog[1]).toMatchObject({
|
|
69
|
+
operation: 'UPDATE',
|
|
70
|
+
model: 'TestModel',
|
|
71
|
+
method: 'update',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(operationLog[2]).toMatchObject({
|
|
75
|
+
operation: 'DELETE',
|
|
76
|
+
model: 'TestModel',
|
|
77
|
+
method: 'delete',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('should return mock data for create operations', async () => {
|
|
82
|
+
const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
|
|
83
|
+
|
|
84
|
+
const result = await wrapped.create({ name: 'Test', value: 42 });
|
|
85
|
+
|
|
86
|
+
expect(result).toMatchObject({
|
|
87
|
+
name: 'Test',
|
|
88
|
+
value: 42,
|
|
89
|
+
_dryRun: true,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result.id).toMatch(/^dry-run-/);
|
|
93
|
+
expect(result.createdAt).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('should return mock data for update operations', async () => {
|
|
97
|
+
const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
|
|
98
|
+
|
|
99
|
+
const result = await wrapped.update('123', { status: 'inactive' });
|
|
100
|
+
|
|
101
|
+
expect(result).toMatchObject({
|
|
102
|
+
id: '123',
|
|
103
|
+
status: 'inactive',
|
|
104
|
+
_dryRun: true,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('should return mock data for delete operations', async () => {
|
|
109
|
+
const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
|
|
110
|
+
|
|
111
|
+
const result = await wrapped.delete('123');
|
|
112
|
+
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
deletedCount: 1,
|
|
115
|
+
_dryRun: true,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('should try to return existing data for updates when possible', async () => {
|
|
120
|
+
const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
|
|
121
|
+
|
|
122
|
+
const result = await wrapped.updateStatus('123', 'inactive');
|
|
123
|
+
|
|
124
|
+
// Should attempt to read existing data
|
|
125
|
+
expect(mockRepository.findById).toHaveBeenCalledWith('123');
|
|
126
|
+
|
|
127
|
+
// If found, should return existing merged with updates
|
|
128
|
+
expect(result.id).toBe('123');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('sanitizeArgs', () => {
|
|
133
|
+
test('should redact sensitive fields in objects', () => {
|
|
134
|
+
const args = [
|
|
135
|
+
{
|
|
136
|
+
id: '123',
|
|
137
|
+
password: 'secret123',
|
|
138
|
+
token: 'abc-def-ghi',
|
|
139
|
+
apiKey: 'sk_live_123',
|
|
140
|
+
name: 'Test User',
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const sanitized = sanitizeArgs(args);
|
|
145
|
+
|
|
146
|
+
expect(sanitized[0]).toEqual({
|
|
147
|
+
id: '123',
|
|
148
|
+
password: '[REDACTED]',
|
|
149
|
+
token: '[REDACTED]',
|
|
150
|
+
apiKey: '[REDACTED]',
|
|
151
|
+
name: 'Test User',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('should handle nested objects', () => {
|
|
156
|
+
const args = [
|
|
157
|
+
{
|
|
158
|
+
user: {
|
|
159
|
+
name: 'Test',
|
|
160
|
+
credentials: {
|
|
161
|
+
password: 'secret',
|
|
162
|
+
apiToken: 'token123',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const sanitized = sanitizeArgs(args);
|
|
169
|
+
|
|
170
|
+
expect(sanitized[0].user.name).toBe('Test');
|
|
171
|
+
expect(sanitized[0].user.credentials.password).toBe('[REDACTED]');
|
|
172
|
+
expect(sanitized[0].user.credentials.apiToken).toBe('[REDACTED]');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('should handle arrays', () => {
|
|
176
|
+
const args = [
|
|
177
|
+
[
|
|
178
|
+
{ id: '1', token: 'abc' },
|
|
179
|
+
{ id: '2', secret: 'xyz' },
|
|
180
|
+
],
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const sanitized = sanitizeArgs(args);
|
|
184
|
+
|
|
185
|
+
expect(sanitized[0][0].token).toBe('[REDACTED]');
|
|
186
|
+
expect(sanitized[0][1].secret).toBe('[REDACTED]');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('should preserve primitives', () => {
|
|
190
|
+
const args = ['string', 123, true, null, undefined];
|
|
191
|
+
const sanitized = sanitizeArgs(args);
|
|
192
|
+
|
|
193
|
+
expect(sanitized).toEqual(['string', 123, true, null, undefined]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('wrapAdminFriggCommandsForDryRun', () => {
|
|
198
|
+
let mockCommands;
|
|
199
|
+
let operationLog;
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
operationLog = [];
|
|
203
|
+
mockCommands = {
|
|
204
|
+
// Read operations
|
|
205
|
+
findIntegrationById: jest.fn(async (id) => ({ id, status: 'active' })),
|
|
206
|
+
listIntegrations: jest.fn(async () => []),
|
|
207
|
+
|
|
208
|
+
// Write operations
|
|
209
|
+
updateIntegrationConfig: jest.fn(async (id, config) => ({ id, config })),
|
|
210
|
+
updateIntegrationStatus: jest.fn(async (id, status) => ({ id, status })),
|
|
211
|
+
updateCredential: jest.fn(async (id, updates) => ({ id, ...updates })),
|
|
212
|
+
|
|
213
|
+
// Other methods
|
|
214
|
+
log: jest.fn(),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('should pass through read operations', async () => {
|
|
219
|
+
const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog);
|
|
220
|
+
|
|
221
|
+
const integration = await wrapped.findIntegrationById('123');
|
|
222
|
+
const list = await wrapped.listIntegrations();
|
|
223
|
+
|
|
224
|
+
expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123');
|
|
225
|
+
expect(mockCommands.listIntegrations).toHaveBeenCalled();
|
|
226
|
+
|
|
227
|
+
expect(integration.id).toBe('123');
|
|
228
|
+
expect(operationLog).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('should intercept write operations', async () => {
|
|
232
|
+
const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog);
|
|
233
|
+
|
|
234
|
+
await wrapped.updateIntegrationConfig('123', { setting: 'value' });
|
|
235
|
+
await wrapped.updateIntegrationStatus('456', 'inactive');
|
|
236
|
+
|
|
237
|
+
expect(mockCommands.updateIntegrationConfig).not.toHaveBeenCalled();
|
|
238
|
+
expect(mockCommands.updateIntegrationStatus).not.toHaveBeenCalled();
|
|
239
|
+
|
|
240
|
+
expect(operationLog).toHaveLength(2);
|
|
241
|
+
expect(operationLog[0].operation).toBe('UPDATEINTEGRATIONCONFIG');
|
|
242
|
+
expect(operationLog[1].operation).toBe('UPDATEINTEGRATIONSTATUS');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('should return existing data for known update methods', async () => {
|
|
246
|
+
const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog);
|
|
247
|
+
|
|
248
|
+
const result = await wrapped.updateIntegrationConfig('123', { new: 'config' });
|
|
249
|
+
|
|
250
|
+
// Should have tried to fetch existing
|
|
251
|
+
expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123');
|
|
252
|
+
|
|
253
|
+
// Should return existing data
|
|
254
|
+
expect(result.id).toBe('123');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const { ScheduleManagementUseCase } = require('../schedule-management-use-case');
|
|
2
|
+
|
|
3
|
+
describe('ScheduleManagementUseCase', () => {
|
|
4
|
+
let useCase;
|
|
5
|
+
let mockCommands;
|
|
6
|
+
let mockSchedulerAdapter;
|
|
7
|
+
let mockScriptFactory;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockCommands = {
|
|
11
|
+
getScheduleByScriptName: jest.fn(),
|
|
12
|
+
upsertSchedule: jest.fn(),
|
|
13
|
+
updateScheduleAwsInfo: jest.fn(),
|
|
14
|
+
deleteSchedule: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
mockSchedulerAdapter = {
|
|
18
|
+
createSchedule: jest.fn(),
|
|
19
|
+
deleteSchedule: jest.fn(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
mockScriptFactory = {
|
|
23
|
+
has: jest.fn(),
|
|
24
|
+
get: jest.fn(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
useCase = new ScheduleManagementUseCase({
|
|
28
|
+
commands: mockCommands,
|
|
29
|
+
schedulerAdapter: mockSchedulerAdapter,
|
|
30
|
+
scriptFactory: mockScriptFactory,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('getEffectiveSchedule', () => {
|
|
35
|
+
it('should return database schedule when override exists', async () => {
|
|
36
|
+
const dbSchedule = {
|
|
37
|
+
scriptName: 'test-script',
|
|
38
|
+
enabled: true,
|
|
39
|
+
cronExpression: '0 9 * * *',
|
|
40
|
+
timezone: 'UTC',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
44
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
45
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule);
|
|
46
|
+
|
|
47
|
+
const result = await useCase.getEffectiveSchedule('test-script');
|
|
48
|
+
|
|
49
|
+
expect(result.source).toBe('database');
|
|
50
|
+
expect(result.schedule).toEqual(dbSchedule);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return definition schedule when no database override', async () => {
|
|
54
|
+
const definitionSchedule = {
|
|
55
|
+
enabled: true,
|
|
56
|
+
cronExpression: '0 12 * * *',
|
|
57
|
+
timezone: 'America/New_York',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
61
|
+
mockScriptFactory.get.mockReturnValue({
|
|
62
|
+
Definition: { schedule: definitionSchedule },
|
|
63
|
+
});
|
|
64
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
65
|
+
|
|
66
|
+
const result = await useCase.getEffectiveSchedule('test-script');
|
|
67
|
+
|
|
68
|
+
expect(result.source).toBe('definition');
|
|
69
|
+
expect(result.schedule.enabled).toBe(true);
|
|
70
|
+
expect(result.schedule.cronExpression).toBe('0 12 * * *');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return none when no schedule configured', async () => {
|
|
74
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
75
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
76
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
77
|
+
|
|
78
|
+
const result = await useCase.getEffectiveSchedule('test-script');
|
|
79
|
+
|
|
80
|
+
expect(result.source).toBe('none');
|
|
81
|
+
expect(result.schedule.enabled).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should throw error when script not found', async () => {
|
|
85
|
+
mockScriptFactory.has.mockReturnValue(false);
|
|
86
|
+
|
|
87
|
+
await expect(useCase.getEffectiveSchedule('non-existent'))
|
|
88
|
+
.rejects.toThrow('Script "non-existent" not found');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('upsertSchedule', () => {
|
|
93
|
+
it('should create schedule and provision EventBridge when enabled', async () => {
|
|
94
|
+
const savedSchedule = {
|
|
95
|
+
scriptName: 'test-script',
|
|
96
|
+
enabled: true,
|
|
97
|
+
cronExpression: '0 12 * * *',
|
|
98
|
+
timezone: 'UTC',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
102
|
+
mockCommands.upsertSchedule.mockResolvedValue(savedSchedule);
|
|
103
|
+
mockSchedulerAdapter.createSchedule.mockResolvedValue({
|
|
104
|
+
scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
105
|
+
scheduleName: 'frigg-script-test-script',
|
|
106
|
+
});
|
|
107
|
+
mockCommands.updateScheduleAwsInfo.mockResolvedValue({
|
|
108
|
+
...savedSchedule,
|
|
109
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const result = await useCase.upsertSchedule('test-script', {
|
|
113
|
+
enabled: true,
|
|
114
|
+
cronExpression: '0 12 * * *',
|
|
115
|
+
timezone: 'UTC',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(result.success).toBe(true);
|
|
119
|
+
expect(result.schedule.scriptName).toBe('test-script');
|
|
120
|
+
expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({
|
|
121
|
+
scriptName: 'test-script',
|
|
122
|
+
cronExpression: '0 12 * * *',
|
|
123
|
+
timezone: 'UTC',
|
|
124
|
+
});
|
|
125
|
+
expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should delete EventBridge schedule when disabling', async () => {
|
|
129
|
+
const existingSchedule = {
|
|
130
|
+
scriptName: 'test-script',
|
|
131
|
+
enabled: false,
|
|
132
|
+
cronExpression: null,
|
|
133
|
+
timezone: 'UTC',
|
|
134
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
138
|
+
mockCommands.upsertSchedule.mockResolvedValue(existingSchedule);
|
|
139
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
140
|
+
mockCommands.updateScheduleAwsInfo.mockResolvedValue({
|
|
141
|
+
...existingSchedule,
|
|
142
|
+
awsScheduleArn: null,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = await useCase.upsertSchedule('test-script', {
|
|
146
|
+
enabled: false,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result.success).toBe(true);
|
|
150
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should handle scheduler errors gracefully', async () => {
|
|
154
|
+
const savedSchedule = {
|
|
155
|
+
scriptName: 'test-script',
|
|
156
|
+
enabled: true,
|
|
157
|
+
cronExpression: '0 12 * * *',
|
|
158
|
+
timezone: 'UTC',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
162
|
+
mockCommands.upsertSchedule.mockResolvedValue(savedSchedule);
|
|
163
|
+
mockSchedulerAdapter.createSchedule.mockRejectedValue(
|
|
164
|
+
new Error('AWS Scheduler API error')
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const result = await useCase.upsertSchedule('test-script', {
|
|
168
|
+
enabled: true,
|
|
169
|
+
cronExpression: '0 12 * * *',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Should succeed with warning, not fail
|
|
173
|
+
expect(result.success).toBe(true);
|
|
174
|
+
expect(result.schedulerWarning).toBe('AWS Scheduler API error');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should throw error when script not found', async () => {
|
|
178
|
+
mockScriptFactory.has.mockReturnValue(false);
|
|
179
|
+
|
|
180
|
+
await expect(useCase.upsertSchedule('non-existent', { enabled: true }))
|
|
181
|
+
.rejects.toThrow('Script "non-existent" not found');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should throw error when enabled without cronExpression', async () => {
|
|
185
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
186
|
+
|
|
187
|
+
await expect(useCase.upsertSchedule('test-script', { enabled: true }))
|
|
188
|
+
.rejects.toThrow('cronExpression is required when enabled is true');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('deleteSchedule', () => {
|
|
193
|
+
it('should delete schedule and EventBridge rule', async () => {
|
|
194
|
+
const deletedSchedule = {
|
|
195
|
+
scriptName: 'test-script',
|
|
196
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
200
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
201
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
202
|
+
deletedCount: 1,
|
|
203
|
+
deleted: deletedSchedule,
|
|
204
|
+
});
|
|
205
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
206
|
+
|
|
207
|
+
const result = await useCase.deleteSchedule('test-script');
|
|
208
|
+
|
|
209
|
+
expect(result.success).toBe(true);
|
|
210
|
+
expect(result.deletedCount).toBe(1);
|
|
211
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should not call scheduler when no AWS rule exists', async () => {
|
|
215
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
216
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
217
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
218
|
+
deletedCount: 1,
|
|
219
|
+
deleted: { scriptName: 'test-script' }, // No awsScheduleArn
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const result = await useCase.deleteSchedule('test-script');
|
|
223
|
+
|
|
224
|
+
expect(result.success).toBe(true);
|
|
225
|
+
expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should handle scheduler delete errors gracefully', async () => {
|
|
229
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
230
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
231
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
232
|
+
deletedCount: 1,
|
|
233
|
+
deleted: {
|
|
234
|
+
scriptName: 'test-script',
|
|
235
|
+
awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
mockSchedulerAdapter.deleteSchedule.mockRejectedValue(
|
|
239
|
+
new Error('AWS delete failed')
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const result = await useCase.deleteSchedule('test-script');
|
|
243
|
+
|
|
244
|
+
expect(result.success).toBe(true);
|
|
245
|
+
expect(result.schedulerWarning).toBe('AWS delete failed');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should return effective schedule after deletion', async () => {
|
|
249
|
+
const definitionSchedule = {
|
|
250
|
+
enabled: true,
|
|
251
|
+
cronExpression: '0 6 * * *',
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
255
|
+
mockScriptFactory.get.mockReturnValue({
|
|
256
|
+
Definition: { schedule: definitionSchedule },
|
|
257
|
+
});
|
|
258
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
259
|
+
deletedCount: 1,
|
|
260
|
+
deleted: { scriptName: 'test-script' },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await useCase.deleteSchedule('test-script');
|
|
264
|
+
|
|
265
|
+
expect(result.effectiveSchedule.source).toBe('definition');
|
|
266
|
+
expect(result.effectiveSchedule.enabled).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should throw error when script not found', async () => {
|
|
270
|
+
mockScriptFactory.has.mockReturnValue(false);
|
|
271
|
+
|
|
272
|
+
await expect(useCase.deleteSchedule('non-existent'))
|
|
273
|
+
.rejects.toThrow('Script "non-existent" not found');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|