@friggframework/admin-scripts 2.0.0--canary.517.300ded3.0 → 2.0.0--canary.517.35ee143.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/package.json +6 -7
- package/src/application/__tests__/admin-frigg-commands.test.js +1 -1
- package/src/application/__tests__/admin-script-base.test.js +2 -2
- package/src/application/__tests__/script-runner.test.js +2 -2
- package/src/application/admin-frigg-commands.js +1 -1
- package/src/application/admin-script-base.js +1 -1
- package/src/application/script-runner.js +5 -3
- package/src/application/use-cases/__tests__/delete-schedule-use-case.test.js +168 -0
- package/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js +114 -0
- package/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js +201 -0
- package/src/application/use-cases/delete-schedule-use-case.js +108 -0
- package/src/application/use-cases/get-effective-schedule-use-case.js +78 -0
- package/src/application/use-cases/index.js +18 -0
- package/src/application/use-cases/upsert-schedule-use-case.js +127 -0
- package/src/builtins/__tests__/integration-health-check.test.js +1 -1
- package/src/builtins/__tests__/oauth-token-refresh.test.js +1 -1
- package/src/builtins/integration-health-check.js +1 -1
- package/src/builtins/oauth-token-refresh.js +1 -1
- package/src/infrastructure/__tests__/admin-script-router.test.js +22 -22
- package/src/infrastructure/admin-script-router.js +24 -16
- package/src/application/__tests__/schedule-management-use-case.test.js +0 -276
- package/src/application/schedule-management-use-case.js +0 -230
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/admin-scripts",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.517.
|
|
4
|
+
"version": "2.0.0--canary.517.35ee143.0",
|
|
5
5
|
"description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
8
|
-
"@friggframework/core": "2.0.0--canary.517.
|
|
8
|
+
"@friggframework/core": "2.0.0--canary.517.35ee143.0",
|
|
9
9
|
"bcryptjs": "^2.4.3",
|
|
10
10
|
"express": "^4.18.2",
|
|
11
11
|
"lodash": "4.17.21",
|
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
"uuid": "^9.0.1"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@friggframework/eslint-config": "2.0.0--canary.517.
|
|
17
|
-
"@friggframework/prettier-config": "2.0.0--canary.517.
|
|
18
|
-
"@friggframework/test": "2.0.0--canary.517.
|
|
16
|
+
"@friggframework/eslint-config": "2.0.0--canary.517.35ee143.0",
|
|
17
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.35ee143.0",
|
|
18
|
+
"@friggframework/test": "2.0.0--canary.517.35ee143.0",
|
|
19
19
|
"eslint": "^8.22.0",
|
|
20
20
|
"jest": "^29.7.0",
|
|
21
21
|
"prettier": "^2.7.1",
|
|
22
|
-
"sinon": "^16.1.1",
|
|
23
22
|
"supertest": "^7.1.4"
|
|
24
23
|
},
|
|
25
24
|
"scripts": {
|
|
@@ -47,5 +46,5 @@
|
|
|
47
46
|
"maintenance",
|
|
48
47
|
"operations"
|
|
49
48
|
],
|
|
50
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "35ee143ec0654d0935aee59bf62b882f5f27d8b7"
|
|
51
50
|
}
|
|
@@ -341,7 +341,7 @@ describe('AdminFriggCommands', () => {
|
|
|
341
341
|
|
|
342
342
|
await expect(commands.instantiate('int_123')).rejects.toThrow(
|
|
343
343
|
'instantiate() requires integrationFactory. ' +
|
|
344
|
-
'Set Definition.config.
|
|
344
|
+
'Set Definition.config.requireIntegrationInstance = true'
|
|
345
345
|
);
|
|
346
346
|
});
|
|
347
347
|
|
|
@@ -28,7 +28,7 @@ describe('AdminScriptBase', () => {
|
|
|
28
28
|
config: {
|
|
29
29
|
timeout: 600000,
|
|
30
30
|
maxRetries: 3,
|
|
31
|
-
|
|
31
|
+
requireIntegrationInstance: true,
|
|
32
32
|
},
|
|
33
33
|
display: {
|
|
34
34
|
label: 'Test Script',
|
|
@@ -236,7 +236,7 @@ describe('AdminScriptBase', () => {
|
|
|
236
236
|
version: '1.0.0',
|
|
237
237
|
description: 'My test script',
|
|
238
238
|
config: {
|
|
239
|
-
|
|
239
|
+
requireIntegrationInstance: true,
|
|
240
240
|
},
|
|
241
241
|
};
|
|
242
242
|
|
|
@@ -23,7 +23,7 @@ describe('ScriptRunner', () => {
|
|
|
23
23
|
config: {
|
|
24
24
|
timeout: 300000,
|
|
25
25
|
maxRetries: 0,
|
|
26
|
-
|
|
26
|
+
requireIntegrationInstance: false,
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
|
|
@@ -146,7 +146,7 @@ describe('ScriptRunner', () => {
|
|
|
146
146
|
version: '1.0.0',
|
|
147
147
|
description: 'Integration script',
|
|
148
148
|
config: {
|
|
149
|
-
|
|
149
|
+
requireIntegrationInstance: true,
|
|
150
150
|
},
|
|
151
151
|
};
|
|
152
152
|
|
|
@@ -142,7 +142,7 @@ class AdminFriggCommands {
|
|
|
142
142
|
if (!this.integrationFactory) {
|
|
143
143
|
throw new Error(
|
|
144
144
|
'instantiate() requires integrationFactory. ' +
|
|
145
|
-
'Set Definition.config.
|
|
145
|
+
'Set Definition.config.requireIntegrationInstance = true'
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
return this.integrationFactory.getInstanceFromIntegrationId({
|
|
@@ -50,7 +50,7 @@ class AdminScriptBase {
|
|
|
50
50
|
config: {
|
|
51
51
|
timeout: 300000, // Default 5 min (ms)
|
|
52
52
|
maxRetries: 0,
|
|
53
|
-
|
|
53
|
+
requireIntegrationInstance: false, // Hint: does script need to instantiate integrations?
|
|
54
54
|
},
|
|
55
55
|
|
|
56
56
|
display: {
|
|
@@ -27,7 +27,9 @@ class ScriptRunner {
|
|
|
27
27
|
* @param {string} options.trigger - 'MANUAL' | 'SCHEDULED' | 'QUEUE'
|
|
28
28
|
* @param {string} options.mode - 'sync' | 'async'
|
|
29
29
|
* @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress }
|
|
30
|
-
* @param {string} options.executionId - Reuse existing execution ID
|
|
30
|
+
* @param {string} options.executionId - Reuse existing AdminProcess record ID (NOT the Lambda execution ID).
|
|
31
|
+
* This is the database ID from the AdminProcess collection/table that tracks script executions.
|
|
32
|
+
* Pass this when resuming a queued execution to continue using the same execution record.
|
|
31
33
|
* @param {boolean} options.dryRun - Dry-run mode: validate and preview without executing
|
|
32
34
|
*/
|
|
33
35
|
async execute(scriptName, params = {}, options = {}) {
|
|
@@ -38,7 +40,7 @@ class ScriptRunner {
|
|
|
38
40
|
const definition = scriptClass.Definition;
|
|
39
41
|
|
|
40
42
|
// Validate integrationFactory requirement
|
|
41
|
-
if (definition.config?.
|
|
43
|
+
if (definition.config?.requireIntegrationInstance && !this.integrationFactory) {
|
|
42
44
|
throw new Error(
|
|
43
45
|
`Script "${scriptName}" requires integrationFactory but none was provided`
|
|
44
46
|
);
|
|
@@ -157,7 +159,7 @@ class ScriptRunner {
|
|
|
157
159
|
name: definition.name,
|
|
158
160
|
version: definition.version,
|
|
159
161
|
description: definition.description,
|
|
160
|
-
|
|
162
|
+
requireIntegrationInstance: definition.config?.requireIntegrationInstance || false,
|
|
161
163
|
},
|
|
162
164
|
input: params,
|
|
163
165
|
inputSchema: definition.inputSchema || null,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { DeleteScheduleUseCase } = require('../delete-schedule-use-case');
|
|
2
|
+
|
|
3
|
+
describe('DeleteScheduleUseCase', () => {
|
|
4
|
+
let useCase;
|
|
5
|
+
let mockCommands;
|
|
6
|
+
let mockSchedulerAdapter;
|
|
7
|
+
let mockScriptFactory;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockCommands = {
|
|
11
|
+
deleteSchedule: jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
mockSchedulerAdapter = {
|
|
15
|
+
deleteSchedule: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
mockScriptFactory = {
|
|
19
|
+
has: jest.fn(),
|
|
20
|
+
get: jest.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
useCase = new DeleteScheduleUseCase({
|
|
24
|
+
commands: mockCommands,
|
|
25
|
+
schedulerAdapter: mockSchedulerAdapter,
|
|
26
|
+
scriptFactory: mockScriptFactory,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('execute', () => {
|
|
31
|
+
it('should delete schedule and cleanup external scheduler', async () => {
|
|
32
|
+
const deletedSchedule = {
|
|
33
|
+
scriptName: 'test-script',
|
|
34
|
+
externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
38
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
39
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
40
|
+
deletedCount: 1,
|
|
41
|
+
deleted: deletedSchedule,
|
|
42
|
+
});
|
|
43
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
44
|
+
|
|
45
|
+
const result = await useCase.execute('test-script');
|
|
46
|
+
|
|
47
|
+
expect(result.success).toBe(true);
|
|
48
|
+
expect(result.deletedCount).toBe(1);
|
|
49
|
+
expect(result.message).toBe('Schedule override removed');
|
|
50
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should not call scheduler when no external rule exists', async () => {
|
|
54
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
55
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
56
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
57
|
+
deletedCount: 1,
|
|
58
|
+
deleted: { scriptName: 'test-script' }, // No externalScheduleId
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await useCase.execute('test-script');
|
|
62
|
+
|
|
63
|
+
expect(result.success).toBe(true);
|
|
64
|
+
expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should handle scheduler delete errors gracefully with warning', async () => {
|
|
68
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
69
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
70
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
71
|
+
deletedCount: 1,
|
|
72
|
+
deleted: {
|
|
73
|
+
scriptName: 'test-script',
|
|
74
|
+
externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
mockSchedulerAdapter.deleteSchedule.mockRejectedValue(
|
|
78
|
+
new Error('Scheduler delete failed')
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = await useCase.execute('test-script');
|
|
82
|
+
|
|
83
|
+
expect(result.success).toBe(true);
|
|
84
|
+
expect(result.schedulerWarning).toBe('Scheduler delete failed');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should return definition schedule as effective after deletion', async () => {
|
|
88
|
+
const definitionSchedule = {
|
|
89
|
+
enabled: true,
|
|
90
|
+
cronExpression: '0 6 * * *',
|
|
91
|
+
timezone: 'America/Los_Angeles',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
95
|
+
mockScriptFactory.get.mockReturnValue({
|
|
96
|
+
Definition: { schedule: definitionSchedule },
|
|
97
|
+
});
|
|
98
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
99
|
+
deletedCount: 1,
|
|
100
|
+
deleted: { scriptName: 'test-script' },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await useCase.execute('test-script');
|
|
104
|
+
|
|
105
|
+
expect(result.effectiveSchedule.source).toBe('definition');
|
|
106
|
+
expect(result.effectiveSchedule.enabled).toBe(true);
|
|
107
|
+
expect(result.effectiveSchedule.cronExpression).toBe('0 6 * * *');
|
|
108
|
+
expect(result.effectiveSchedule.timezone).toBe('America/Los_Angeles');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should default timezone to UTC when not in definition', async () => {
|
|
112
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
113
|
+
mockScriptFactory.get.mockReturnValue({
|
|
114
|
+
Definition: { schedule: { enabled: true, cronExpression: '0 6 * * *' } },
|
|
115
|
+
});
|
|
116
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
117
|
+
deletedCount: 1,
|
|
118
|
+
deleted: { scriptName: 'test-script' },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = await useCase.execute('test-script');
|
|
122
|
+
|
|
123
|
+
expect(result.effectiveSchedule.timezone).toBe('UTC');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return none as effective when no definition schedule', async () => {
|
|
127
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
128
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
129
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
130
|
+
deletedCount: 1,
|
|
131
|
+
deleted: { scriptName: 'test-script' },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = await useCase.execute('test-script');
|
|
135
|
+
|
|
136
|
+
expect(result.effectiveSchedule.source).toBe('none');
|
|
137
|
+
expect(result.effectiveSchedule.enabled).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return correct message when no schedule found', async () => {
|
|
141
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
142
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
143
|
+
mockCommands.deleteSchedule.mockResolvedValue({
|
|
144
|
+
deletedCount: 0,
|
|
145
|
+
deleted: null,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const result = await useCase.execute('test-script');
|
|
149
|
+
|
|
150
|
+
expect(result.success).toBe(true);
|
|
151
|
+
expect(result.deletedCount).toBe(0);
|
|
152
|
+
expect(result.message).toBe('No schedule override found');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => {
|
|
156
|
+
mockScriptFactory.has.mockReturnValue(false);
|
|
157
|
+
|
|
158
|
+
await expect(useCase.execute('non-existent'))
|
|
159
|
+
.rejects.toThrow('Script "non-existent" not found');
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await useCase.execute('non-existent');
|
|
163
|
+
} catch (error) {
|
|
164
|
+
expect(error.code).toBe('SCRIPT_NOT_FOUND');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { GetEffectiveScheduleUseCase } = require('../get-effective-schedule-use-case');
|
|
2
|
+
|
|
3
|
+
describe('GetEffectiveScheduleUseCase', () => {
|
|
4
|
+
let useCase;
|
|
5
|
+
let mockCommands;
|
|
6
|
+
let mockScriptFactory;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockCommands = {
|
|
10
|
+
getScheduleByScriptName: jest.fn(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
mockScriptFactory = {
|
|
14
|
+
has: jest.fn(),
|
|
15
|
+
get: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
useCase = new GetEffectiveScheduleUseCase({
|
|
19
|
+
commands: mockCommands,
|
|
20
|
+
scriptFactory: mockScriptFactory,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('execute', () => {
|
|
25
|
+
it('should return database schedule when override exists', async () => {
|
|
26
|
+
const dbSchedule = {
|
|
27
|
+
scriptName: 'test-script',
|
|
28
|
+
enabled: true,
|
|
29
|
+
cronExpression: '0 9 * * *',
|
|
30
|
+
timezone: 'UTC',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
34
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
35
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule);
|
|
36
|
+
|
|
37
|
+
const result = await useCase.execute('test-script');
|
|
38
|
+
|
|
39
|
+
expect(result.source).toBe('database');
|
|
40
|
+
expect(result.schedule).toEqual(dbSchedule);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return definition schedule when no database override', async () => {
|
|
44
|
+
const definitionSchedule = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
cronExpression: '0 12 * * *',
|
|
47
|
+
timezone: 'America/New_York',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
51
|
+
mockScriptFactory.get.mockReturnValue({
|
|
52
|
+
Definition: { schedule: definitionSchedule },
|
|
53
|
+
});
|
|
54
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
55
|
+
|
|
56
|
+
const result = await useCase.execute('test-script');
|
|
57
|
+
|
|
58
|
+
expect(result.source).toBe('definition');
|
|
59
|
+
expect(result.schedule.enabled).toBe(true);
|
|
60
|
+
expect(result.schedule.cronExpression).toBe('0 12 * * *');
|
|
61
|
+
expect(result.schedule.timezone).toBe('America/New_York');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should default timezone to UTC when not specified in definition', async () => {
|
|
65
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
66
|
+
mockScriptFactory.get.mockReturnValue({
|
|
67
|
+
Definition: { schedule: { enabled: true, cronExpression: '0 12 * * *' } },
|
|
68
|
+
});
|
|
69
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
70
|
+
|
|
71
|
+
const result = await useCase.execute('test-script');
|
|
72
|
+
|
|
73
|
+
expect(result.schedule.timezone).toBe('UTC');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return none when no schedule configured', async () => {
|
|
77
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
78
|
+
mockScriptFactory.get.mockReturnValue({ Definition: {} });
|
|
79
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
80
|
+
|
|
81
|
+
const result = await useCase.execute('test-script');
|
|
82
|
+
|
|
83
|
+
expect(result.source).toBe('none');
|
|
84
|
+
expect(result.schedule.enabled).toBe(false);
|
|
85
|
+
expect(result.schedule.scriptName).toBe('test-script');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return none when definition schedule is disabled', async () => {
|
|
89
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
90
|
+
mockScriptFactory.get.mockReturnValue({
|
|
91
|
+
Definition: { schedule: { enabled: false } },
|
|
92
|
+
});
|
|
93
|
+
mockCommands.getScheduleByScriptName.mockResolvedValue(null);
|
|
94
|
+
|
|
95
|
+
const result = await useCase.execute('test-script');
|
|
96
|
+
|
|
97
|
+
expect(result.source).toBe('none');
|
|
98
|
+
expect(result.schedule.enabled).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => {
|
|
102
|
+
mockScriptFactory.has.mockReturnValue(false);
|
|
103
|
+
|
|
104
|
+
await expect(useCase.execute('non-existent'))
|
|
105
|
+
.rejects.toThrow('Script "non-existent" not found');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await useCase.execute('non-existent');
|
|
109
|
+
} catch (error) {
|
|
110
|
+
expect(error.code).toBe('SCRIPT_NOT_FOUND');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const { UpsertScheduleUseCase } = require('../upsert-schedule-use-case');
|
|
2
|
+
|
|
3
|
+
describe('UpsertScheduleUseCase', () => {
|
|
4
|
+
let useCase;
|
|
5
|
+
let mockCommands;
|
|
6
|
+
let mockSchedulerAdapter;
|
|
7
|
+
let mockScriptFactory;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockCommands = {
|
|
11
|
+
upsertSchedule: jest.fn(),
|
|
12
|
+
updateScheduleExternalInfo: jest.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
mockSchedulerAdapter = {
|
|
16
|
+
createSchedule: jest.fn(),
|
|
17
|
+
deleteSchedule: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
mockScriptFactory = {
|
|
21
|
+
has: jest.fn(),
|
|
22
|
+
get: jest.fn(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
useCase = new UpsertScheduleUseCase({
|
|
26
|
+
commands: mockCommands,
|
|
27
|
+
schedulerAdapter: mockSchedulerAdapter,
|
|
28
|
+
scriptFactory: mockScriptFactory,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('execute', () => {
|
|
33
|
+
it('should create schedule and provision external scheduler when enabled', async () => {
|
|
34
|
+
const savedSchedule = {
|
|
35
|
+
scriptName: 'test-script',
|
|
36
|
+
enabled: true,
|
|
37
|
+
cronExpression: '0 12 * * *',
|
|
38
|
+
timezone: 'UTC',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
42
|
+
mockCommands.upsertSchedule.mockResolvedValue(savedSchedule);
|
|
43
|
+
mockSchedulerAdapter.createSchedule.mockResolvedValue({
|
|
44
|
+
scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
45
|
+
scheduleName: 'frigg-script-test-script',
|
|
46
|
+
});
|
|
47
|
+
mockCommands.updateScheduleExternalInfo.mockResolvedValue({
|
|
48
|
+
...savedSchedule,
|
|
49
|
+
externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await useCase.execute('test-script', {
|
|
53
|
+
enabled: true,
|
|
54
|
+
cronExpression: '0 12 * * *',
|
|
55
|
+
timezone: 'UTC',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result.success).toBe(true);
|
|
59
|
+
expect(result.schedule.scriptName).toBe('test-script');
|
|
60
|
+
expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({
|
|
61
|
+
scriptName: 'test-script',
|
|
62
|
+
cronExpression: '0 12 * * *',
|
|
63
|
+
timezone: 'UTC',
|
|
64
|
+
});
|
|
65
|
+
expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should default timezone to UTC', async () => {
|
|
69
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
70
|
+
mockCommands.upsertSchedule.mockResolvedValue({
|
|
71
|
+
scriptName: 'test-script',
|
|
72
|
+
enabled: true,
|
|
73
|
+
cronExpression: '0 12 * * *',
|
|
74
|
+
timezone: 'UTC',
|
|
75
|
+
});
|
|
76
|
+
mockSchedulerAdapter.createSchedule.mockResolvedValue({
|
|
77
|
+
scheduleArn: 'arn:test',
|
|
78
|
+
scheduleName: 'test',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await useCase.execute('test-script', {
|
|
82
|
+
enabled: true,
|
|
83
|
+
cronExpression: '0 12 * * *',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({
|
|
87
|
+
scriptName: 'test-script',
|
|
88
|
+
cronExpression: '0 12 * * *',
|
|
89
|
+
timezone: 'UTC',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should delete external scheduler when disabling', async () => {
|
|
94
|
+
const existingSchedule = {
|
|
95
|
+
scriptName: 'test-script',
|
|
96
|
+
enabled: false,
|
|
97
|
+
cronExpression: null,
|
|
98
|
+
timezone: 'UTC',
|
|
99
|
+
externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
103
|
+
mockCommands.upsertSchedule.mockResolvedValue(existingSchedule);
|
|
104
|
+
mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
|
|
105
|
+
mockCommands.updateScheduleExternalInfo.mockResolvedValue({
|
|
106
|
+
...existingSchedule,
|
|
107
|
+
externalScheduleId: null,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await useCase.execute('test-script', {
|
|
111
|
+
enabled: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should handle scheduler errors gracefully with warning', async () => {
|
|
119
|
+
const savedSchedule = {
|
|
120
|
+
scriptName: 'test-script',
|
|
121
|
+
enabled: true,
|
|
122
|
+
cronExpression: '0 12 * * *',
|
|
123
|
+
timezone: 'UTC',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
127
|
+
mockCommands.upsertSchedule.mockResolvedValue(savedSchedule);
|
|
128
|
+
mockSchedulerAdapter.createSchedule.mockRejectedValue(
|
|
129
|
+
new Error('Scheduler API error')
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const result = await useCase.execute('test-script', {
|
|
133
|
+
enabled: true,
|
|
134
|
+
cronExpression: '0 12 * * *',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Should succeed with warning, not fail
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
expect(result.schedulerWarning).toBe('Scheduler API error');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => {
|
|
143
|
+
mockScriptFactory.has.mockReturnValue(false);
|
|
144
|
+
|
|
145
|
+
await expect(useCase.execute('non-existent', { enabled: true }))
|
|
146
|
+
.rejects.toThrow('Script "non-existent" not found');
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await useCase.execute('non-existent', { enabled: true });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
expect(error.code).toBe('SCRIPT_NOT_FOUND');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should throw INVALID_INPUT error when enabled is not a boolean', async () => {
|
|
156
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
157
|
+
|
|
158
|
+
await expect(useCase.execute('test-script', { enabled: 'yes' }))
|
|
159
|
+
.rejects.toThrow('enabled must be a boolean');
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await useCase.execute('test-script', { enabled: 'yes' });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
expect(error.code).toBe('INVALID_INPUT');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should throw INVALID_INPUT error when enabled without cronExpression', async () => {
|
|
169
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
170
|
+
|
|
171
|
+
await expect(useCase.execute('test-script', { enabled: true }))
|
|
172
|
+
.rejects.toThrow('cronExpression is required when enabled is true');
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await useCase.execute('test-script', { enabled: true });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
expect(error.code).toBe('INVALID_INPUT');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should not require cronExpression when disabled', async () => {
|
|
182
|
+
mockScriptFactory.has.mockReturnValue(true);
|
|
183
|
+
mockCommands.upsertSchedule.mockResolvedValue({
|
|
184
|
+
scriptName: 'test-script',
|
|
185
|
+
enabled: false,
|
|
186
|
+
cronExpression: null,
|
|
187
|
+
timezone: 'UTC',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const result = await useCase.execute('test-script', { enabled: false });
|
|
191
|
+
|
|
192
|
+
expect(result.success).toBe(true);
|
|
193
|
+
expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({
|
|
194
|
+
scriptName: 'test-script',
|
|
195
|
+
enabled: false,
|
|
196
|
+
cronExpression: null,
|
|
197
|
+
timezone: 'UTC',
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|