@friggframework/devtools 2.0.0--canary.474.898a56c.0 → 2.0.0--canary.474.a794ea3.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 (23) hide show
  1. package/infrastructure/domains/database/migration-builder.js +199 -1
  2. package/infrastructure/domains/database/migration-builder.test.js +73 -0
  3. package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
  4. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +397 -29
  5. package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
  6. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +6 -0
  7. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +162 -9
  8. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +19 -1
  9. package/infrastructure/domains/health/domain/entities/issue.js +50 -1
  10. package/infrastructure/domains/health/domain/entities/issue.test.js +111 -0
  11. package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
  12. package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
  13. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +55 -28
  14. package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
  15. package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
  16. package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
  17. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +21 -6
  18. package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
  19. package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
  20. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +407 -20
  21. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +698 -26
  22. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +392 -1
  23. package/package.json +6 -6
@@ -26,12 +26,16 @@ describe('LogicalIdMapper', () => {
26
26
  // Arrange
27
27
  const orphanedResources = [
28
28
  {
29
- physicalId: 'vpc-0eadd96976d29ede7',
29
+ physicalId: 'vpc-12345678',
30
30
  resourceType: 'AWS::EC2::VPC',
31
- tags: [
32
- { Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' },
33
- { Key: 'aws:cloudformation:logical-id', Value: 'FriggVPC' },
34
- ],
31
+ properties: {
32
+ VpcId: 'vpc-12345678',
33
+ CidrBlock: '10.0.0.0/16',
34
+ tags: {
35
+ 'aws:cloudformation:stack-name': 'acme-integrations-dev',
36
+ 'aws:cloudformation:logical-id': 'FriggVPC',
37
+ },
38
+ },
35
39
  },
36
40
  ];
37
41
 
@@ -49,7 +53,7 @@ describe('LogicalIdMapper', () => {
49
53
  expect(result).toHaveLength(1);
50
54
  expect(result[0]).toEqual({
51
55
  logicalId: 'FriggVPC',
52
- physicalId: 'vpc-0eadd96976d29ede7',
56
+ physicalId: 'vpc-12345678',
53
57
  resourceType: 'AWS::EC2::VPC',
54
58
  matchMethod: 'tag',
55
59
  confidence: 'high',
@@ -60,9 +64,13 @@ describe('LogicalIdMapper', () => {
60
64
  // Arrange
61
65
  const orphanedResources = [
62
66
  {
63
- physicalId: 'vpc-0eadd96976d29ede7',
67
+ physicalId: 'vpc-12345678',
64
68
  resourceType: 'AWS::EC2::VPC',
65
- tags: [],
69
+ properties: {
70
+ VpcId: 'vpc-12345678',
71
+ CidrBlock: '10.0.0.0/16',
72
+ tags: {},
73
+ },
66
74
  },
67
75
  ];
68
76
 
@@ -89,7 +97,7 @@ describe('LogicalIdMapper', () => {
89
97
  Type: 'AWS::Lambda::Function',
90
98
  Properties: {
91
99
  VpcConfig: {
92
- SubnetIds: ['subnet-00ab9e0502e66aac3', 'subnet-00d085a52937aaf91'],
100
+ SubnetIds: ['subnet-11111111', 'subnet-22222222'],
93
101
  },
94
102
  },
95
103
  },
@@ -99,8 +107,8 @@ describe('LogicalIdMapper', () => {
99
107
  // Mock EC2 describe-subnets response
100
108
  mockEc2Client.send.mockResolvedValueOnce({
101
109
  Subnets: [
102
- { SubnetId: 'subnet-00ab9e0502e66aac3', VpcId: 'vpc-0eadd96976d29ede7' },
103
- { SubnetId: 'subnet-00d085a52937aaf91', VpcId: 'vpc-0eadd96976d29ede7' },
110
+ { SubnetId: 'subnet-11111111', VpcId: 'vpc-12345678' },
111
+ { SubnetId: 'subnet-22222222', VpcId: 'vpc-12345678' },
104
112
  ],
105
113
  });
106
114
 
@@ -115,7 +123,7 @@ describe('LogicalIdMapper', () => {
115
123
  expect(result).toHaveLength(1);
116
124
  expect(result[0]).toEqual({
117
125
  logicalId: 'FriggVPC',
118
- physicalId: 'vpc-0eadd96976d29ede7',
126
+ physicalId: 'vpc-12345678',
119
127
  resourceType: 'AWS::EC2::VPC',
120
128
  matchMethod: 'contained-resources',
121
129
  confidence: 'high',
@@ -126,9 +134,13 @@ describe('LogicalIdMapper', () => {
126
134
  // Arrange
127
135
  const orphanedResources = [
128
136
  {
129
- physicalId: 'subnet-00ab9e0502e66aac3',
137
+ physicalId: 'subnet-11111111',
130
138
  resourceType: 'AWS::EC2::Subnet',
131
- tags: [],
139
+ properties: {
140
+ SubnetId: 'subnet-11111111',
141
+ VpcId: 'vpc-12345678',
142
+ tags: {},
143
+ },
132
144
  },
133
145
  ];
134
146
 
@@ -152,7 +164,7 @@ describe('LogicalIdMapper', () => {
152
164
  Type: 'AWS::Lambda::Function',
153
165
  Properties: {
154
166
  VpcConfig: {
155
- SubnetIds: ['subnet-00ab9e0502e66aac3'],
167
+ SubnetIds: ['subnet-11111111'],
156
168
  },
157
169
  },
158
170
  },
@@ -170,7 +182,7 @@ describe('LogicalIdMapper', () => {
170
182
  expect(result).toHaveLength(1);
171
183
  expect(result[0]).toEqual({
172
184
  logicalId: 'FriggPrivateSubnet1',
173
- physicalId: 'subnet-00ab9e0502e66aac3',
185
+ physicalId: 'subnet-11111111',
174
186
  resourceType: 'AWS::EC2::Subnet',
175
187
  matchMethod: 'vpc-usage',
176
188
  confidence: 'high',
@@ -183,7 +195,10 @@ describe('LogicalIdMapper', () => {
183
195
  {
184
196
  physicalId: 'sg-07c01370e830b6ad6',
185
197
  resourceType: 'AWS::EC2::SecurityGroup',
186
- tags: [],
198
+ properties: {
199
+ GroupId: 'sg-07c01370e830b6ad6',
200
+ tags: {},
201
+ },
187
202
  },
188
203
  ];
189
204
 
@@ -238,7 +253,10 @@ describe('LogicalIdMapper', () => {
238
253
  {
239
254
  physicalId: 'vpc-unknown',
240
255
  resourceType: 'AWS::EC2::VPC',
241
- tags: [],
256
+ properties: {
257
+ VpcId: 'vpc-unknown',
258
+ tags: {},
259
+ },
242
260
  },
243
261
  ];
244
262
 
@@ -267,14 +285,23 @@ describe('LogicalIdMapper', () => {
267
285
  // Arrange
268
286
  const orphanedResources = [
269
287
  {
270
- physicalId: 'vpc-0eadd96976d29ede7',
288
+ physicalId: 'vpc-12345678',
271
289
  resourceType: 'AWS::EC2::VPC',
272
- tags: [{ Key: 'aws:cloudformation:logical-id', Value: 'FriggVPC' }],
290
+ properties: {
291
+ VpcId: 'vpc-12345678',
292
+ tags: {
293
+ 'aws:cloudformation:logical-id': 'FriggVPC',
294
+ },
295
+ },
273
296
  },
274
297
  {
275
- physicalId: 'subnet-00ab9e0502e66aac3',
298
+ physicalId: 'subnet-11111111',
276
299
  resourceType: 'AWS::EC2::Subnet',
277
- tags: [],
300
+ properties: {
301
+ SubnetId: 'subnet-11111111',
302
+ VpcId: 'vpc-12345678',
303
+ tags: {},
304
+ },
278
305
  },
279
306
  ];
280
307
 
@@ -298,7 +325,7 @@ describe('LogicalIdMapper', () => {
298
325
  Type: 'AWS::Lambda::Function',
299
326
  Properties: {
300
327
  VpcConfig: {
301
- SubnetIds: ['subnet-00ab9e0502e66aac3'],
328
+ SubnetIds: ['subnet-11111111'],
302
329
  },
303
330
  },
304
331
  },
@@ -374,7 +401,7 @@ describe('LogicalIdMapper', () => {
374
401
  it('should match VPC that contains all expected subnets', async () => {
375
402
  // Arrange
376
403
  const vpc = {
377
- physicalId: 'vpc-0eadd96976d29ede7',
404
+ physicalId: 'vpc-12345678',
378
405
  resourceType: 'AWS::EC2::VPC',
379
406
  };
380
407
 
@@ -400,9 +427,9 @@ describe('LogicalIdMapper', () => {
400
427
  // Mock EC2 describe-subnets response
401
428
  mockEc2Client.send.mockResolvedValueOnce({
402
429
  Subnets: [
403
- { SubnetId: 'subnet-111', VpcId: 'vpc-0eadd96976d29ede7' },
404
- { SubnetId: 'subnet-222', VpcId: 'vpc-0eadd96976d29ede7' },
405
- { SubnetId: 'subnet-333', VpcId: 'vpc-0eadd96976d29ede7' },
430
+ { SubnetId: 'subnet-111', VpcId: 'vpc-12345678' },
431
+ { SubnetId: 'subnet-222', VpcId: 'vpc-12345678' },
432
+ { SubnetId: 'subnet-333', VpcId: 'vpc-12345678' },
406
433
  ],
407
434
  });
408
435
 
@@ -465,7 +492,7 @@ describe('LogicalIdMapper', () => {
465
492
  it('should return null if no expected subnets in deployed template', async () => {
466
493
  // Arrange
467
494
  const vpc = {
468
- physicalId: 'vpc-0eadd96976d29ede7',
495
+ physicalId: 'vpc-12345678',
469
496
  resourceType: 'AWS::EC2::VPC',
470
497
  };
471
498
 
@@ -0,0 +1,419 @@
1
+ /**
2
+ * UpdateProgressMonitor Tests
3
+ *
4
+ * TDD tests for monitoring CloudFormation UPDATE operations
5
+ */
6
+
7
+ const { UpdateProgressMonitor } = require('../update-progress-monitor');
8
+ const StackIdentifier = require('../../../domain/value-objects/stack-identifier');
9
+
10
+ // Mock timers for testing delays and timeouts
11
+ jest.useFakeTimers();
12
+
13
+ describe('UpdateProgressMonitor', () => {
14
+ let monitor;
15
+ let mockCFRepo;
16
+ let onProgressCallback;
17
+
18
+ beforeEach(() => {
19
+ // Reset mock CloudFormation repository
20
+ mockCFRepo = {
21
+ getStackEvents: jest.fn(),
22
+ getStackStatus: jest.fn(),
23
+ };
24
+
25
+ // Reset progress callback
26
+ onProgressCallback = jest.fn();
27
+
28
+ // Create monitor instance
29
+ monitor = new UpdateProgressMonitor({
30
+ cloudFormationRepository: mockCFRepo,
31
+ });
32
+
33
+ // Clear all timers
34
+ jest.clearAllTimers();
35
+ });
36
+
37
+ afterEach(() => {
38
+ jest.clearAllTimers();
39
+ });
40
+
41
+ describe('constructor', () => {
42
+ it('should require cloudFormationRepository', () => {
43
+ expect(() => new UpdateProgressMonitor({})).toThrow(
44
+ 'cloudFormationRepository is required'
45
+ );
46
+ });
47
+
48
+ it('should create instance with valid dependencies', () => {
49
+ const monitor = new UpdateProgressMonitor({
50
+ cloudFormationRepository: mockCFRepo,
51
+ });
52
+ expect(monitor).toBeInstanceOf(UpdateProgressMonitor);
53
+ });
54
+ });
55
+
56
+ describe('monitorUpdate - successful update', () => {
57
+ it('should monitor single resource update to completion', async () => {
58
+ const stackIdentifier = new StackIdentifier({
59
+ stackName: 'test-stack',
60
+ region: 'us-east-1',
61
+ });
62
+
63
+ const resourceLogicalIds = ['AttioLambdaFunction'];
64
+
65
+ // Mock stack events sequence
66
+ mockCFRepo.getStackEvents
67
+ // First poll: UPDATE_IN_PROGRESS
68
+ .mockResolvedValueOnce([
69
+ {
70
+ LogicalResourceId: 'AttioLambdaFunction',
71
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
72
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
73
+ },
74
+ ])
75
+ // Second poll: UPDATE_COMPLETE
76
+ .mockResolvedValueOnce([
77
+ {
78
+ LogicalResourceId: 'AttioLambdaFunction',
79
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
80
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
81
+ },
82
+ {
83
+ LogicalResourceId: 'AttioLambdaFunction',
84
+ ResourceStatus: 'UPDATE_COMPLETE',
85
+ Timestamp: new Date('2025-01-01T00:00:05Z'),
86
+ },
87
+ ]);
88
+
89
+ // Mock stack status
90
+ mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_COMPLETE');
91
+
92
+ // Start monitoring in background
93
+ const monitorPromise = monitor.monitorUpdate({
94
+ stackIdentifier,
95
+ resourceLogicalIds,
96
+ onProgress: onProgressCallback,
97
+ });
98
+
99
+ // Advance timers to trigger first poll (2 seconds)
100
+ await jest.advanceTimersByTimeAsync(2000);
101
+
102
+ // Advance timers to trigger second poll (2 more seconds)
103
+ await jest.advanceTimersByTimeAsync(2000);
104
+
105
+ // Wait for monitoring to complete
106
+ const result = await monitorPromise;
107
+
108
+ // Verify result
109
+ expect(result.success).toBe(true);
110
+ expect(result.updatedCount).toBe(1);
111
+ expect(result.failedCount).toBe(0);
112
+
113
+ // Verify progress callbacks
114
+ expect(onProgressCallback).toHaveBeenCalledWith({
115
+ logicalId: 'AttioLambdaFunction',
116
+ status: 'IN_PROGRESS',
117
+ });
118
+ expect(onProgressCallback).toHaveBeenCalledWith({
119
+ logicalId: 'AttioLambdaFunction',
120
+ status: 'COMPLETE',
121
+ progress: 1,
122
+ total: 1,
123
+ });
124
+ });
125
+
126
+ it('should monitor multiple resources updating simultaneously', async () => {
127
+ const stackIdentifier = new StackIdentifier({
128
+ stackName: 'test-stack',
129
+ region: 'us-east-1',
130
+ });
131
+
132
+ const resourceLogicalIds = [
133
+ 'AttioLambdaFunction',
134
+ 'PipedriveLambdaFunction',
135
+ 'ZohoCrmLambdaFunction',
136
+ ];
137
+
138
+ // Mock stack events - all resources update together
139
+ mockCFRepo.getStackEvents
140
+ // First poll: All IN_PROGRESS
141
+ .mockResolvedValueOnce([
142
+ {
143
+ LogicalResourceId: 'AttioLambdaFunction',
144
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
145
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
146
+ },
147
+ {
148
+ LogicalResourceId: 'PipedriveLambdaFunction',
149
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
150
+ Timestamp: new Date('2025-01-01T00:00:02Z'),
151
+ },
152
+ {
153
+ LogicalResourceId: 'ZohoCrmLambdaFunction',
154
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
155
+ Timestamp: new Date('2025-01-01T00:00:03Z'),
156
+ },
157
+ ])
158
+ // Second poll: First complete
159
+ .mockResolvedValueOnce([
160
+ {
161
+ LogicalResourceId: 'AttioLambdaFunction',
162
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
163
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
164
+ },
165
+ {
166
+ LogicalResourceId: 'AttioLambdaFunction',
167
+ ResourceStatus: 'UPDATE_COMPLETE',
168
+ Timestamp: new Date('2025-01-01T00:00:10Z'),
169
+ },
170
+ {
171
+ LogicalResourceId: 'PipedriveLambdaFunction',
172
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
173
+ Timestamp: new Date('2025-01-01T00:00:02Z'),
174
+ },
175
+ {
176
+ LogicalResourceId: 'ZohoCrmLambdaFunction',
177
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
178
+ Timestamp: new Date('2025-01-01T00:00:03Z'),
179
+ },
180
+ ])
181
+ // Third poll: All complete
182
+ .mockResolvedValueOnce([
183
+ {
184
+ LogicalResourceId: 'AttioLambdaFunction',
185
+ ResourceStatus: 'UPDATE_COMPLETE',
186
+ Timestamp: new Date('2025-01-01T00:00:10Z'),
187
+ },
188
+ {
189
+ LogicalResourceId: 'PipedriveLambdaFunction',
190
+ ResourceStatus: 'UPDATE_COMPLETE',
191
+ Timestamp: new Date('2025-01-01T00:00:11Z'),
192
+ },
193
+ {
194
+ LogicalResourceId: 'ZohoCrmLambdaFunction',
195
+ ResourceStatus: 'UPDATE_COMPLETE',
196
+ Timestamp: new Date('2025-01-01T00:00:12Z'),
197
+ },
198
+ ]);
199
+
200
+ mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_COMPLETE');
201
+
202
+ const monitorPromise = monitor.monitorUpdate({
203
+ stackIdentifier,
204
+ resourceLogicalIds,
205
+ onProgress: onProgressCallback,
206
+ });
207
+
208
+ // Advance through polling intervals
209
+ await jest.advanceTimersByTimeAsync(2000);
210
+ await jest.advanceTimersByTimeAsync(2000);
211
+ await jest.advanceTimersByTimeAsync(2000);
212
+
213
+ const result = await monitorPromise;
214
+
215
+ expect(result.success).toBe(true);
216
+ expect(result.updatedCount).toBe(3);
217
+ expect(result.failedCount).toBe(0);
218
+ });
219
+ });
220
+
221
+ describe('monitorUpdate - failed updates', () => {
222
+ it('should detect and report UPDATE_FAILED resources', async () => {
223
+ const stackIdentifier = new StackIdentifier({
224
+ stackName: 'test-stack',
225
+ region: 'us-east-1',
226
+ });
227
+
228
+ const resourceLogicalIds = ['AttioLambdaFunction'];
229
+
230
+ mockCFRepo.getStackEvents
231
+ .mockResolvedValueOnce([
232
+ {
233
+ LogicalResourceId: 'AttioLambdaFunction',
234
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
235
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
236
+ },
237
+ ])
238
+ .mockResolvedValueOnce([
239
+ {
240
+ LogicalResourceId: 'AttioLambdaFunction',
241
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
242
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
243
+ },
244
+ {
245
+ LogicalResourceId: 'AttioLambdaFunction',
246
+ ResourceStatus: 'UPDATE_FAILED',
247
+ ResourceStatusReason: 'Subnet does not exist: subnet-invalid',
248
+ Timestamp: new Date('2025-01-01T00:00:05Z'),
249
+ },
250
+ ]);
251
+
252
+ mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_ROLLBACK_COMPLETE');
253
+
254
+ const monitorPromise = monitor.monitorUpdate({
255
+ stackIdentifier,
256
+ resourceLogicalIds,
257
+ onProgress: onProgressCallback,
258
+ });
259
+
260
+ await jest.advanceTimersByTimeAsync(2000);
261
+ await jest.advanceTimersByTimeAsync(2000);
262
+
263
+ const result = await monitorPromise;
264
+
265
+ expect(result.success).toBe(false);
266
+ expect(result.updatedCount).toBe(0);
267
+ expect(result.failedCount).toBe(1);
268
+ expect(result.failedResources).toHaveLength(1);
269
+ expect(result.failedResources[0].logicalId).toBe('AttioLambdaFunction');
270
+ expect(result.failedResources[0].reason).toBe('Subnet does not exist: subnet-invalid');
271
+
272
+ // Verify FAILED callback was triggered
273
+ expect(onProgressCallback).toHaveBeenCalledWith({
274
+ logicalId: 'AttioLambdaFunction',
275
+ status: 'FAILED',
276
+ reason: 'Subnet does not exist: subnet-invalid',
277
+ });
278
+ });
279
+
280
+ it('should detect stack rollback during update', async () => {
281
+ const stackIdentifier = new StackIdentifier({
282
+ stackName: 'test-stack',
283
+ region: 'us-east-1',
284
+ });
285
+
286
+ const resourceLogicalIds = ['AttioLambdaFunction'];
287
+
288
+ mockCFRepo.getStackEvents.mockResolvedValue([
289
+ {
290
+ LogicalResourceId: 'AttioLambdaFunction',
291
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
292
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
293
+ },
294
+ ]);
295
+
296
+ // Stack status shows rollback in progress
297
+ mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_ROLLBACK_IN_PROGRESS');
298
+
299
+ const monitorPromise = monitor.monitorUpdate({
300
+ stackIdentifier,
301
+ resourceLogicalIds,
302
+ onProgress: onProgressCallback,
303
+ });
304
+
305
+ await jest.advanceTimersByTimeAsync(2000);
306
+
307
+ await expect(monitorPromise).rejects.toThrow('Update operation failed and rolled back');
308
+ });
309
+ });
310
+
311
+ describe('monitorUpdate - timeout handling', () => {
312
+ it('should timeout after 5 minutes', async () => {
313
+ const stackIdentifier = new StackIdentifier({
314
+ stackName: 'test-stack',
315
+ region: 'us-east-1',
316
+ });
317
+
318
+ const resourceLogicalIds = ['AttioLambdaFunction'];
319
+
320
+ // Mock events that never complete
321
+ mockCFRepo.getStackEvents.mockResolvedValue([
322
+ {
323
+ LogicalResourceId: 'AttioLambdaFunction',
324
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
325
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
326
+ },
327
+ ]);
328
+
329
+ mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_IN_PROGRESS');
330
+
331
+ const monitorPromise = monitor.monitorUpdate({
332
+ stackIdentifier,
333
+ resourceLogicalIds,
334
+ onProgress: onProgressCallback,
335
+ });
336
+
337
+ // Advance past 5 minute timeout (300000ms)
338
+ await jest.advanceTimersByTimeAsync(300000 + 2000);
339
+
340
+ await expect(monitorPromise).rejects.toThrow('Update operation timed out');
341
+ });
342
+ });
343
+
344
+ describe('monitorUpdate - event deduplication', () => {
345
+ it('should not process duplicate events', async () => {
346
+ const stackIdentifier = new StackIdentifier({
347
+ stackName: 'test-stack',
348
+ region: 'us-east-1',
349
+ });
350
+
351
+ const resourceLogicalIds = ['AttioLambdaFunction'];
352
+
353
+ // Mock duplicate events (same timestamp + logicalId + status)
354
+ mockCFRepo.getStackEvents
355
+ .mockResolvedValueOnce([
356
+ {
357
+ LogicalResourceId: 'AttioLambdaFunction',
358
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
359
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
360
+ },
361
+ ])
362
+ .mockResolvedValueOnce([
363
+ // Duplicate event
364
+ {
365
+ LogicalResourceId: 'AttioLambdaFunction',
366
+ ResourceStatus: 'UPDATE_IN_PROGRESS',
367
+ Timestamp: new Date('2025-01-01T00:00:01Z'),
368
+ },
369
+ // New event
370
+ {
371
+ LogicalResourceId: 'AttioLambdaFunction',
372
+ ResourceStatus: 'UPDATE_COMPLETE',
373
+ Timestamp: new Date('2025-01-01T00:00:05Z'),
374
+ },
375
+ ]);
376
+
377
+ mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_COMPLETE');
378
+
379
+ const monitorPromise = monitor.monitorUpdate({
380
+ stackIdentifier,
381
+ resourceLogicalIds,
382
+ onProgress: onProgressCallback,
383
+ });
384
+
385
+ await jest.advanceTimersByTimeAsync(2000);
386
+ await jest.advanceTimersByTimeAsync(2000);
387
+
388
+ await monitorPromise;
389
+
390
+ // IN_PROGRESS callback should only be called once (not twice for duplicate)
391
+ const inProgressCalls = onProgressCallback.mock.calls.filter(
392
+ (call) => call[0].status === 'IN_PROGRESS'
393
+ );
394
+ expect(inProgressCalls).toHaveLength(1);
395
+ });
396
+ });
397
+
398
+ describe('monitorUpdate - no resources to track', () => {
399
+ it('should return immediately if no resources to track', async () => {
400
+ const stackIdentifier = new StackIdentifier({
401
+ stackName: 'test-stack',
402
+ region: 'us-east-1',
403
+ });
404
+
405
+ const result = await monitor.monitorUpdate({
406
+ stackIdentifier,
407
+ resourceLogicalIds: [],
408
+ onProgress: onProgressCallback,
409
+ });
410
+
411
+ expect(result.success).toBe(true);
412
+ expect(result.updatedCount).toBe(0);
413
+ expect(result.failedCount).toBe(0);
414
+
415
+ // Should not have polled CloudFormation
416
+ expect(mockCFRepo.getStackEvents).not.toHaveBeenCalled();
417
+ });
418
+ });
419
+ });