@friggframework/admin-scripts 2.0.0--canary.522.cbd3d5a.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.
Files changed (30) hide show
  1. package/index.js +2 -2
  2. package/package.json +6 -9
  3. package/src/application/__tests__/admin-frigg-commands.test.js +19 -19
  4. package/src/application/__tests__/admin-script-base.test.js +2 -2
  5. package/src/application/__tests__/script-runner.test.js +146 -16
  6. package/src/application/admin-frigg-commands.js +8 -8
  7. package/src/application/admin-script-base.js +3 -5
  8. package/src/application/script-runner.js +125 -129
  9. package/src/application/use-cases/__tests__/delete-schedule-use-case.test.js +168 -0
  10. package/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js +114 -0
  11. package/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js +201 -0
  12. package/src/application/use-cases/delete-schedule-use-case.js +108 -0
  13. package/src/application/use-cases/get-effective-schedule-use-case.js +78 -0
  14. package/src/application/use-cases/index.js +18 -0
  15. package/src/application/use-cases/upsert-schedule-use-case.js +127 -0
  16. package/src/builtins/__tests__/integration-health-check.test.js +1 -1
  17. package/src/builtins/__tests__/oauth-token-refresh.test.js +1 -1
  18. package/src/builtins/integration-health-check.js +1 -1
  19. package/src/builtins/oauth-token-refresh.js +1 -1
  20. package/src/infrastructure/__tests__/admin-auth-middleware.test.js +32 -95
  21. package/src/infrastructure/__tests__/admin-script-router.test.js +46 -47
  22. package/src/infrastructure/admin-auth-middleware.js +5 -43
  23. package/src/infrastructure/admin-script-router.js +38 -32
  24. package/src/infrastructure/script-executor-handler.js +2 -2
  25. package/src/application/__tests__/dry-run-http-interceptor.test.js +0 -313
  26. package/src/application/__tests__/dry-run-repository-wrapper.test.js +0 -257
  27. package/src/application/__tests__/schedule-management-use-case.test.js +0 -276
  28. package/src/application/dry-run-http-interceptor.js +0 -296
  29. package/src/application/dry-run-repository-wrapper.js +0 -261
  30. package/src/application/schedule-management-use-case.js +0 -230
@@ -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
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Delete Schedule Use Case
3
+ *
4
+ * Application Layer - Hexagonal Architecture
5
+ *
6
+ * Deletes a schedule override and cleans up external scheduler resources.
7
+ * Returns the effective schedule after deletion (may fall back to definition).
8
+ */
9
+ class DeleteScheduleUseCase {
10
+ constructor({ commands, schedulerAdapter, scriptFactory }) {
11
+ this.commands = commands;
12
+ this.schedulerAdapter = schedulerAdapter;
13
+ this.scriptFactory = scriptFactory;
14
+ }
15
+
16
+ /**
17
+ * Delete a schedule override
18
+ * @param {string} scriptName - Name of the script
19
+ * @returns {Promise<{success: boolean, deletedCount: number, message: string, effectiveSchedule: Object, schedulerWarning?: string}>}
20
+ */
21
+ async execute(scriptName) {
22
+ this._validateScriptExists(scriptName);
23
+
24
+ // Delete from database
25
+ const deleteResult = await this.commands.deleteSchedule(scriptName);
26
+
27
+ // Cleanup external scheduler if needed
28
+ const schedulerWarning = await this._cleanupExternalScheduler(
29
+ scriptName,
30
+ deleteResult.deleted?.externalScheduleId
31
+ );
32
+
33
+ // Determine effective schedule after deletion
34
+ const effectiveSchedule = this._getEffectiveScheduleAfterDeletion(scriptName);
35
+
36
+ return {
37
+ success: true,
38
+ deletedCount: deleteResult.deletedCount,
39
+ message: deleteResult.deletedCount > 0
40
+ ? 'Schedule override removed'
41
+ : 'No schedule override found',
42
+ effectiveSchedule,
43
+ ...(schedulerWarning && { schedulerWarning }),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @private
49
+ */
50
+ _validateScriptExists(scriptName) {
51
+ if (!this.scriptFactory.has(scriptName)) {
52
+ const error = new Error(`Script "${scriptName}" not found`);
53
+ error.code = 'SCRIPT_NOT_FOUND';
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get the definition schedule from a script class
60
+ * @private
61
+ */
62
+ _getDefinitionSchedule(scriptName) {
63
+ const scriptClass = this.scriptFactory.get(scriptName);
64
+ return scriptClass.Definition?.schedule || null;
65
+ }
66
+
67
+ /**
68
+ * Determine effective schedule after deletion
69
+ * @private
70
+ */
71
+ _getEffectiveScheduleAfterDeletion(scriptName) {
72
+ const definitionSchedule = this._getDefinitionSchedule(scriptName);
73
+
74
+ if (definitionSchedule?.enabled) {
75
+ return {
76
+ source: 'definition',
77
+ enabled: definitionSchedule.enabled,
78
+ cronExpression: definitionSchedule.cronExpression,
79
+ timezone: definitionSchedule.timezone || 'UTC',
80
+ };
81
+ }
82
+
83
+ return {
84
+ source: 'none',
85
+ enabled: false,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Cleanup external scheduler resources
91
+ * @private
92
+ */
93
+ async _cleanupExternalScheduler(scriptName, externalScheduleId) {
94
+ if (!externalScheduleId) {
95
+ return null;
96
+ }
97
+
98
+ try {
99
+ await this.schedulerAdapter.deleteSchedule(scriptName);
100
+ return null;
101
+ } catch (error) {
102
+ // Non-fatal: DB is cleaned up, external scheduler can be retried
103
+ return error.message;
104
+ }
105
+ }
106
+ }
107
+
108
+ module.exports = { DeleteScheduleUseCase };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Get Effective Schedule Use Case
3
+ *
4
+ * Application Layer - Hexagonal Architecture
5
+ *
6
+ * Resolves the effective schedule for a script following priority:
7
+ * 1. Database override (runtime configuration)
8
+ * 2. Definition default (code-defined schedule)
9
+ * 3. None (manual execution only)
10
+ */
11
+ class GetEffectiveScheduleUseCase {
12
+ constructor({ commands, scriptFactory }) {
13
+ this.commands = commands;
14
+ this.scriptFactory = scriptFactory;
15
+ }
16
+
17
+ /**
18
+ * Get effective schedule for a script
19
+ * @param {string} scriptName - Name of the script
20
+ * @returns {Promise<{source: 'database'|'definition'|'none', schedule: Object}>}
21
+ */
22
+ async execute(scriptName) {
23
+ this._validateScriptExists(scriptName);
24
+
25
+ // Priority 1: Database override
26
+ const dbSchedule = await this.commands.getScheduleByScriptName(scriptName);
27
+ if (dbSchedule) {
28
+ return {
29
+ source: 'database',
30
+ schedule: dbSchedule,
31
+ };
32
+ }
33
+
34
+ // Priority 2: Definition default
35
+ const definitionSchedule = this._getDefinitionSchedule(scriptName);
36
+ if (definitionSchedule?.enabled) {
37
+ return {
38
+ source: 'definition',
39
+ schedule: {
40
+ scriptName,
41
+ enabled: definitionSchedule.enabled,
42
+ cronExpression: definitionSchedule.cronExpression,
43
+ timezone: definitionSchedule.timezone || 'UTC',
44
+ },
45
+ };
46
+ }
47
+
48
+ // Priority 3: No schedule
49
+ return {
50
+ source: 'none',
51
+ schedule: {
52
+ scriptName,
53
+ enabled: false,
54
+ },
55
+ };
56
+ }
57
+
58
+ /**
59
+ * @private
60
+ */
61
+ _validateScriptExists(scriptName) {
62
+ if (!this.scriptFactory.has(scriptName)) {
63
+ const error = new Error(`Script "${scriptName}" not found`);
64
+ error.code = 'SCRIPT_NOT_FOUND';
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @private
71
+ */
72
+ _getDefinitionSchedule(scriptName) {
73
+ const scriptClass = this.scriptFactory.get(scriptName);
74
+ return scriptClass.Definition?.schedule || null;
75
+ }
76
+ }
77
+
78
+ module.exports = { GetEffectiveScheduleUseCase };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Schedule Management Use Cases
3
+ *
4
+ * Separated by Single Responsibility Principle:
5
+ * - GetEffectiveScheduleUseCase: Read schedule with priority resolution
6
+ * - UpsertScheduleUseCase: Create/update schedule with scheduler sync
7
+ * - DeleteScheduleUseCase: Delete schedule with scheduler cleanup
8
+ */
9
+
10
+ const { GetEffectiveScheduleUseCase } = require('./get-effective-schedule-use-case');
11
+ const { UpsertScheduleUseCase } = require('./upsert-schedule-use-case');
12
+ const { DeleteScheduleUseCase } = require('./delete-schedule-use-case');
13
+
14
+ module.exports = {
15
+ GetEffectiveScheduleUseCase,
16
+ UpsertScheduleUseCase,
17
+ DeleteScheduleUseCase,
18
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Upsert Schedule Use Case
3
+ *
4
+ * Application Layer - Hexagonal Architecture
5
+ *
6
+ * Creates or updates a schedule override with external scheduler provisioning.
7
+ * Abstracts scheduler provider (AWS EventBridge, etc.) behind schedulerAdapter.
8
+ */
9
+ class UpsertScheduleUseCase {
10
+ constructor({ commands, schedulerAdapter, scriptFactory }) {
11
+ this.commands = commands;
12
+ this.schedulerAdapter = schedulerAdapter;
13
+ this.scriptFactory = scriptFactory;
14
+ }
15
+
16
+ /**
17
+ * Create or update a schedule
18
+ * @param {string} scriptName - Name of the script
19
+ * @param {Object} input - Schedule configuration
20
+ * @param {boolean} input.enabled - Whether schedule is enabled
21
+ * @param {string} [input.cronExpression] - Cron expression (required if enabled)
22
+ * @param {string} [input.timezone] - Timezone (defaults to UTC)
23
+ * @returns {Promise<{success: boolean, schedule: Object, schedulerWarning?: string}>}
24
+ */
25
+ async execute(scriptName, { enabled, cronExpression, timezone }) {
26
+ this._validateScriptExists(scriptName);
27
+ this._validateInput(enabled, cronExpression);
28
+
29
+ // Save to database
30
+ const schedule = await this.commands.upsertSchedule({
31
+ scriptName,
32
+ enabled,
33
+ cronExpression: cronExpression || null,
34
+ timezone: timezone || 'UTC',
35
+ });
36
+
37
+ // Sync with external scheduler (AWS EventBridge, etc.)
38
+ const schedulerResult = await this._syncExternalScheduler(
39
+ scriptName,
40
+ enabled,
41
+ cronExpression,
42
+ timezone,
43
+ schedule.externalScheduleId
44
+ );
45
+
46
+ return {
47
+ success: true,
48
+ schedule: {
49
+ ...schedule,
50
+ externalScheduleId: schedulerResult.externalScheduleId || schedule.externalScheduleId,
51
+ externalScheduleName: schedulerResult.externalScheduleName || schedule.externalScheduleName,
52
+ },
53
+ ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @private
59
+ */
60
+ _validateScriptExists(scriptName) {
61
+ if (!this.scriptFactory.has(scriptName)) {
62
+ const error = new Error(`Script "${scriptName}" not found`);
63
+ error.code = 'SCRIPT_NOT_FOUND';
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @private
70
+ */
71
+ _validateInput(enabled, cronExpression) {
72
+ if (typeof enabled !== 'boolean') {
73
+ const error = new Error('enabled must be a boolean');
74
+ error.code = 'INVALID_INPUT';
75
+ throw error;
76
+ }
77
+
78
+ if (enabled && !cronExpression) {
79
+ const error = new Error('cronExpression is required when enabled is true');
80
+ error.code = 'INVALID_INPUT';
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Sync with external scheduler service
87
+ * Abstracts AWS EventBridge or other scheduler providers
88
+ * @private
89
+ */
90
+ async _syncExternalScheduler(scriptName, enabled, cronExpression, timezone, existingId) {
91
+ const result = { externalScheduleId: null, externalScheduleName: null, warning: null };
92
+
93
+ try {
94
+ if (enabled && cronExpression) {
95
+ // Create/update external schedule
96
+ const schedulerInfo = await this.schedulerAdapter.createSchedule({
97
+ scriptName,
98
+ cronExpression,
99
+ timezone: timezone || 'UTC',
100
+ });
101
+
102
+ if (schedulerInfo?.scheduleArn) {
103
+ await this.commands.updateScheduleExternalInfo(scriptName, {
104
+ externalScheduleId: schedulerInfo.scheduleArn,
105
+ externalScheduleName: schedulerInfo.scheduleName,
106
+ });
107
+ result.externalScheduleId = schedulerInfo.scheduleArn;
108
+ result.externalScheduleName = schedulerInfo.scheduleName;
109
+ }
110
+ } else if (!enabled && existingId) {
111
+ // Delete external schedule
112
+ await this.schedulerAdapter.deleteSchedule(scriptName);
113
+ await this.commands.updateScheduleExternalInfo(scriptName, {
114
+ externalScheduleId: null,
115
+ externalScheduleName: null,
116
+ });
117
+ }
118
+ } catch (error) {
119
+ // Non-fatal: DB schedule is saved, external scheduler can be retried
120
+ result.warning = error.message;
121
+ }
122
+
123
+ return result;
124
+ }
125
+ }
126
+
127
+ module.exports = { UpsertScheduleUseCase };
@@ -6,7 +6,7 @@ describe('IntegrationHealthCheckScript', () => {
6
6
  expect(IntegrationHealthCheckScript.Definition.name).toBe('integration-health-check');
7
7
  expect(IntegrationHealthCheckScript.Definition.version).toBe('1.0.0');
8
8
  expect(IntegrationHealthCheckScript.Definition.source).toBe('BUILTIN');
9
- expect(IntegrationHealthCheckScript.Definition.config.requiresIntegrationFactory).toBe(true);
9
+ expect(IntegrationHealthCheckScript.Definition.config.requireIntegrationInstance).toBe(true);
10
10
  });
11
11
 
12
12
  it('should have valid input schema', () => {
@@ -6,7 +6,7 @@ describe('OAuthTokenRefreshScript', () => {
6
6
  expect(OAuthTokenRefreshScript.Definition.name).toBe('oauth-token-refresh');
7
7
  expect(OAuthTokenRefreshScript.Definition.version).toBe('1.0.0');
8
8
  expect(OAuthTokenRefreshScript.Definition.source).toBe('BUILTIN');
9
- expect(OAuthTokenRefreshScript.Definition.config.requiresIntegrationFactory).toBe(true);
9
+ expect(OAuthTokenRefreshScript.Definition.config.requireIntegrationInstance).toBe(true);
10
10
  });
11
11
 
12
12
  it('should have valid input schema', () => {
@@ -54,7 +54,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase {
54
54
  config: {
55
55
  timeout: 900000, // 15 minutes
56
56
  maxRetries: 0,
57
- requiresIntegrationFactory: true,
57
+ requireIntegrationInstance: true,
58
58
  },
59
59
 
60
60
  schedule: {
@@ -47,7 +47,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase {
47
47
  config: {
48
48
  timeout: 600000, // 10 minutes
49
49
  maxRetries: 1,
50
- requiresIntegrationFactory: true, // Needs to call external APIs
50
+ requireIntegrationInstance: true, // Needs to call external APIs
51
51
  },
52
52
 
53
53
  display: {