diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/application/use-cases/ExecuteDryRunUseCase.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/application/use-cases/ExecuteDryRunUseCase.test.js new file mode 100644 index 000000000..d7bae9607 --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/application/use-cases/ExecuteDryRunUseCase.test.js @@ -0,0 +1,993 @@ +/** + * ExecuteDryRunUseCase Tests + * + * TDD test suite for the dry-run orchestrator use case + */ + +const { ExecuteDryRunUseCase } = require('../../../application/use-cases/ExecuteDryRunUseCase'); +const { DryRunReport } = require('../../../domain/entities/DryRunReport'); +const { DryRunStatus } = require('../../../domain/value-objects/DryRunStatus'); +const { ValidationResult } = require('../../../domain/value-objects/ValidationResult'); +const { ChangeSetSummary } = require('../../../domain/value-objects/ChangeSetSummary'); +const { + createMockChangeSetCreator, + createMockEnvironmentValidator, + createMockTemplateGenerator, +} = require('../../helpers/test-utils'); + +describe('ExecuteDryRunUseCase', () => { + let mockPreFlightChecker; + let mockEnvironmentValidator; + let mockTemplateGenerator; + let mockChangeSetCreator; + let mockChangeSetAnalyzer; + let useCase; + + beforeEach(() => { + // Setup default mocks + mockPreFlightChecker = { + check: jest.fn(), + }; + + mockEnvironmentValidator = createMockEnvironmentValidator(); + + mockTemplateGenerator = createMockTemplateGenerator(); + + mockChangeSetCreator = createMockChangeSetCreator(); + + mockChangeSetAnalyzer = { + analyzeChangeSet: jest.fn(), + }; + + useCase = new ExecuteDryRunUseCase({ + preFlightChecker: mockPreFlightChecker, + environmentValidator: mockEnvironmentValidator, + templateGenerator: mockTemplateGenerator, + changeSetCreator: mockChangeSetCreator, + changeSetAnalyzer: mockChangeSetAnalyzer, + }); + }); + + describe('Constructor', () => { + it('should throw if preFlightChecker is missing', () => { + expect(() => { + new ExecuteDryRunUseCase({ + environmentValidator: mockEnvironmentValidator, + templateGenerator: mockTemplateGenerator, + changeSetCreator: mockChangeSetCreator, + changeSetAnalyzer: mockChangeSetAnalyzer, + }); + }).toThrow('preFlightChecker is required'); + }); + + it('should throw if environmentValidator is missing', () => { + expect(() => { + new ExecuteDryRunUseCase({ + preFlightChecker: mockPreFlightChecker, + templateGenerator: mockTemplateGenerator, + changeSetCreator: mockChangeSetCreator, + changeSetAnalyzer: mockChangeSetAnalyzer, + }); + }).toThrow('environmentValidator is required'); + }); + + it('should throw if templateGenerator is missing', () => { + expect(() => { + new ExecuteDryRunUseCase({ + preFlightChecker: mockPreFlightChecker, + environmentValidator: mockEnvironmentValidator, + changeSetCreator: mockChangeSetCreator, + changeSetAnalyzer: mockChangeSetAnalyzer, + }); + }).toThrow('templateGenerator is required'); + }); + + it('should throw if changeSetCreator is missing', () => { + expect(() => { + new ExecuteDryRunUseCase({ + preFlightChecker: mockPreFlightChecker, + environmentValidator: mockEnvironmentValidator, + templateGenerator: mockTemplateGenerator, + changeSetAnalyzer: mockChangeSetAnalyzer, + }); + }).toThrow('changeSetCreator is required'); + }); + + it('should throw if changeSetAnalyzer is missing', () => { + expect(() => { + new ExecuteDryRunUseCase({ + preFlightChecker: mockPreFlightChecker, + environmentValidator: mockEnvironmentValidator, + templateGenerator: mockTemplateGenerator, + changeSetCreator: mockChangeSetCreator, + }); + }).toThrow('changeSetAnalyzer is required'); + }); + + it('should create instance with all dependencies', () => { + expect(useCase).toBeInstanceOf(ExecuteDryRunUseCase); + }); + }); + + describe('execute - Happy Path', () => { + it('should successfully execute all phases and return complete DryRunReport', async () => { + // Arrange + const appPath = '/test/app'; + const stackName = 'test-stack'; + const region = 'us-east-1'; + const stage = 'dev'; + + mockPreFlightChecker.check.mockResolvedValue( + ValidationResult.success({ + appName: 'test-app', + provider: 'aws', + }) + ); + + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success({ + required: { present: ['AWS_REGION'], missing: [] }, + optional: { present: [], missing: [] }, + }) + ); + + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ + accountId: '123456789012', + region: 'us-east-1', + }) + ); + + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: { + functions: ['handler'], + endpoints: ['/api/test'], + resources: { lambdaCount: 1 }, + }, + }); + + const mockChangeSet = { + Id: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/test-changeset', + StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack', + Changes: [ + { + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'HandlerFunction', + ResourceType: 'AWS::Lambda::Function', + }, + }, + ], + }; + + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue(mockChangeSet); + + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.fromChanges(mockChangeSet.Changes), + criticalChanges: [], + warnings: [], + impact: { + lambdaFunctionsAffected: 1, + databasesAffected: 0, + replacements: 0, + estimatedDowntime: 'None expected', + coldStartsExpected: true, + breakingChanges: false, + }, + }); + + // Act + const result = await useCase.execute({ + appPath, + stackName, + region, + stage, + }); + + // Assert + expect(result).toBeInstanceOf(DryRunReport); + expect(result.stackName).toBe(stackName); + expect(result.region).toBe(region); + expect(result.stage).toBe(stage); + expect(result.status.isSuccess()).toBe(true); + + // Verify pre-flight was called + expect(mockPreFlightChecker.check).toHaveBeenCalledWith(appPath); + + // Verify environment validation was called + expect(mockEnvironmentValidator.validateEnvironmentVariables).toHaveBeenCalled(); + expect(mockEnvironmentValidator.validateAwsCredentials).toHaveBeenCalledWith(region); + + // Verify template generation was called + expect(mockTemplateGenerator.generateTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + appPath, + stage, + }) + ); + + // Verify change set creation was called + expect(mockChangeSetCreator.createChangeSet).toHaveBeenCalled(); + expect(mockChangeSetCreator.getChangeSetDetails).toHaveBeenCalled(); + + // Verify change set analysis was called + expect(mockChangeSetAnalyzer.analyzeChangeSet).toHaveBeenCalledWith(mockChangeSet); + + // Verify report has all phase results + expect(result.preFlight).toBeDefined(); + expect(result.environment).toBeInstanceOf(ValidationResult); + expect(result.template).toBeDefined(); + expect(result.changeSet).toBeDefined(); + expect(result.impact).toBeDefined(); + }); + + it('should handle new stack creation (stack does not exist)', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + mockChangeSetCreator.stackExists.mockResolvedValue(false); + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ + Changes: [], + }); + + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: { + lambdaFunctionsAffected: 0, + databasesAffected: 0, + replacements: 0, + estimatedDowntime: 'None expected', + coldStartsExpected: false, + breakingChanges: false, + }, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'new-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result).toBeInstanceOf(DryRunReport); + expect(result.status.isSuccess()).toBe(true); + expect(mockChangeSetCreator.stackExists).toHaveBeenCalled(); + }); + }); + + describe('execute - Pre-Flight Check Failures', () => { + it('should stop execution if pre-flight check fails', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue( + ValidationResult.failure(['Missing required file: index.js'], []) + ); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result).toBeInstanceOf(DryRunReport); + expect(result.status.hasErrors()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + expect(result.preFlight).toBeDefined(); + expect(result.preFlight.hasErrors()).toBe(true); + + // Verify subsequent phases were NOT called + expect(mockEnvironmentValidator.validateEnvironmentVariables).not.toHaveBeenCalled(); + expect(mockTemplateGenerator.generateTemplate).not.toHaveBeenCalled(); + expect(mockChangeSetCreator.createChangeSet).not.toHaveBeenCalled(); + }); + + it('should continue if pre-flight check has warnings only', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue( + ValidationResult.withWarnings(['Optional feature not configured'], {}) + ); + + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ Changes: [] }); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: {}, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasWarnings()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.WARNING); + + // Verify all phases were called + expect(mockEnvironmentValidator.validateEnvironmentVariables).toHaveBeenCalled(); + expect(mockTemplateGenerator.generateTemplate).toHaveBeenCalled(); + }); + }); + + describe('execute - Environment Validation Failures', () => { + it('should stop execution if environment validation has errors', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.failure(['Missing required variable: AWS_REGION'], []) + ); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasErrors()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + expect(result.environment).toBeDefined(); + expect(result.environment.hasErrors()).toBe(true); + + // Verify subsequent phases were NOT called + expect(mockTemplateGenerator.generateTemplate).not.toHaveBeenCalled(); + expect(mockChangeSetCreator.createChangeSet).not.toHaveBeenCalled(); + }); + + it('should continue if environment validation has warnings only', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.withWarnings(['Optional variable not set: CACHE_TTL'], {}) + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ Changes: [] }); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: {}, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasWarnings()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.WARNING); + + // Verify all phases were called + expect(mockTemplateGenerator.generateTemplate).toHaveBeenCalled(); + expect(mockChangeSetCreator.createChangeSet).toHaveBeenCalled(); + }); + + it('should stop execution if AWS credentials validation fails', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.failure(['Invalid AWS credentials'], []) + ); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasErrors()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + + // Verify subsequent phases were NOT called + expect(mockTemplateGenerator.generateTemplate).not.toHaveBeenCalled(); + }); + }); + + describe('execute - Template Generation Failures', () => { + it('should handle template generation error gracefully', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + + const templateError = new Error('Failed to generate template: Invalid configuration'); + mockTemplateGenerator.generateTemplate.mockRejectedValue(templateError); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasErrors()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + expect(result.template).toEqual({ + error: templateError.message, + }); + + // Verify change set creation was NOT called + expect(mockChangeSetCreator.createChangeSet).not.toHaveBeenCalled(); + }); + }); + + describe('execute - Change Set Creation Failures', () => { + it('should handle change set creation error gracefully', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + const changeSetError = new Error('Failed to create change set'); + mockChangeSetCreator.createChangeSet.mockRejectedValue(changeSetError); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasErrors()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + expect(result.changeSet).toEqual({ + error: changeSetError.message, + }); + + // Verify analysis was NOT called + expect(mockChangeSetAnalyzer.analyzeChangeSet).not.toHaveBeenCalled(); + }); + + it('should handle no changes scenario', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ + Changes: [], + }); + + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: { + lambdaFunctionsAffected: 0, + databasesAffected: 0, + replacements: 0, + estimatedDowntime: 'None expected', + coldStartsExpected: false, + breakingChanges: false, + }, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.isSuccess()).toBe(true); + expect(result.changeSet).toBeDefined(); + expect(result.impact).toBeDefined(); + expect(result.impact.summary.hasChanges()).toBe(false); + }); + }); + + describe('execute - Change Set Analysis', () => { + it('should analyze change set with additions', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + const mockChangeSet = { + Changes: [ + { + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'NewFunction', + ResourceType: 'AWS::Lambda::Function', + }, + }, + ], + }; + + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue(mockChangeSet); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.fromChanges(mockChangeSet.Changes), + criticalChanges: [], + warnings: [], + impact: { + lambdaFunctionsAffected: 1, + databasesAffected: 0, + replacements: 0, + estimatedDowntime: 'None expected', + coldStartsExpected: true, + breakingChanges: false, + }, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.isSuccess()).toBe(true); + expect(result.impact.summary.add).toBe(1); + expect(mockChangeSetAnalyzer.analyzeChangeSet).toHaveBeenCalledWith(mockChangeSet); + }); + + it('should analyze change set with modifications', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + const mockChangeSet = { + Changes: [ + { + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ExistingFunction', + ResourceType: 'AWS::Lambda::Function', + Replacement: 'False', + }, + }, + ], + }; + + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue(mockChangeSet); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.fromChanges(mockChangeSet.Changes), + criticalChanges: [], + warnings: [], + impact: { + lambdaFunctionsAffected: 1, + databasesAffected: 0, + replacements: 0, + estimatedDowntime: 'None expected', + coldStartsExpected: true, + breakingChanges: false, + }, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.isSuccess()).toBe(true); + expect(result.impact.summary.modify).toBe(1); + }); + + it('should analyze change set with replacements and mark as critical', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + const mockChangeSet = { + Changes: [ + { + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'CriticalFunction', + ResourceType: 'AWS::Lambda::Function', + Replacement: 'True', + }, + }, + ], + }; + + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue(mockChangeSet); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.fromChanges(mockChangeSet.Changes), + criticalChanges: [ + { + logicalId: 'CriticalFunction', + resourceType: 'AWS::Lambda::Function', + action: 'Modify', + reason: 'Requires replacement', + severity: 'high', + }, + ], + warnings: [], + impact: { + lambdaFunctionsAffected: 1, + databasesAffected: 0, + replacements: 1, + estimatedDowntime: '2-5 minutes', + coldStartsExpected: true, + breakingChanges: true, + }, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.isSuccess()).toBe(true); + expect(result.impact.summary.replace).toBe(1); + expect(result.impact.criticalChanges).toHaveLength(1); + expect(result.impact.impact.breakingChanges).toBe(true); + }); + + it('should handle multiple change types', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + + const mockChangeSet = { + Changes: [ + { + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'NewFunction', + ResourceType: 'AWS::Lambda::Function', + }, + }, + { + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ExistingFunction', + ResourceType: 'AWS::Lambda::Function', + Replacement: 'False', + }, + }, + { + ResourceChange: { + Action: 'Remove', + LogicalResourceId: 'OldFunction', + ResourceType: 'AWS::Lambda::Function', + }, + }, + ], + }; + + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue(mockChangeSet); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.fromChanges(mockChangeSet.Changes), + criticalChanges: [ + { + logicalId: 'OldFunction', + resourceType: 'AWS::Lambda::Function', + action: 'Remove', + reason: 'Resource will be deleted', + severity: 'high', + }, + ], + warnings: [], + impact: { + lambdaFunctionsAffected: 3, + databasesAffected: 0, + replacements: 0, + estimatedDowntime: 'None expected', + coldStartsExpected: true, + breakingChanges: true, + }, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.isSuccess()).toBe(true); + expect(result.impact.summary.add).toBe(1); + expect(result.impact.summary.modify).toBe(1); + expect(result.impact.summary.remove).toBe(1); + expect(result.impact.criticalChanges).toHaveLength(1); + }); + }); + + describe('execute - Status Determination', () => { + it('should return SUCCESS status when no errors or warnings', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ Changes: [] }); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: {}, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.isSuccess()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.SUCCESS); + expect(result.getExitCode()).toBe(0); + }); + + it('should return WARNING status when warnings are present', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue( + ValidationResult.withWarnings(['Config warning']) + ); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ Changes: [] }); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: {}, + }); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasWarnings()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.WARNING); + expect(result.getExitCode()).toBe(2); + }); + + it('should return VALIDATION_ERROR status when errors are present', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue( + ValidationResult.failure(['Missing file'], []) + ); + + // Act + const result = await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }); + + // Assert + expect(result.status.hasErrors()).toBe(true); + expect(result.status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + expect(result.getExitCode()).toBe(1); + }); + }); + + describe('execute - Input Validation', () => { + it('should throw error if appPath is missing', async () => { + await expect( + useCase.execute({ + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + }) + ).rejects.toThrow('appPath is required'); + }); + + it('should throw error if stackName is missing', async () => { + await expect( + useCase.execute({ + appPath: '/test/app', + region: 'us-east-1', + stage: 'dev', + }) + ).rejects.toThrow('stackName is required'); + }); + + it('should throw error if region is missing', async () => { + await expect( + useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + stage: 'dev', + }) + ).rejects.toThrow('region is required'); + }); + + it('should throw error if stage is missing', async () => { + await expect( + useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + }) + ).rejects.toThrow('stage is required'); + }); + }); + + describe('execute - Options Handling', () => { + it('should pass options to template generator', async () => { + // Arrange + mockPreFlightChecker.check.mockResolvedValue(ValidationResult.success()); + mockEnvironmentValidator.validateEnvironmentVariables.mockResolvedValue( + ValidationResult.success() + ); + mockEnvironmentValidator.validateAwsCredentials.mockResolvedValue( + ValidationResult.success({ accountId: '123456789012' }) + ); + mockTemplateGenerator.generateTemplate.mockResolvedValue({ + template: 'Resources: {}', + summary: {}, + }); + mockChangeSetCreator.getChangeSetDetails.mockResolvedValue({ Changes: [] }); + mockChangeSetAnalyzer.analyzeChangeSet.mockReturnValue({ + summary: ChangeSetSummary.empty(), + criticalChanges: [], + warnings: [], + impact: {}, + }); + + const options = { + verbose: true, + timeout: 5000, + }; + + // Act + await useCase.execute({ + appPath: '/test/app', + stackName: 'test-stack', + region: 'us-east-1', + stage: 'dev', + options, + }); + + // Assert + expect(mockTemplateGenerator.generateTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + options, + }) + ); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/domain/services/ChangeSetAnalyzer.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/domain/services/ChangeSetAnalyzer.test.js new file mode 100644 index 000000000..6b1c47296 --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/domain/services/ChangeSetAnalyzer.test.js @@ -0,0 +1,717 @@ +/** + * ChangeSetAnalyzer Service Tests + * + * Comprehensive test suite for the ChangeSetAnalyzer domain service + * following TDD principles + */ + +const { ChangeSetAnalyzer } = require('../../../domain/services/ChangeSetAnalyzer'); +const { ChangeSetSummary } = require('../../../domain/value-objects/ChangeSetSummary'); +const { + mockChangeSetEmpty, + mockChangeSetWithAdditions, + mockChangeSetWithModifications, + mockChangeSetWithReplacements, + mockChangeSetWithDatabase, +} = require('../../fixtures/mock-change-sets'); + +describe('ChangeSetAnalyzer', () => { + let analyzer; + + beforeEach(() => { + analyzer = new ChangeSetAnalyzer(); + }); + + describe('analyzeChangeSet()', () => { + describe('when change set is empty or invalid', () => { + it('should handle null change set', () => { + const result = analyzer.analyzeChangeSet(null); + + expect(result.summary).toBeInstanceOf(ChangeSetSummary); + expect(result.summary.add).toBe(0); + expect(result.summary.modify).toBe(0); + expect(result.summary.remove).toBe(0); + expect(result.summary.replace).toBe(0); + expect(result.criticalChanges).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.impact.lambdaFunctionsAffected).toBe(0); + expect(result.impact.databasesAffected).toBe(0); + expect(result.impact.replacements).toBe(0); + expect(result.impact.estimatedDowntime).toBe('None expected'); + expect(result.impact.coldStartsExpected).toBe(false); + expect(result.impact.breakingChanges).toBe(false); + }); + + it('should handle undefined change set', () => { + const result = analyzer.analyzeChangeSet(undefined); + + expect(result.summary).toBeInstanceOf(ChangeSetSummary); + expect(result.summary.total).toBe(0); + expect(result.criticalChanges).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('should handle change set without Changes property', () => { + const changeSet = { + ChangeSetId: 'test-id', + StackName: 'test-stack', + }; + + const result = analyzer.analyzeChangeSet(changeSet); + + expect(result.summary.total).toBe(0); + expect(result.criticalChanges).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('should handle empty change set with no changes', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetEmpty); + + expect(result.summary).toBeInstanceOf(ChangeSetSummary); + expect(result.summary.add).toBe(0); + expect(result.summary.modify).toBe(0); + expect(result.summary.remove).toBe(0); + expect(result.summary.replace).toBe(0); + expect(result.summary.total).toBe(0); + expect(result.summary.hasChanges()).toBe(false); + expect(result.criticalChanges).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.impact.estimatedDowntime).toBe('None expected'); + expect(result.impact.breakingChanges).toBe(false); + }); + }); + + describe('when change set has additions only', () => { + it('should correctly count additions', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.summary.add).toBe(3); + expect(result.summary.modify).toBe(0); + expect(result.summary.remove).toBe(0); + expect(result.summary.replace).toBe(0); + expect(result.summary.total).toBe(3); + }); + + it('should identify no critical changes for additions', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.criticalChanges).toEqual([]); + expect(result.summary.hasCriticalChanges()).toBe(false); + }); + + it('should calculate impact for Lambda additions', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.impact.lambdaFunctionsAffected).toBe(2); + expect(result.impact.databasesAffected).toBe(0); + expect(result.impact.replacements).toBe(0); + expect(result.impact.coldStartsExpected).toBe(true); + expect(result.impact.breakingChanges).toBe(false); + expect(result.impact.estimatedDowntime).toBe('None expected'); + }); + + it('should generate no warnings for simple additions', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.warnings).toEqual([]); + }); + }); + + describe('when change set has modifications', () => { + it('should correctly count modifications', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + expect(result.summary.add).toBe(0); + expect(result.summary.modify).toBe(2); + expect(result.summary.remove).toBe(0); + expect(result.summary.replace).toBe(0); + expect(result.summary.total).toBe(2); + }); + + it('should identify affected Lambda functions', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + expect(result.impact.lambdaFunctionsAffected).toBe(2); + expect(result.impact.coldStartsExpected).toBe(true); + }); + + it('should not identify critical changes for regular modifications', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + expect(result.criticalChanges).toEqual([]); + expect(result.summary.hasCriticalChanges()).toBe(false); + }); + + it('should estimate no downtime for regular modifications', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + expect(result.impact.estimatedDowntime).toBe('None expected'); + expect(result.impact.breakingChanges).toBe(false); + }); + }); + + describe('when change set has replacements', () => { + it('should correctly count replacements', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(result.summary.add).toBe(0); + expect(result.summary.modify).toBe(0); + expect(result.summary.remove).toBe(1); + expect(result.summary.replace).toBe(1); + expect(result.summary.total).toBe(1); + }); + + it('should identify critical changes for replacements', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(result.criticalChanges).toHaveLength(2); + + const replacementChange = result.criticalChanges.find( + (c) => c.logicalId === 'DatabaseSecurityGroup' + ); + expect(replacementChange).toBeDefined(); + expect(replacementChange.action).toBe('Modify'); + expect(replacementChange.resourceType).toBe('AWS::EC2::SecurityGroup'); + expect(replacementChange.reason).toBe('Requires replacement'); + expect(replacementChange.severity).toBe('high'); + expect(replacementChange.physicalId).toBe('sg-abc123'); + }); + + it('should identify critical changes for removals', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + const removalChange = result.criticalChanges.find( + (c) => c.logicalId === 'OldLambdaFunction' + ); + expect(removalChange).toBeDefined(); + expect(removalChange.action).toBe('Remove'); + expect(removalChange.resourceType).toBe('AWS::Lambda::Function'); + expect(removalChange.reason).toBe('Resource will be deleted'); + expect(removalChange.severity).toBe('high'); + expect(removalChange.physicalId).toBe('test-stack-OldLambdaFunction-OLD123'); + }); + + it('should mark as having critical changes', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(result.summary.hasCriticalChanges()).toBe(true); + }); + + it('should estimate downtime for replacements', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(result.impact.replacements).toBe(1); + expect(result.impact.estimatedDowntime).toBe('2-5 minutes'); + expect(result.impact.breakingChanges).toBe(true); + }); + }); + + describe('when change set has Lambda VPC changes', () => { + it('should detect VPC configuration changes with VpcConfig attribute', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + const vpcWarning = result.warnings.find( + (w) => w.logicalId === 'HealthLambdaFunction' + ); + expect(vpcWarning).toBeDefined(); + expect(vpcWarning.type).toBe('VPC_CONFIGURATION_CHANGE'); + expect(vpcWarning.message).toBe( + 'VPC configuration change - Lambda function will experience cold start' + ); + expect(vpcWarning.severity).toBe('medium'); + }); + + it('should detect VPC configuration changes with VpcConfig name', () => { + const changeSetWithVpcConfigName = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'VpcLambdaFunction', + ResourceType: 'AWS::Lambda::Function', + Details: [ + { + Target: { + Name: 'VpcConfig', + RequiresRecreation: 'Never', + }, + }, + ], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithVpcConfigName); + + const vpcWarning = result.warnings.find( + (w) => w.logicalId === 'VpcLambdaFunction' + ); + expect(vpcWarning).toBeDefined(); + expect(vpcWarning.type).toBe('VPC_CONFIGURATION_CHANGE'); + }); + + it('should not generate VPC warning for Lambda without VPC changes', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + const nonVpcWarning = result.warnings.find( + (w) => w.logicalId === 'IntegrationLambdaFunction' + ); + expect(nonVpcWarning).toBeUndefined(); + }); + + it('should handle Lambda functions without Details array', () => { + const changeSetWithoutDetails = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'SimpleLambdaFunction', + ResourceType: 'AWS::Lambda::Function', + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithoutDetails); + + expect(result.warnings).toEqual([]); + }); + }); + + describe('when change set has database modifications', () => { + it('should detect RDS DBCluster modifications', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithDatabase); + + const dbWarning = result.warnings.find( + (w) => w.logicalId === 'DatabaseCluster' + ); + expect(dbWarning).toBeDefined(); + expect(dbWarning.type).toBe('DATABASE_MODIFICATION'); + expect(dbWarning.message).toBe('Database modification detected - potential downtime'); + expect(dbWarning.severity).toBe('high'); + }); + + it('should detect RDS DBInstance modifications', () => { + const changeSetWithDBInstance = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'DatabaseInstance', + ResourceType: 'AWS::RDS::DBInstance', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithDBInstance); + + const dbWarning = result.warnings.find( + (w) => w.logicalId === 'DatabaseInstance' + ); + expect(dbWarning).toBeDefined(); + expect(dbWarning.type).toBe('DATABASE_MODIFICATION'); + expect(dbWarning.severity).toBe('high'); + }); + + it('should detect database replacement', () => { + const changeSetWithDBReplacement = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'DatabaseInstance', + ResourceType: 'AWS::RDS::DBInstance', + Replacement: 'True', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithDBReplacement); + + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'DATABASE_MODIFICATION', + severity: 'high', + }), + ]) + ); + + expect(result.criticalChanges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + logicalId: 'DatabaseInstance', + reason: 'Requires replacement', + }), + ]) + ); + }); + + it('should calculate impact with database changes', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithDatabase); + + expect(result.impact.databasesAffected).toBe(1); + expect(result.impact.estimatedDowntime).toBe('5-15 minutes'); + expect(result.impact.breakingChanges).toBe(true); + }); + + it('should count both DBInstance and DBCluster in impact', () => { + const changeSetWithMultipleDatabases = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'DatabaseInstance', + ResourceType: 'AWS::RDS::DBInstance', + Details: [], + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'DatabaseCluster', + ResourceType: 'AWS::RDS::DBCluster', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithMultipleDatabases); + + expect(result.impact.databasesAffected).toBe(2); + }); + }); + + describe('when change set has conditional replacements', () => { + it('should generate warning for conditional replacements', () => { + const changeSetWithConditional = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ConditionalResource', + ResourceType: 'AWS::EC2::Instance', + Replacement: 'Conditional', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithConditional); + + const conditionalWarning = result.warnings.find( + (w) => w.logicalId === 'ConditionalResource' + ); + expect(conditionalWarning).toBeDefined(); + expect(conditionalWarning.type).toBe('CONDITIONAL_REPLACEMENT'); + expect(conditionalWarning.message).toBe( + 'Resource may require replacement depending on property values' + ); + expect(conditionalWarning.severity).toBe('medium'); + }); + + it('should not count conditional replacements as critical changes', () => { + const changeSetWithConditional = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ConditionalResource', + ResourceType: 'AWS::EC2::Instance', + Replacement: 'Conditional', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithConditional); + + expect(result.criticalChanges).toEqual([]); + expect(result.summary.replace).toBe(0); + }); + }); + + describe('complex change sets with multiple change types', () => { + it('should handle change set with all change types', () => { + const complexChangeSet = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'NewFunction', + ResourceType: 'AWS::Lambda::Function', + Details: [], + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ExistingFunction', + ResourceType: 'AWS::Lambda::Function', + Details: [], + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ReplacedResource', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'True', + Details: [], + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + LogicalResourceId: 'OldResource', + ResourceType: 'AWS::IAM::Role', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(complexChangeSet); + + expect(result.summary.add).toBe(1); + expect(result.summary.modify).toBe(1); + expect(result.summary.remove).toBe(1); + expect(result.summary.replace).toBe(1); + expect(result.summary.total).toBe(3); + }); + + it('should handle changes without ResourceChange property', () => { + const changeSetWithInvalidChange = { + Changes: [ + { + Type: 'Resource', + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'ValidFunction', + ResourceType: 'AWS::Lambda::Function', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithInvalidChange); + + expect(result.summary.add).toBe(1); + expect(result.criticalChanges).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('should generate multiple warnings for same resource when applicable', () => { + const changeSetWithMultipleWarnings = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'DBInstance', + ResourceType: 'AWS::RDS::DBInstance', + Replacement: 'Conditional', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithMultipleWarnings); + + expect(result.warnings).toHaveLength(2); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'DATABASE_MODIFICATION' }), + expect.objectContaining({ type: 'CONDITIONAL_REPLACEMENT' }), + ]) + ); + }); + }); + + describe('impact calculation', () => { + it('should prioritize database downtime over replacement downtime', () => { + const changeSetWithBoth = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Database', + ResourceType: 'AWS::RDS::DBInstance', + Details: [], + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'SecurityGroup', + ResourceType: 'AWS::EC2::SecurityGroup', + Replacement: 'True', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithBoth); + + expect(result.impact.databasesAffected).toBe(1); + expect(result.impact.replacements).toBe(1); + expect(result.impact.estimatedDowntime).toBe('5-15 minutes'); + }); + + it('should return None expected for modifications without replacements', () => { + const changeSetWithOnlyModifications = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Function', + ResourceType: 'AWS::Lambda::Function', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithOnlyModifications); + + expect(result.impact.estimatedDowntime).toBe('None expected'); + expect(result.impact.breakingChanges).toBe(false); + }); + + it('should return 2-5 minutes for replacements without databases', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(result.impact.replacements).toBe(1); + expect(result.impact.databasesAffected).toBe(0); + expect(result.impact.estimatedDowntime).toBe('2-5 minutes'); + }); + + it('should detect cold starts for Lambda functions', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.impact.lambdaFunctionsAffected).toBeGreaterThan(0); + expect(result.impact.coldStartsExpected).toBe(true); + }); + + it('should not expect cold starts when no Lambda functions affected', () => { + const changeSetWithoutLambda = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'Bucket', + ResourceType: 'AWS::S3::Bucket', + Details: [], + }, + }, + ], + }; + + const result = analyzer.analyzeChangeSet(changeSetWithoutLambda); + + expect(result.impact.lambdaFunctionsAffected).toBe(0); + expect(result.impact.coldStartsExpected).toBe(false); + }); + + it('should mark breaking changes when replacements exist', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(result.impact.breakingChanges).toBe(true); + }); + + it('should mark breaking changes when databases are affected', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithDatabase); + + expect(result.impact.breakingChanges).toBe(true); + }); + }); + + describe('return structure validation', () => { + it('should return object with all required properties', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('criticalChanges'); + expect(result).toHaveProperty('warnings'); + expect(result).toHaveProperty('impact'); + }); + + it('should return ChangeSetSummary instance', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.summary).toBeInstanceOf(ChangeSetSummary); + expect(typeof result.summary.add).toBe('number'); + expect(typeof result.summary.modify).toBe('number'); + expect(typeof result.summary.remove).toBe('number'); + expect(typeof result.summary.replace).toBe('number'); + }); + + it('should return array of critical changes', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithReplacements); + + expect(Array.isArray(result.criticalChanges)).toBe(true); + result.criticalChanges.forEach((change) => { + expect(change).toHaveProperty('logicalId'); + expect(change).toHaveProperty('physicalId'); + expect(change).toHaveProperty('resourceType'); + expect(change).toHaveProperty('action'); + expect(change).toHaveProperty('reason'); + expect(change).toHaveProperty('severity'); + }); + }); + + it('should return array of warnings', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithModifications); + + expect(Array.isArray(result.warnings)).toBe(true); + result.warnings.forEach((warning) => { + expect(warning).toHaveProperty('logicalId'); + expect(warning).toHaveProperty('type'); + expect(warning).toHaveProperty('message'); + expect(warning).toHaveProperty('severity'); + }); + }); + + it('should return impact object with all fields', () => { + const result = analyzer.analyzeChangeSet(mockChangeSetWithAdditions); + + expect(result.impact).toHaveProperty('lambdaFunctionsAffected'); + expect(result.impact).toHaveProperty('databasesAffected'); + expect(result.impact).toHaveProperty('replacements'); + expect(result.impact).toHaveProperty('estimatedDowntime'); + expect(result.impact).toHaveProperty('coldStartsExpected'); + expect(result.impact).toHaveProperty('breakingChanges'); + }); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/infrastructure/adapters/CloudFormationChangeSetCreator.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/infrastructure/adapters/CloudFormationChangeSetCreator.test.js new file mode 100644 index 000000000..5785fb43b --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/infrastructure/adapters/CloudFormationChangeSetCreator.test.js @@ -0,0 +1,628 @@ +/** + * Tests for CloudFormationChangeSetCreator Adapter + * + * Tests CloudFormation Change Set API integration using mocked AWS SDK v3 clients + * Following TDD principles - tests written first + */ + +// Mock AWS SDK v3 - must be before any requires +jest.mock('@aws-sdk/client-cloudformation'); + +const { + mockChangeSetEmpty, + mockChangeSetWithAdditions, + mockAwsSdkResponses, +} = require('../../fixtures/mock-change-sets'); + +describe('CloudFormationChangeSetCreator', () => { + let creator; + let mockSend; + let mockClient; + let CloudFormationChangeSetCreator; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Get the mocked AWS SDK + const { + CloudFormationClient, + CreateChangeSetCommand, + DescribeStacksCommand, + DescribeChangeSetCommand, + DeleteChangeSetCommand, + } = require('@aws-sdk/client-cloudformation'); + + // Create mock client with send method + mockSend = jest.fn(); + mockClient = { + send: mockSend, + }; + + // Configure the CloudFormationClient mock to return mockClient + CloudFormationClient.mockImplementation(() => mockClient); + + // Mock the Command constructors to just return their input + CreateChangeSetCommand.mockImplementation((input) => ({ input })); + DescribeStacksCommand.mockImplementation((input) => ({ input })); + DescribeChangeSetCommand.mockImplementation((input) => ({ input })); + DeleteChangeSetCommand.mockImplementation((input) => ({ input })); + + // Require the module after mocks are set up + ({ CloudFormationChangeSetCreator } = require('../../../infrastructure/adapters/CloudFormationChangeSetCreator')); + + creator = new CloudFormationChangeSetCreator({ region: 'us-east-1' }); + }); + + describe('constructor', () => { + it('should create instance with default region', () => { + const instance = new CloudFormationChangeSetCreator(); + expect(instance).toBeInstanceOf(CloudFormationChangeSetCreator); + }); + + it('should create instance with custom region', () => { + const instance = new CloudFormationChangeSetCreator({ region: 'eu-west-1' }); + expect(instance).toBeInstanceOf(CloudFormationChangeSetCreator); + }); + + it('should lazy-load CloudFormation client', () => { + const { CloudFormationClient } = require('@aws-sdk/client-cloudformation'); + CloudFormationClient.mockClear(); + + const instance = new CloudFormationChangeSetCreator({ region: 'us-east-1' }); + + // Client should not be created until first use + expect(CloudFormationClient).not.toHaveBeenCalled(); + }); + }); + + describe('stackExists', () => { + it('should return true if stack exists', async () => { + mockSend.mockResolvedValue(mockAwsSdkResponses.cloudformation.describeStacks); + + const exists = await creator.stackExists('test-stack'); + + expect(exists).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should return false if stack does not exist', async () => { + const error = new Error('Stack with id non-existent-stack does not exist'); + error.name = 'ValidationError'; + mockSend.mockRejectedValue(error); + + const exists = await creator.stackExists('non-existent-stack'); + + expect(exists).toBe(false); + }); + + it('should return false for deleted stacks', async () => { + mockSend.mockResolvedValue({ + Stacks: [ + { + StackName: 'deleted-stack', + StackStatus: 'DELETE_COMPLETE', + }, + ], + }); + + const exists = await creator.stackExists('deleted-stack'); + + expect(exists).toBe(false); + }); + + it('should throw error for other AWS errors', async () => { + const error = new Error('Access Denied'); + error.name = 'AccessDeniedException'; + mockSend.mockRejectedValue(error); + + await expect(creator.stackExists('test-stack')).rejects.toThrow('Access Denied'); + }); + }); + + describe('createChangeSet', () => { + it('should create change set for new stack (CREATE)', async () => { + // Stack doesn't exist + const notFoundError = new Error('Stack does not exist'); + notFoundError.name = 'ValidationError'; + mockSend.mockRejectedValueOnce(notFoundError); + + // CreateChangeSet succeeds + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.createChangeSet); + + const params = { + stackName: 'new-stack', + template: 'Resources: {}', + parameters: [{ ParameterKey: 'Stage', ParameterValue: 'prod' }], + tags: [{ Key: 'Team', Value: 'platform' }], + capabilities: ['CAPABILITY_IAM'], + }; + + const result = await creator.createChangeSet(params); + + expect(result).toEqual({ + changeSetId: mockAwsSdkResponses.cloudformation.createChangeSet.Id, + stackId: mockAwsSdkResponses.cloudformation.createChangeSet.StackId, + changeSetName: expect.stringContaining('frigg-dry-run-'), + changeSetType: 'CREATE', + }); + + expect(mockSend).toHaveBeenCalledTimes(2); // DescribeStacks + CreateChangeSet + }); + + it('should create change set for existing stack (UPDATE)', async () => { + // Stack exists + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.describeStacks); + + // CreateChangeSet succeeds + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.createChangeSet); + + const params = { + stackName: 'test-stack', + template: 'Resources: {}', + parameters: [], + tags: [], + capabilities: ['CAPABILITY_IAM'], + }; + + const result = await creator.createChangeSet(params); + + expect(result).toEqual({ + changeSetId: mockAwsSdkResponses.cloudformation.createChangeSet.Id, + stackId: mockAwsSdkResponses.cloudformation.createChangeSet.StackId, + changeSetName: expect.stringContaining('frigg-dry-run-'), + changeSetType: 'UPDATE', + }); + + expect(mockSend).toHaveBeenCalledTimes(2); + }); + + it('should include all parameters in CreateChangeSet command', async () => { + // Stack exists + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.describeStacks); + + // CreateChangeSet + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.createChangeSet); + + const { CreateChangeSetCommand } = require('@aws-sdk/client-cloudformation'); + CreateChangeSetCommand.mockClear(); + + const params = { + stackName: 'test-stack', + template: 'AWSTemplateFormatVersion: "2010-09-09"', + parameters: [ + { ParameterKey: 'Stage', ParameterValue: 'prod' }, + { ParameterKey: 'Region', ParameterValue: 'us-east-1' }, + ], + tags: [ + { Key: 'Team', Value: 'platform' }, + { Key: 'Environment', Value: 'production' }, + ], + capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + }; + + await creator.createChangeSet(params); + + // Verify CreateChangeSetCommand was called with correct parameters + expect(CreateChangeSetCommand).toHaveBeenCalledWith( + expect.objectContaining({ + StackName: 'test-stack', + TemplateBody: params.template, + Parameters: params.parameters, + Tags: params.tags, + Capabilities: params.capabilities, + ChangeSetType: 'UPDATE', + ChangeSetName: expect.stringContaining('frigg-dry-run-'), + }) + ); + }); + + it('should handle CreateChangeSet errors', async () => { + // Stack exists + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.describeStacks); + + // CreateChangeSet fails + const error = new Error('Invalid template'); + error.name = 'ValidationError'; + mockSend.mockRejectedValueOnce(error); + + const params = { + stackName: 'test-stack', + template: 'Invalid: template', + parameters: [], + tags: [], + capabilities: [], + }; + + await expect(creator.createChangeSet(params)).rejects.toThrow('Invalid template'); + }); + + it('should generate unique change set names', async () => { + // Stack exists + mockSend.mockResolvedValue(mockAwsSdkResponses.cloudformation.describeStacks); + mockSend.mockResolvedValue(mockAwsSdkResponses.cloudformation.createChangeSet); + + const params = { + stackName: 'test-stack', + template: 'Resources: {}', + parameters: [], + tags: [], + capabilities: [], + }; + + const result1 = await creator.createChangeSet(params); + + // Reset for second call + jest.clearAllMocks(); + mockSend.mockResolvedValue(mockAwsSdkResponses.cloudformation.describeStacks); + mockSend.mockResolvedValue(mockAwsSdkResponses.cloudformation.createChangeSet); + + const result2 = await creator.createChangeSet(params); + + // Change set names should be different due to timestamp + expect(result1.changeSetName).toMatch(/frigg-dry-run-\d+/); + expect(result2.changeSetName).toMatch(/frigg-dry-run-\d+/); + }); + }); + + describe('waitForChangeSet', () => { + it('should wait for change set to reach CREATE_COMPLETE status', async () => { + // First poll: IN_PROGRESS + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_IN_PROGRESS', + StatusReason: 'Creating change set', + }); + + // Second poll: CREATE_COMPLETE + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_COMPLETE', + StatusReason: 'Change set created successfully', + }); + + await creator.waitForChangeSet('test-stack', 'change-set-123', 10000); + + expect(mockSend).toHaveBeenCalledTimes(2); + }); + + it('should handle "No updates" status as successful completion', async () => { + mockSend.mockResolvedValue({ + Status: 'FAILED', + StatusReason: "The submitted information didn't contain changes. Submit different information to create a change set.", + }); + + // Should not throw error for "No updates" case + await expect( + creator.waitForChangeSet('test-stack', 'change-set-123', 5000) + ).resolves.not.toThrow(); + + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should throw error if change set creation fails', async () => { + mockSend.mockResolvedValue({ + Status: 'FAILED', + StatusReason: 'Invalid template syntax', + }); + + await expect( + creator.waitForChangeSet('test-stack', 'change-set-123', 5000) + ).rejects.toThrow('Change set creation failed: Invalid template syntax'); + }); + + it('should timeout if max wait time exceeded', async () => { + // Always return CREATE_IN_PROGRESS + mockSend.mockResolvedValue({ + Status: 'CREATE_IN_PROGRESS', + StatusReason: 'Still creating...', + }); + + // Use short timeout for test + await expect( + creator.waitForChangeSet('test-stack', 'change-set-123', 1000) + ).rejects.toThrow('Timeout waiting for change set creation'); + }, 10000); + + it('should poll at regular intervals', async () => { + const startTime = Date.now(); + + // Return IN_PROGRESS twice, then COMPLETE + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_IN_PROGRESS', + }); + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_IN_PROGRESS', + }); + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_COMPLETE', + }); + + await creator.waitForChangeSet('test-stack', 'change-set-123', 10000); + + const elapsed = Date.now() - startTime; + + expect(mockSend).toHaveBeenCalledTimes(3); + // Should have polled at least 3 times (with delays between polls) + expect(elapsed).toBeGreaterThanOrEqual(4000); // 2 delays of 2 seconds each + }, 15000); + + it('should handle API errors during polling', async () => { + const error = new Error('Network error'); + mockSend.mockRejectedValue(error); + + await expect( + creator.waitForChangeSet('test-stack', 'change-set-123', 5000) + ).rejects.toThrow('Network error'); + }); + }); + + describe('getChangeSetDetails', () => { + it('should retrieve change set details', async () => { + mockSend.mockResolvedValue(mockChangeSetWithAdditions); + + const details = await creator.getChangeSetDetails('test-stack', 'change-set-123'); + + expect(details).toEqual(mockChangeSetWithAdditions); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should handle empty change sets', async () => { + mockSend.mockResolvedValue(mockChangeSetEmpty); + + const details = await creator.getChangeSetDetails('test-stack', 'change-set-123'); + + expect(details.Changes).toEqual([]); + expect(details.Status).toBe('CREATE_COMPLETE'); + }); + + it('should handle change sets with multiple pages (pagination)', async () => { + // First page + mockSend.mockResolvedValueOnce({ + ...mockChangeSetWithAdditions, + Changes: [mockChangeSetWithAdditions.Changes[0]], + NextToken: 'token-123', + }); + + // Second page + mockSend.mockResolvedValueOnce({ + ...mockChangeSetWithAdditions, + Changes: [mockChangeSetWithAdditions.Changes[1], mockChangeSetWithAdditions.Changes[2]], + }); + + const details = await creator.getChangeSetDetails('test-stack', 'change-set-123'); + + expect(details.Changes).toHaveLength(3); + expect(mockSend).toHaveBeenCalledTimes(2); + }); + + it('should throw error if change set does not exist', async () => { + const error = new Error('Change set does not exist'); + error.name = 'ChangeSetNotFoundException'; + mockSend.mockRejectedValue(error); + + await expect( + creator.getChangeSetDetails('test-stack', 'change-set-123') + ).rejects.toThrow('Change set does not exist'); + }); + + it('should include all change set metadata', async () => { + mockSend.mockResolvedValue(mockChangeSetWithAdditions); + + const details = await creator.getChangeSetDetails('test-stack', 'change-set-123'); + + expect(details).toHaveProperty('ChangeSetId'); + expect(details).toHaveProperty('ChangeSetName'); + expect(details).toHaveProperty('StackId'); + expect(details).toHaveProperty('StackName'); + expect(details).toHaveProperty('Status'); + expect(details).toHaveProperty('Changes'); + expect(details).toHaveProperty('CreationTime'); + }); + }); + + describe('deleteChangeSet', () => { + it('should delete change set successfully', async () => { + mockSend.mockResolvedValue(mockAwsSdkResponses.cloudformation.deleteChangeSet); + + await creator.deleteChangeSet('test-stack', 'change-set-123'); + + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should handle deletion of non-existent change set gracefully', async () => { + const error = new Error('Change set does not exist'); + error.name = 'ChangeSetNotFoundException'; + mockSend.mockRejectedValue(error); + + // Should not throw - deletion is idempotent + await expect( + creator.deleteChangeSet('test-stack', 'change-set-123') + ).resolves.not.toThrow(); + }); + + it('should throw error for other deletion errors', async () => { + const error = new Error('Access Denied'); + error.name = 'AccessDeniedException'; + mockSend.mockRejectedValue(error); + + await expect( + creator.deleteChangeSet('test-stack', 'change-set-123') + ).rejects.toThrow('Access Denied'); + }); + + it('should use correct DeleteChangeSet command parameters', async () => { + mockSend.mockResolvedValue({}); + + const { DeleteChangeSetCommand } = require('@aws-sdk/client-cloudformation'); + DeleteChangeSetCommand.mockClear(); + + await creator.deleteChangeSet('my-stack', 'my-change-set'); + + expect(DeleteChangeSetCommand).toHaveBeenCalledWith({ + StackName: 'my-stack', + ChangeSetName: 'my-change-set', + }); + }); + }); + + describe('error handling', () => { + it('should handle network errors gracefully', async () => { + const error = new Error('Network timeout'); + error.code = 'NetworkingError'; + mockSend.mockRejectedValue(error); + + await expect(creator.stackExists('test-stack')).rejects.toThrow('Network timeout'); + }); + + it('should handle throttling errors', async () => { + const error = new Error('Rate exceeded'); + error.name = 'Throttling'; + mockSend.mockRejectedValue(error); + + await expect(creator.stackExists('test-stack')).rejects.toThrow('Rate exceeded'); + }); + + it('should preserve error stack traces', async () => { + const error = new Error('Test error'); + error.stack = 'Error: Test error\n at Test.it'; + mockSend.mockRejectedValue(error); + + try { + await creator.stackExists('test-stack'); + } catch (e) { + expect(e.stack).toBeDefined(); + expect(e.stack).toContain('Test error'); + } + }); + }); + + describe('integration scenarios', () => { + it('should handle complete dry-run workflow', async () => { + // 1. Check stack exists + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.describeStacks); + + // 2. Create change set (internally calls stackExists + createChangeSet) + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.describeStacks); + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.createChangeSet); + + // 3. Wait for change set (CREATE_COMPLETE immediately) + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_COMPLETE', + }); + + // 4. Get change set details + mockSend.mockResolvedValueOnce(mockChangeSetWithAdditions); + + // 5. Delete change set + mockSend.mockResolvedValueOnce({}); + + const params = { + stackName: 'test-stack', + template: 'Resources: {}', + parameters: [], + tags: [], + capabilities: [], + }; + + // Execute workflow + const exists = await creator.stackExists('test-stack'); + expect(exists).toBe(true); + + const changeSet = await creator.createChangeSet(params); + expect(changeSet.changeSetType).toBe('UPDATE'); + + await creator.waitForChangeSet('test-stack', changeSet.changeSetName, 10000); + + const details = await creator.getChangeSetDetails('test-stack', changeSet.changeSetName); + expect(details.Changes).toHaveLength(3); + + await creator.deleteChangeSet('test-stack', changeSet.changeSetName); + + expect(mockSend).toHaveBeenCalledTimes(6); + }); + + it('should handle new stack creation workflow', async () => { + // 1. Check stack exists - it doesn't + const notFoundError = new Error('Stack does not exist'); + notFoundError.name = 'ValidationError'; + mockSend.mockRejectedValueOnce(notFoundError); + + // 2. Create change set (internally calls stackExists + createChangeSet) + const notFoundError2 = new Error('Stack does not exist'); + notFoundError2.name = 'ValidationError'; + mockSend.mockRejectedValueOnce(notFoundError2); + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.createChangeSet); + + // 3. Wait for change set + mockSend.mockResolvedValueOnce({ + Status: 'CREATE_COMPLETE', + }); + + // 4. Get details + mockSend.mockResolvedValueOnce(mockChangeSetWithAdditions); + + // 5. Delete + mockSend.mockResolvedValueOnce({}); + + const params = { + stackName: 'new-stack', + template: 'Resources: {}', + parameters: [], + tags: [], + capabilities: [], + }; + + const exists = await creator.stackExists('new-stack'); + expect(exists).toBe(false); + + const changeSet = await creator.createChangeSet(params); + expect(changeSet.changeSetType).toBe('CREATE'); + + await creator.waitForChangeSet('new-stack', changeSet.changeSetName, 10000); + + const details = await creator.getChangeSetDetails('new-stack', changeSet.changeSetName); + expect(details.Changes).toBeDefined(); + + await creator.deleteChangeSet('new-stack', changeSet.changeSetName); + + expect(mockSend).toHaveBeenCalledTimes(6); + }); + + it('should handle no changes scenario', async () => { + // Stack exists + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.describeStacks); + + // Create change set + mockSend.mockResolvedValueOnce(mockAwsSdkResponses.cloudformation.createChangeSet); + + // Wait returns "No updates" + mockSend.mockResolvedValueOnce({ + Status: 'FAILED', + StatusReason: "The submitted information didn't contain changes. Submit different information to create a change set.", + }); + + // Get details returns empty changes + mockSend.mockResolvedValueOnce(mockChangeSetEmpty); + + // Delete change set + mockSend.mockResolvedValueOnce({}); + + const params = { + stackName: 'test-stack', + template: 'Resources: {}', + parameters: [], + tags: [], + capabilities: [], + }; + + const changeSet = await creator.createChangeSet(params); + + // Should not throw for "No updates" + await creator.waitForChangeSet('test-stack', changeSet.changeSetName, 5000); + + const details = await creator.getChangeSetDetails('test-stack', changeSet.changeSetName); + expect(details.Changes).toEqual([]); + + await creator.deleteChangeSet('test-stack', changeSet.changeSetName); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/infrastructure/adapters/EnvironmentValidator.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/infrastructure/adapters/EnvironmentValidator.test.js new file mode 100644 index 000000000..458ad9a39 --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/__tests__/infrastructure/adapters/EnvironmentValidator.test.js @@ -0,0 +1,640 @@ +/** + * Tests for EnvironmentValidator Adapter + * + * Tests environment variable validation and AWS credentials validation using mocked AWS SDK v3 clients. + * Following TDD principles - tests written first. + */ + +// Mock AWS SDK v3 - must be before any requires +const mockSend = jest.fn(); +const mockSTSClient = jest.fn(); +const mockGetCallerIdentityCommand = jest.fn(); + +jest.mock('@aws-sdk/client-sts', () => ({ + STSClient: mockSTSClient, + GetCallerIdentityCommand: mockGetCallerIdentityCommand, +})); + +const { + setTestEnvironmentVariables, + cleanupTestEnvironmentVariables, +} = require('../../helpers/test-utils'); + +describe('EnvironmentValidator', () => { + let validator; + let EnvironmentValidator; + let testVarNames; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + mockSend.mockClear(); + mockSTSClient.mockClear(); + mockGetCallerIdentityCommand.mockClear(); + + // Configure the STSClient mock to return an object with send method + mockSTSClient.mockImplementation(() => ({ + send: mockSend, + })); + + // Mock the Command constructor to just return its input + mockGetCallerIdentityCommand.mockImplementation((input) => ({ input })); + + // Require the module after mocks are set up + ({ EnvironmentValidator } = require('../../../infrastructure/adapters/EnvironmentValidator')); + + validator = new EnvironmentValidator({ region: 'us-east-1' }); + + // Clean up any existing test variables + testVarNames = []; + }); + + afterEach(() => { + cleanupTestEnvironmentVariables(testVarNames); + }); + + describe('constructor', () => { + it('should create instance with default region', () => { + const instance = new EnvironmentValidator(); + expect(instance).toBeInstanceOf(EnvironmentValidator); + }); + + it('should create instance with custom region', () => { + const instance = new EnvironmentValidator({ region: 'eu-west-1' }); + expect(instance).toBeInstanceOf(EnvironmentValidator); + }); + + it('should lazy-load STS client', () => { + mockSTSClient.mockClear(); + + const instance = new EnvironmentValidator({ region: 'us-east-1' }); + + // Client should not be created until first use + expect(mockSTSClient).not.toHaveBeenCalled(); + }); + }); + + describe('validateEnvironmentVariables', () => { + describe('all required variables present', () => { + it('should return success when all required environment variables are present', async () => { + testVarNames = setTestEnvironmentVariables({ + AWS_REGION: 'us-east-1', + STAGE: 'test', + DB_URI: 'mongodb://localhost:27017/test', + }); + + const appDefinition = { + name: 'test-app', + environment: { + AWS_REGION: true, + STAGE: true, + DB_URI: true, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.metadata.required.present).toEqual(['AWS_REGION', 'STAGE', 'DB_URI']); + expect(result.metadata.required.missing).toEqual([]); + }); + + it('should return success with metadata about present variables', async () => { + testVarNames = setTestEnvironmentVariables({ + API_KEY: 'test-key', + DATABASE_URL: 'postgres://localhost/test', + }); + + const appDefinition = { + name: 'test-app', + environment: { + API_KEY: true, + DATABASE_URL: true, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.metadata.required.present).toContain('API_KEY'); + expect(result.metadata.required.present).toContain('DATABASE_URL'); + }); + }); + + describe('required variables missing', () => { + it('should return failure when required environment variables are missing', async () => { + // Set only AWS_REGION, ensure STAGE and DB_URI are not set + process.env.AWS_REGION = 'us-east-1'; + delete process.env.STAGE; + delete process.env.DB_URI; + testVarNames = ['AWS_REGION']; + + const appDefinition = { + name: 'test-app', + environment: { + AWS_REGION: true, + STAGE: true, + DB_URI: true, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required environment variable: STAGE'); + expect(result.errors).toContain('Missing required environment variable: DB_URI'); + expect(result.metadata.required.present).toEqual(['AWS_REGION']); + expect(result.metadata.required.missing).toEqual(['STAGE', 'DB_URI']); + }); + + it('should list all missing required variables in errors', async () => { + const appDefinition = { + name: 'test-app', + environment: { + VAR1: true, + VAR2: true, + VAR3: true, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(3); + expect(result.errors).toContain('Missing required environment variable: VAR1'); + expect(result.errors).toContain('Missing required environment variable: VAR2'); + expect(result.errors).toContain('Missing required environment variable: VAR3'); + }); + }); + + describe('optional variables', () => { + it('should handle optional variables when present', async () => { + testVarNames = setTestEnvironmentVariables({ + REQUIRED_VAR: 'required', + OPTIONAL_VAR: 'optional', + }); + + const appDefinition = { + name: 'test-app', + environment: { + REQUIRED_VAR: true, + OPTIONAL_VAR: { required: false }, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.metadata.required.present).toEqual(['REQUIRED_VAR']); + expect(result.metadata.optional.present).toEqual(['OPTIONAL_VAR']); + expect(result.metadata.optional.missing).toEqual([]); + }); + + it('should create warnings when optional variables are missing', async () => { + testVarNames = setTestEnvironmentVariables({ + REQUIRED_VAR: 'required', + }); + + const appDefinition = { + name: 'test-app', + environment: { + REQUIRED_VAR: true, + OPTIONAL_VAR: { required: false }, + ANOTHER_OPTIONAL: { required: false }, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toContain('Optional environment variable not set: OPTIONAL_VAR'); + expect(result.warnings).toContain('Optional environment variable not set: ANOTHER_OPTIONAL'); + expect(result.metadata.optional.missing).toEqual(['OPTIONAL_VAR', 'ANOTHER_OPTIONAL']); + }); + + it('should not fail validation when optional variables are missing', async () => { + testVarNames = setTestEnvironmentVariables({ + REQUIRED_VAR: 'required', + }); + + const appDefinition = { + name: 'test-app', + environment: { + REQUIRED_VAR: true, + OPTIONAL_VAR: { required: false }, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); + + describe('no environment variables defined', () => { + it('should return success when no environment variables are defined', async () => { + const appDefinition = { + name: 'test-app', + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.metadata.required.present).toEqual([]); + expect(result.metadata.required.missing).toEqual([]); + }); + + it('should return success when environment is empty object', async () => { + const appDefinition = { + name: 'test-app', + environment: {}, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); + + describe('edge cases', () => { + it('should handle empty string values as present', async () => { + testVarNames = setTestEnvironmentVariables({ + EMPTY_VAR: '', + }); + + const appDefinition = { + name: 'test-app', + environment: { + EMPTY_VAR: true, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.metadata.required.present).toContain('EMPTY_VAR'); + }); + + it('should handle undefined app definition', async () => { + const result = await validator.validateEnvironmentVariables(undefined); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should handle null app definition', async () => { + const result = await validator.validateEnvironmentVariables(null); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should handle mixed required and optional variables', async () => { + testVarNames = setTestEnvironmentVariables({ + REQ1: 'value1', + REQ2: 'value2', + OPT1: 'value3', + }); + + const appDefinition = { + name: 'test-app', + environment: { + REQ1: true, + REQ2: true, + OPT1: { required: false }, + OPT2: { required: false }, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toContain('Optional environment variable not set: OPT2'); + expect(result.metadata.required.present).toEqual(['REQ1', 'REQ2']); + expect(result.metadata.optional.present).toEqual(['OPT1']); + expect(result.metadata.optional.missing).toEqual(['OPT2']); + }); + }); + }); + + describe('validateAwsCredentials', () => { + describe('valid credentials', () => { + it('should return success when AWS credentials are valid', async () => { + mockSend.mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/test-user', + UserId: 'AIDAI23EXAMPLE', + }); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.metadata.accountId).toBe('123456789012'); + expect(result.metadata.region).toBe('us-east-1'); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should return account details in metadata', async () => { + mockSend.mockResolvedValue({ + Account: '987654321098', + Arn: 'arn:aws:iam::987654321098:user/deploy-user', + UserId: 'AIDAI45EXAMPLE', + }); + + const customValidator = new EnvironmentValidator({ region: 'eu-west-1' }); + const result = await customValidator.validateAwsCredentials(); + + expect(result.valid).toBe(true); + expect(result.metadata.accountId).toBe('987654321098'); + expect(result.metadata.region).toBe('eu-west-1'); + }); + + it('should lazy-load STS client on first use', async () => { + mockSTSClient.mockClear(); + + mockSend.mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/test', + UserId: 'AIDAI23EXAMPLE', + }); + + const newValidator = new EnvironmentValidator({ region: 'us-east-1' }); + + // Client not created yet + expect(mockSTSClient).not.toHaveBeenCalled(); + + await newValidator.validateAwsCredentials(); + + // Client created on first call + expect(mockSTSClient).toHaveBeenCalledWith({ region: 'us-east-1' }); + }); + }); + + describe('invalid credentials', () => { + it('should return failure when AWS credentials are invalid', async () => { + const error = new Error('The security token included in the request is invalid'); + error.name = 'InvalidClientTokenId'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('AWS credentials are invalid or expired'); + expect(result.metadata.accountId).toBeNull(); + expect(result.metadata.region).toBe('us-east-1'); + }); + + it('should handle missing credentials error', async () => { + const error = new Error('Missing credentials in config'); + error.name = 'CredentialsProviderError'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('AWS credentials not found. Please configure AWS credentials.'); + }); + + it('should handle expired credentials', async () => { + const error = new Error('Token has expired'); + error.name = 'ExpiredTokenException'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('AWS credentials are invalid or expired'); + }); + + it('should handle access denied errors', async () => { + const error = new Error('User is not authorized to perform: sts:GetCallerIdentity'); + error.name = 'AccessDeniedException'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Access denied. Check your AWS IAM permissions.'); + }); + }); + + describe('STS API errors', () => { + it('should handle network errors', async () => { + const error = new Error('Network timeout'); + error.code = 'NetworkingError'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network error connecting to AWS: Network timeout'); + }); + + it('should handle throttling errors', async () => { + const error = new Error('Rate exceeded'); + error.name = 'Throttling'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('AWS API throttling error. Please retry later.'); + }); + + it('should handle service unavailable errors', async () => { + const error = new Error('Service unavailable'); + error.name = 'ServiceUnavailableException'; + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('AWS service unavailable: Service unavailable'); + }); + + it('should handle unknown errors gracefully', async () => { + const error = new Error('Unknown error'); + mockSend.mockRejectedValue(error); + + const result = await validator.validateAwsCredentials(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Failed to validate AWS credentials: Unknown error'); + }); + + it('should preserve error stack traces', async () => { + const error = new Error('Test error'); + error.stack = 'Error: Test error\n at Test.it'; + mockSend.mockRejectedValue(error); + + try { + await validator.validateAwsCredentials(); + } catch (e) { + // Should not throw, but handle gracefully + } + + const result = await validator.validateAwsCredentials(); + expect(result.valid).toBe(false); + }); + }); + + describe('GetCallerIdentity command', () => { + it('should use GetCallerIdentityCommand correctly', async () => { + mockSend.mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/test', + UserId: 'AIDAI23EXAMPLE', + }); + + mockGetCallerIdentityCommand.mockClear(); + + await validator.validateAwsCredentials(); + + expect(mockGetCallerIdentityCommand).toHaveBeenCalledWith({}); + }); + }); + }); + + describe('integration scenarios', () => { + it('should validate both environment and AWS credentials successfully', async () => { + // Set up environment variables + testVarNames = setTestEnvironmentVariables({ + AWS_REGION: 'us-east-1', + STAGE: 'production', + }); + + const appDefinition = { + name: 'test-app', + environment: { + AWS_REGION: true, + STAGE: true, + }, + }; + + // Mock successful AWS credentials + mockSend.mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/deploy', + UserId: 'AIDAI23EXAMPLE', + }); + + const envResult = await validator.validateEnvironmentVariables(appDefinition); + const awsResult = await validator.validateAwsCredentials(); + + expect(envResult.valid).toBe(true); + expect(awsResult.valid).toBe(true); + }); + + it('should handle validation failures in both checks', async () => { + // Missing environment variables + const appDefinition = { + name: 'test-app', + environment: { + REQUIRED_VAR: true, + }, + }; + + // Invalid AWS credentials + const error = new Error('Invalid credentials'); + error.name = 'InvalidClientTokenId'; + mockSend.mockRejectedValue(error); + + const envResult = await validator.validateEnvironmentVariables(appDefinition); + const awsResult = await validator.validateAwsCredentials(); + + expect(envResult.valid).toBe(false); + expect(envResult.errors).toContain('Missing required environment variable: REQUIRED_VAR'); + expect(awsResult.valid).toBe(false); + expect(awsResult.errors).toContain('AWS credentials are invalid or expired'); + }); + + it('should handle partial success scenarios', async () => { + // Valid environment variables + testVarNames = setTestEnvironmentVariables({ + STAGE: 'test', + }); + + const appDefinition = { + name: 'test-app', + environment: { + STAGE: true, + }, + }; + + // Invalid AWS credentials + const error = new Error('Missing credentials'); + error.name = 'CredentialsProviderError'; + mockSend.mockRejectedValue(error); + + const envResult = await validator.validateEnvironmentVariables(appDefinition); + const awsResult = await validator.validateAwsCredentials(); + + expect(envResult.valid).toBe(true); + expect(awsResult.valid).toBe(false); + }); + + it('should handle empty app with valid AWS credentials', async () => { + mockSend.mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/test', + UserId: 'AIDAI23EXAMPLE', + }); + + const appDefinition = { + name: 'minimal-app', + }; + + const envResult = await validator.validateEnvironmentVariables(appDefinition); + const awsResult = await validator.validateAwsCredentials(); + + expect(envResult.valid).toBe(true); + expect(awsResult.valid).toBe(true); + }); + }); + + describe('ValidationResult value object usage', () => { + it('should return ValidationResult instance for environment validation', async () => { + testVarNames = setTestEnvironmentVariables({ + TEST_VAR: 'value', + }); + + const appDefinition = { + environment: { + TEST_VAR: true, + }, + }; + + const result = await validator.validateEnvironmentVariables(appDefinition); + + expect(result).toHaveProperty('valid'); + expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty('warnings'); + expect(result).toHaveProperty('metadata'); + }); + + it('should return ValidationResult instance for AWS validation', async () => { + mockSend.mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/test', + UserId: 'AIDAI23EXAMPLE', + }); + + const result = await validator.validateAwsCredentials(); + + expect(result).toHaveProperty('valid'); + expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty('warnings'); + expect(result).toHaveProperty('metadata'); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/domain/services/PreFlightChecker.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/services/PreFlightChecker.test.js new file mode 100644 index 000000000..9379406f2 --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/services/PreFlightChecker.test.js @@ -0,0 +1,969 @@ +/** + * PreFlightChecker Service Tests + * + * Comprehensive test suite for the PreFlightChecker domain service + * following TDD principles + */ + +const { PreFlightChecker } = require('./PreFlightChecker'); +const { ValidationResult } = require('../value-objects/ValidationResult'); + +describe('PreFlightChecker', () => { + let mockFileSystem; + let checker; + + beforeEach(() => { + // Create mock file system for dependency injection + mockFileSystem = { + fileExists: jest.fn(), + readFile: jest.fn(), + resolvePath: jest.fn((basePath, fileName) => { + // Combine paths properly + if (fileName) { + return `${basePath}/${fileName}`; + } + return basePath; + }), + }; + + checker = new PreFlightChecker(mockFileSystem); + }); + + describe('constructor', () => { + it('should create PreFlightChecker with file system dependency', () => { + const checker = new PreFlightChecker(mockFileSystem); + + expect(checker).toBeInstanceOf(PreFlightChecker); + }); + + it('should require file system dependency', () => { + expect(() => { + new PreFlightChecker(); + }).toThrow('File system dependency is required'); + }); + + it('should validate file system has required methods', () => { + const invalidFs = { + fileExists: jest.fn(), + // Missing readFile and resolvePath + }; + + expect(() => { + new PreFlightChecker(invalidFs); + }).toThrow('File system must implement fileExists, readFile, and resolvePath methods'); + }); + }); + + describe('check()', () => { + describe('when all files exist and app definition is valid', () => { + const validAppDefinition = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + stage: 'dev', + }; + + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(validAppDefinition)};`); + } + if (filePath === '/test/app/package.json') { + return Promise.resolve(JSON.stringify({ name: 'test-app', version: '1.0.0' })); + } + return Promise.resolve(''); + }); + }); + + it('should return successful validation result', async () => { + const result = await checker.check('/test/app'); + + expect(result).toBeInstanceOf(ValidationResult); + expect(result.valid).toBe(true); + expect(result.hasErrors()).toBe(false); + }); + + it('should check for all required files', async () => { + await checker.check('/test/app'); + + expect(mockFileSystem.fileExists).toHaveBeenCalledWith('/test/app/index.js'); + expect(mockFileSystem.fileExists).toHaveBeenCalledWith('/test/app/infrastructure.js'); + expect(mockFileSystem.fileExists).toHaveBeenCalledWith('/test/app/package.json'); + }); + + it('should load and validate app definition', async () => { + await checker.check('/test/app'); + + expect(mockFileSystem.readFile).toHaveBeenCalledWith('/test/app/infrastructure.js'); + }); + + it('should return app definition in metadata', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata.appDefinition).toEqual(validAppDefinition); + }); + + it('should return files found in metadata', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata.files).toEqual({ + indexJs: true, + infrastructureJs: true, + packageJson: true, + }); + }); + + it('should return app name in metadata', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata.appName).toBe('test-app'); + }); + + it('should return provider in metadata', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata.provider).toBe('aws'); + }); + + it('should return region in metadata', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata.region).toBe('us-east-1'); + }); + + it('should return stage in metadata', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata.stage).toBe('dev'); + }); + }); + + describe('when app definition has optional configurations', () => { + it('should handle app definition with VPC configuration', async () => { + const appDefWithVpc = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + vpc: { + enable: true, + createNew: false, + }, + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithVpc)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.appDefinition.vpc).toEqual({ + enable: true, + createNew: false, + }); + expect(result.metadata.hasVpc).toBe(true); + }); + + it('should handle app definition with database configuration', async () => { + const appDefWithDb = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + database: { + postgres: { + enable: true, + }, + }, + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithDb)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.appDefinition.database).toEqual({ + postgres: { + enable: true, + }, + }); + expect(result.metadata.hasDatabase).toBe(true); + }); + + it('should handle app definition with integrations', async () => { + const appDefWithIntegrations = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + integrations: [ + { Definition: { name: 'hubspot' } }, + { Definition: { name: 'salesforce' } }, + ], + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithIntegrations)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.appDefinition.integrations).toHaveLength(2); + expect(result.metadata.hasIntegrations).toBe(true); + expect(result.metadata.integrationCount).toBe(2); + }); + + it('should handle app definition with encryption configuration', async () => { + const appDefWithEncryption = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + encryption: { + enable: true, + }, + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithEncryption)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.hasEncryption).toBe(true); + }); + + it('should handle app definition with websockets', async () => { + const appDefWithWebsockets = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + websockets: { + enable: true, + }, + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithWebsockets)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.hasWebsockets).toBe(true); + }); + + it('should handle app definition with SSM configuration', async () => { + const appDefWithSsm = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + ssm: { + enable: true, + }, + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithSsm)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.hasSsm).toBe(true); + }); + + it('should handle minimal app definition without optional configurations', async () => { + const minimalAppDef = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(minimalAppDef)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.hasVpc).toBe(false); + expect(result.metadata.hasDatabase).toBe(false); + expect(result.metadata.hasIntegrations).toBe(false); + expect(result.metadata.hasEncryption).toBe(false); + expect(result.metadata.hasWebsockets).toBe(false); + expect(result.metadata.hasSsm).toBe(false); + expect(result.metadata.integrationCount).toBe(0); + }); + }); + + describe('when files are missing', () => { + const validAppDefinition = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + beforeEach(() => { + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(validAppDefinition)};`); + } + return Promise.resolve('{}'); + }); + }); + + it('should fail when index.js is missing', async () => { + mockFileSystem.fileExists.mockImplementation((filePath) => { + if (filePath === '/test/app/index.js') { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const result = await checker.check('/test/app'); + + expect(result).toBeInstanceOf(ValidationResult); + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('Missing required file: index.js'); + }); + + it('should fail when infrastructure.js is missing', async () => { + mockFileSystem.fileExists.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('Missing required file: infrastructure.js'); + }); + + it('should fail when package.json is missing', async () => { + mockFileSystem.fileExists.mockImplementation((filePath) => { + if (filePath === '/test/app/package.json') { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('Missing required file: package.json'); + }); + + it('should report all missing files', async () => { + mockFileSystem.fileExists.mockResolvedValue(false); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(3); + expect(result.errors).toContain('Missing required file: index.js'); + expect(result.errors).toContain('Missing required file: infrastructure.js'); + expect(result.errors).toContain('Missing required file: package.json'); + }); + + it('should include files found in metadata when some are missing', async () => { + mockFileSystem.fileExists.mockImplementation((filePath) => { + if (filePath === '/test/app/index.js') { + return Promise.resolve(false); + } + return Promise.resolve(true); + }); + + const result = await checker.check('/test/app'); + + expect(result.metadata.files).toEqual({ + indexJs: false, + infrastructureJs: true, + packageJson: true, + }); + }); + }); + + describe('when app definition is invalid', () => { + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/package.json') { + return Promise.resolve(JSON.stringify({ name: 'test-app' })); + } + return Promise.resolve(''); + }); + }); + + it('should fail when app definition is not an object', async () => { + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve('module.exports = "not an object";'); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('App definition must be an object'); + }); + + it('should fail when app definition is null', async () => { + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve('module.exports = null;'); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('App definition must be an object'); + }); + + it('should fail when app definition is an array', async () => { + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve('module.exports = [];'); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('App definition must be an object'); + }); + + it('should fail when infrastructure.js has invalid syntax', async () => { + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve('module.exports = { invalid syntax'); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('Failed to parse infrastructure.js: Invalid JavaScript syntax'); + }); + }); + + describe('when app definition has missing required properties', () => { + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/package.json') { + return Promise.resolve(JSON.stringify({ name: 'test-app' })); + } + return Promise.resolve(''); + }); + }); + + it('should fail when name is missing', async () => { + const appDefWithoutName = { + provider: 'aws', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithoutName)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('App definition must have a name'); + }); + + it('should fail when provider is missing', async () => { + const appDefWithoutProvider = { + name: 'test-app', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithoutProvider)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('App definition must have a provider'); + }); + + it('should fail when region is missing', async () => { + const appDefWithoutRegion = { + name: 'test-app', + provider: 'aws', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithoutRegion)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('App definition must have a region'); + }); + + it('should report all missing required properties', async () => { + const emptyAppDef = {}; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(emptyAppDef)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(3); + expect(result.errors).toContain('App definition must have a name'); + expect(result.errors).toContain('App definition must have a provider'); + expect(result.errors).toContain('App definition must have a region'); + }); + + it('should fail when name is empty string', async () => { + const appDefWithEmptyName = { + name: '', + provider: 'aws', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithEmptyName)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('App definition must have a name'); + }); + + it('should fail when provider is empty string', async () => { + const appDefWithEmptyProvider = { + name: 'test-app', + provider: '', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithEmptyProvider)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('App definition must have a provider'); + }); + + it('should fail when region is empty string', async () => { + const appDefWithEmptyRegion = { + name: 'test-app', + provider: 'aws', + region: '', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithEmptyRegion)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('App definition must have a region'); + }); + }); + + describe('when file read errors occur', () => { + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + }); + + it('should handle infrastructure.js read error', async () => { + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.reject(new Error('EACCES: permission denied')); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('Failed to read infrastructure.js: EACCES: permission denied'); + }); + + it('should handle package.json read error', async () => { + const validAppDef = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(validAppDef)};`); + } + if (filePath === '/test/app/package.json') { + return Promise.reject(new Error('ENOENT: no such file')); + } + return Promise.resolve(''); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors).toContain('Failed to read package.json: ENOENT: no such file'); + }); + + it('should handle file system error during existence check', async () => { + mockFileSystem.fileExists.mockRejectedValue(new Error('File system error')); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors[0]).toContain('Pre-flight check failed'); + }); + + it('should handle invalid JSON in package.json', async () => { + const validAppDef = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(validAppDef)};`); + } + if (filePath === '/test/app/package.json') { + return Promise.resolve('{ invalid json }'); + } + return Promise.resolve(''); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(true); + expect(result.errors[0]).toContain('Failed to parse package.json'); + }); + }); + + describe('path resolution', () => { + const validAppDefinition = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath.endsWith('infrastructure.js')) { + return Promise.resolve(`module.exports = ${JSON.stringify(validAppDefinition)};`); + } + if (filePath.endsWith('package.json')) { + return Promise.resolve(JSON.stringify({ name: 'test-app' })); + } + return Promise.resolve(''); + }); + }); + + it('should use resolvePath for all file operations', async () => { + mockFileSystem.resolvePath.mockImplementation((basePath, fileName) => { + return `${basePath}/${fileName}`; + }); + + await checker.check('/test/app'); + + expect(mockFileSystem.resolvePath).toHaveBeenCalledWith('/test/app', 'index.js'); + expect(mockFileSystem.resolvePath).toHaveBeenCalledWith('/test/app', 'infrastructure.js'); + expect(mockFileSystem.resolvePath).toHaveBeenCalledWith('/test/app', 'package.json'); + }); + + it('should handle relative paths', async () => { + mockFileSystem.resolvePath.mockImplementation((basePath, fileName) => { + return `${basePath}/${fileName}`; + }); + + await checker.check('./test/app'); + + expect(mockFileSystem.resolvePath).toHaveBeenCalledTimes(3); + expect(mockFileSystem.resolvePath).toHaveBeenCalledWith('./test/app', 'index.js'); + }); + }); + + describe('metadata extraction', () => { + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + }); + + it('should extract complete metadata from complex app definition', async () => { + const complexAppDef = { + name: 'complex-app', + provider: 'aws', + region: 'eu-west-1', + stage: 'production', + vpc: { enable: true }, + database: { postgres: { enable: true } }, + encryption: { enable: true }, + ssm: { enable: true }, + websockets: { enable: true }, + integrations: [ + { Definition: { name: 'hubspot' } }, + { Definition: { name: 'salesforce' } }, + { Definition: { name: 'stripe' } }, + ], + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(complexAppDef)};`); + } + if (filePath === '/test/app/package.json') { + return Promise.resolve(JSON.stringify({ name: 'complex-app', version: '2.0.0' })); + } + return Promise.resolve(''); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata).toEqual({ + appDefinition: complexAppDef, + appName: 'complex-app', + provider: 'aws', + region: 'eu-west-1', + stage: 'production', + files: { + indexJs: true, + infrastructureJs: true, + packageJson: true, + }, + hasVpc: true, + hasDatabase: true, + hasEncryption: true, + hasSsm: true, + hasWebsockets: true, + hasIntegrations: true, + integrationCount: 3, + }); + }); + + it('should default stage to "dev" when not specified', async () => { + const appDefWithoutStage = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithoutStage)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.stage).toBe('dev'); + }); + + it('should include partial metadata on validation failure', async () => { + const invalidAppDef = { + name: 'test-app', + // Missing provider and region + }; + + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(invalidAppDef)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(false); + expect(result.metadata.appDefinition).toEqual(invalidAppDef); + expect(result.metadata.appName).toBe('test-app'); + expect(result.metadata.provider).toBeUndefined(); + expect(result.metadata.region).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should require appPath parameter', async () => { + await expect(checker.check()).rejects.toThrow('App path is required'); + }); + + it('should reject null appPath', async () => { + await expect(checker.check(null)).rejects.toThrow('App path is required'); + }); + + it('should reject empty string appPath', async () => { + await expect(checker.check('')).rejects.toThrow('App path is required'); + }); + + it('should handle app definition with undefined properties', async () => { + const appDefWithUndefined = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + vpc: undefined, + database: undefined, + }; + + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(appDefWithUndefined)};`); + } + return Promise.resolve('{}'); + }); + + const result = await checker.check('/test/app'); + + expect(result.valid).toBe(true); + expect(result.metadata.hasVpc).toBe(false); + expect(result.metadata.hasDatabase).toBe(false); + }); + }); + + describe('return structure validation', () => { + const validAppDefinition = { + name: 'test-app', + provider: 'aws', + region: 'us-east-1', + }; + + beforeEach(() => { + mockFileSystem.fileExists.mockResolvedValue(true); + mockFileSystem.readFile.mockImplementation((filePath) => { + if (filePath === '/test/app/infrastructure.js') { + return Promise.resolve(`module.exports = ${JSON.stringify(validAppDefinition)};`); + } + return Promise.resolve('{}'); + }); + }); + + it('should always return ValidationResult instance', async () => { + const result = await checker.check('/test/app'); + + expect(result).toBeInstanceOf(ValidationResult); + }); + + it('should return result with metadata property', async () => { + const result = await checker.check('/test/app'); + + expect(result).toHaveProperty('metadata'); + expect(typeof result.metadata).toBe('object'); + }); + + it('should return result with all expected metadata fields', async () => { + const result = await checker.check('/test/app'); + + expect(result.metadata).toHaveProperty('appDefinition'); + expect(result.metadata).toHaveProperty('appName'); + expect(result.metadata).toHaveProperty('provider'); + expect(result.metadata).toHaveProperty('region'); + expect(result.metadata).toHaveProperty('stage'); + expect(result.metadata).toHaveProperty('files'); + expect(result.metadata).toHaveProperty('hasVpc'); + expect(result.metadata).toHaveProperty('hasDatabase'); + expect(result.metadata).toHaveProperty('hasEncryption'); + expect(result.metadata).toHaveProperty('hasSsm'); + expect(result.metadata).toHaveProperty('hasWebsockets'); + expect(result.metadata).toHaveProperty('hasIntegrations'); + expect(result.metadata).toHaveProperty('integrationCount'); + }); + + it('should not include warnings for successful validation', async () => { + const result = await checker.check('/test/app'); + + expect(result.warnings).toEqual([]); + expect(result.hasWarnings()).toBe(false); + }); + + it('should not include errors for successful validation', async () => { + const result = await checker.check('/test/app'); + + expect(result.errors).toEqual([]); + expect(result.hasErrors()).toBe(false); + }); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/ChangeSetSummary.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/ChangeSetSummary.test.js new file mode 100644 index 000000000..512ab1f7c --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/ChangeSetSummary.test.js @@ -0,0 +1,578 @@ +/** + * Tests for ChangeSetSummary Value Object + */ + +const { ChangeSetSummary } = require('./ChangeSetSummary'); + +describe('ChangeSetSummary', () => { + describe('constructor', () => { + it('should create summary with all counts', () => { + // Arrange & Act + const summary = new ChangeSetSummary({ + add: 3, + modify: 2, + remove: 1, + replace: 1, + }); + + // Assert + expect(summary.add).toBe(3); + expect(summary.modify).toBe(2); + expect(summary.remove).toBe(1); + expect(summary.replace).toBe(1); + }); + + it('should create summary with default values', () => { + // Arrange & Act + const summary = new ChangeSetSummary({}); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should create summary with partial values', () => { + // Arrange & Act + const summary = new ChangeSetSummary({ + add: 5, + modify: 3, + }); + + // Assert + expect(summary.add).toBe(5); + expect(summary.modify).toBe(3); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should accept zero for all counts', () => { + // Arrange & Act + const summary = new ChangeSetSummary({ + add: 0, + modify: 0, + remove: 0, + replace: 0, + }); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should reject negative add count', () => { + // Arrange & Act & Assert + expect(() => { + new ChangeSetSummary({ add: -1 }); + }).toThrow('Change counts must be non-negative'); + }); + + it('should reject negative modify count', () => { + // Arrange & Act & Assert + expect(() => { + new ChangeSetSummary({ modify: -1 }); + }).toThrow('Change counts must be non-negative'); + }); + + it('should reject negative remove count', () => { + // Arrange & Act & Assert + expect(() => { + new ChangeSetSummary({ remove: -1 }); + }).toThrow('Change counts must be non-negative'); + }); + + it('should reject negative replace count', () => { + // Arrange & Act & Assert + expect(() => { + new ChangeSetSummary({ replace: -1 }); + }).toThrow('Change counts must be non-negative'); + }); + }); + + describe('total', () => { + it('should calculate total from add, modify, and remove', () => { + // Arrange & Act + const summary = new ChangeSetSummary({ + add: 3, + modify: 2, + remove: 1, + replace: 5, + }); + + // Assert + expect(summary.total).toBe(6); + }); + + it('should return zero when no changes', () => { + // Arrange & Act + const summary = new ChangeSetSummary({}); + + // Assert + expect(summary.total).toBe(0); + }); + + it('should not include replace in total', () => { + // Arrange & Act + const summary = new ChangeSetSummary({ + add: 0, + modify: 0, + remove: 0, + replace: 5, + }); + + // Assert + expect(summary.total).toBe(0); + }); + }); + + describe('hasChanges', () => { + it('should return true when add count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ add: 1 }); + + // Act & Assert + expect(summary.hasChanges()).toBe(true); + }); + + it('should return true when modify count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ modify: 1 }); + + // Act & Assert + expect(summary.hasChanges()).toBe(true); + }); + + it('should return true when remove count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ remove: 1 }); + + // Act & Assert + expect(summary.hasChanges()).toBe(true); + }); + + it('should return false when all counts are zero', () => { + // Arrange + const summary = new ChangeSetSummary({}); + + // Act & Assert + expect(summary.hasChanges()).toBe(false); + }); + + it('should return false when only replace count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ replace: 5 }); + + // Act & Assert + expect(summary.hasChanges()).toBe(false); + }); + + it('should return true when multiple counts are greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ + add: 2, + modify: 3, + remove: 1, + }); + + // Act & Assert + expect(summary.hasChanges()).toBe(true); + }); + }); + + describe('hasCriticalChanges', () => { + it('should return true when replace count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ replace: 1 }); + + // Act & Assert + expect(summary.hasCriticalChanges()).toBe(true); + }); + + it('should return true when remove count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ remove: 1 }); + + // Act & Assert + expect(summary.hasCriticalChanges()).toBe(true); + }); + + it('should return true when both replace and remove counts are greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ + replace: 2, + remove: 3, + }); + + // Act & Assert + expect(summary.hasCriticalChanges()).toBe(true); + }); + + it('should return false when only add count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ add: 5 }); + + // Act & Assert + expect(summary.hasCriticalChanges()).toBe(false); + }); + + it('should return false when only modify count is greater than zero', () => { + // Arrange + const summary = new ChangeSetSummary({ modify: 5 }); + + // Act & Assert + expect(summary.hasCriticalChanges()).toBe(false); + }); + + it('should return false when all counts are zero', () => { + // Arrange + const summary = new ChangeSetSummary({}); + + // Act & Assert + expect(summary.hasCriticalChanges()).toBe(false); + }); + }); + + describe('toObject', () => { + it('should return object representation with all values', () => { + // Arrange + const summary = new ChangeSetSummary({ + add: 3, + modify: 2, + remove: 1, + replace: 1, + }); + + // Act + const obj = summary.toObject(); + + // Assert + expect(obj).toEqual({ + add: 3, + modify: 2, + remove: 1, + replace: 1, + total: 6, + }); + }); + + it('should return object with calculated total', () => { + // Arrange + const summary = new ChangeSetSummary({ + add: 10, + modify: 5, + remove: 2, + replace: 0, + }); + + // Act + const obj = summary.toObject(); + + // Assert + expect(obj.total).toBe(17); + }); + + it('should return object with zero values', () => { + // Arrange + const summary = new ChangeSetSummary({}); + + // Act + const obj = summary.toObject(); + + // Assert + expect(obj).toEqual({ + add: 0, + modify: 0, + remove: 0, + replace: 0, + total: 0, + }); + }); + }); + + describe('static factory methods', () => { + describe('empty', () => { + it('should create summary with all zeros', () => { + // Arrange & Act + const summary = ChangeSetSummary.empty(); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + expect(summary.total).toBe(0); + }); + + it('should create summary with no changes', () => { + // Arrange & Act + const summary = ChangeSetSummary.empty(); + + // Assert + expect(summary.hasChanges()).toBe(false); + expect(summary.hasCriticalChanges()).toBe(false); + }); + }); + + describe('fromChanges', () => { + it('should create summary from Add actions', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Add' } }, + { ResourceChange: { Action: 'Add' } }, + { ResourceChange: { Action: 'Add' } }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(3); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should create summary from Modify actions without replacement', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Modify', Replacement: 'False' } }, + { ResourceChange: { Action: 'Modify', Replacement: 'False' } }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(2); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should create summary from Modify actions with replacement', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Modify', Replacement: 'True' } }, + { ResourceChange: { Action: 'Modify', Replacement: 'True' } }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(2); + }); + + it('should create summary from Remove actions', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Remove' } }, + { ResourceChange: { Action: 'Remove' } }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(2); + expect(summary.replace).toBe(0); + }); + + it('should create summary from mixed actions', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Add' } }, + { ResourceChange: { Action: 'Modify', Replacement: 'False' } }, + { ResourceChange: { Action: 'Modify', Replacement: 'True' } }, + { ResourceChange: { Action: 'Remove' } }, + { ResourceChange: { Action: 'Add' } }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(2); + expect(summary.modify).toBe(1); + expect(summary.remove).toBe(1); + expect(summary.replace).toBe(1); + }); + + it('should handle changes with Action at root level', () => { + // Arrange + const changes = [ + { Action: 'Add' }, + { Action: 'Remove' }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(1); + expect(summary.remove).toBe(1); + }); + + it('should create empty summary from empty changes array', () => { + // Arrange + const changes = []; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(0); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should ignore changes without action', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Add' } }, + { ResourceChange: {} }, + {}, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.add).toBe(1); + expect(summary.modify).toBe(0); + expect(summary.remove).toBe(0); + expect(summary.replace).toBe(0); + }); + + it('should handle Modify without Replacement as modify', () => { + // Arrange + const changes = [ + { ResourceChange: { Action: 'Modify' } }, + ]; + + // Act + const summary = ChangeSetSummary.fromChanges(changes); + + // Assert + expect(summary.modify).toBe(1); + expect(summary.replace).toBe(0); + }); + }); + }); + + describe('immutability', () => { + it('should not allow modification of add', () => { + // Arrange + const summary = new ChangeSetSummary({ add: 5 }); + const originalValue = summary.add; + + // Act + try { + summary.add = 10; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(summary.add).toBe(originalValue); + }); + + it('should not allow modification of modify', () => { + // Arrange + const summary = new ChangeSetSummary({ modify: 5 }); + const originalValue = summary.modify; + + // Act + try { + summary.modify = 10; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(summary.modify).toBe(originalValue); + }); + + it('should not allow modification of remove', () => { + // Arrange + const summary = new ChangeSetSummary({ remove: 5 }); + const originalValue = summary.remove; + + // Act + try { + summary.remove = 10; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(summary.remove).toBe(originalValue); + }); + + it('should not allow modification of replace', () => { + // Arrange + const summary = new ChangeSetSummary({ replace: 5 }); + const originalValue = summary.replace; + + // Act + try { + summary.replace = 10; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(summary.replace).toBe(originalValue); + }); + + it('should be frozen', () => { + // Arrange + const summary = new ChangeSetSummary({ + add: 1, + modify: 2, + remove: 3, + replace: 4, + }); + + // Act & Assert + expect(Object.isFrozen(summary)).toBe(true); + }); + + it('should not allow adding new properties', () => { + // Arrange + const summary = new ChangeSetSummary({ add: 1 }); + + // Act + try { + summary.newProperty = 'test'; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(summary.newProperty).toBeUndefined(); + }); + + it('should return new object from toObject', () => { + // Arrange + const summary = new ChangeSetSummary({ add: 5 }); + + // Act + const obj1 = summary.toObject(); + const obj2 = summary.toObject(); + + // Assert + expect(obj1).not.toBe(obj2); + expect(obj1).toEqual(obj2); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/DryRunStatus.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/DryRunStatus.test.js new file mode 100644 index 000000000..e6ba03afa --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/DryRunStatus.test.js @@ -0,0 +1,559 @@ +/** + * Tests for DryRunStatus Value Object + */ + +const { DryRunStatus } = require('./DryRunStatus'); + +describe('DryRunStatus', () => { + describe('constructor', () => { + it('should create status with SUCCESS code', () => { + // Arrange & Act + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS); + + // Assert + expect(status.code).toBe(0); + expect(status.message).toBe(''); + }); + + it('should create status with VALIDATION_ERROR code', () => { + // Arrange & Act + const status = new DryRunStatus(DryRunStatus.CODES.VALIDATION_ERROR); + + // Assert + expect(status.code).toBe(1); + expect(status.message).toBe(''); + }); + + it('should create status with WARNING code', () => { + // Arrange & Act + const status = new DryRunStatus(DryRunStatus.CODES.WARNING); + + // Assert + expect(status.code).toBe(2); + expect(status.message).toBe(''); + }); + + it('should create status with custom message', () => { + // Arrange + const message = 'Custom status message'; + + // Act + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS, message); + + // Assert + expect(status.message).toBe(message); + }); + + it('should create status with empty message by default', () => { + // Arrange & Act + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS); + + // Assert + expect(status.message).toBe(''); + }); + + it('should reject invalid status code', () => { + // Arrange & Act & Assert + expect(() => { + new DryRunStatus(999); + }).toThrow('Invalid status code: 999'); + }); + + it('should reject negative status code', () => { + // Arrange & Act & Assert + expect(() => { + new DryRunStatus(-1); + }).toThrow('Invalid status code: -1'); + }); + + it('should reject null status code', () => { + // Arrange & Act & Assert + expect(() => { + new DryRunStatus(null); + }).toThrow('Invalid status code: null'); + }); + + it('should reject undefined status code', () => { + // Arrange & Act & Assert + expect(() => { + new DryRunStatus(undefined); + }).toThrow('Invalid status code: undefined'); + }); + + it('should reject string status code', () => { + // Arrange & Act & Assert + expect(() => { + new DryRunStatus('SUCCESS'); + }).toThrow('Invalid status code: SUCCESS'); + }); + }); + + describe('static CODES', () => { + it('should provide SUCCESS code constant', () => { + // Arrange & Act & Assert + expect(DryRunStatus.CODES.SUCCESS).toBe(0); + }); + + it('should provide VALIDATION_ERROR code constant', () => { + // Arrange & Act & Assert + expect(DryRunStatus.CODES.VALIDATION_ERROR).toBe(1); + }); + + it('should provide WARNING code constant', () => { + // Arrange & Act & Assert + expect(DryRunStatus.CODES.WARNING).toBe(2); + }); + + it('should have three code constants', () => { + // Arrange & Act + const codes = Object.values(DryRunStatus.CODES); + + // Assert + expect(codes).toHaveLength(3); + }); + + it('should have unique code values', () => { + // Arrange & Act + const codes = Object.values(DryRunStatus.CODES); + const uniqueCodes = new Set(codes); + + // Assert + expect(uniqueCodes.size).toBe(codes.length); + }); + }); + + describe('isSuccess', () => { + it('should return true for SUCCESS code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS); + + // Act & Assert + expect(status.isSuccess()).toBe(true); + }); + + it('should return false for VALIDATION_ERROR code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.VALIDATION_ERROR); + + // Act & Assert + expect(status.isSuccess()).toBe(false); + }); + + it('should return false for WARNING code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.WARNING); + + // Act & Assert + expect(status.isSuccess()).toBe(false); + }); + }); + + describe('hasWarnings', () => { + it('should return true for WARNING code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.WARNING); + + // Act & Assert + expect(status.hasWarnings()).toBe(true); + }); + + it('should return false for SUCCESS code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS); + + // Act & Assert + expect(status.hasWarnings()).toBe(false); + }); + + it('should return false for VALIDATION_ERROR code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.VALIDATION_ERROR); + + // Act & Assert + expect(status.hasWarnings()).toBe(false); + }); + }); + + describe('hasErrors', () => { + it('should return true for VALIDATION_ERROR code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.VALIDATION_ERROR); + + // Act & Assert + expect(status.hasErrors()).toBe(true); + }); + + it('should return false for SUCCESS code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS); + + // Act & Assert + expect(status.hasErrors()).toBe(false); + }); + + it('should return false for WARNING code', () => { + // Arrange + const status = new DryRunStatus(DryRunStatus.CODES.WARNING); + + // Act & Assert + expect(status.hasErrors()).toBe(false); + }); + }); + + describe('toObject', () => { + it('should return object representation for SUCCESS', () => { + // Arrange + const status = new DryRunStatus( + DryRunStatus.CODES.SUCCESS, + 'Everything is fine' + ); + + // Act + const obj = status.toObject(); + + // Assert + expect(obj).toEqual({ + code: 0, + message: 'Everything is fine', + success: true, + }); + }); + + it('should return object representation for VALIDATION_ERROR', () => { + // Arrange + const status = new DryRunStatus( + DryRunStatus.CODES.VALIDATION_ERROR, + 'Validation failed' + ); + + // Act + const obj = status.toObject(); + + // Assert + expect(obj).toEqual({ + code: 1, + message: 'Validation failed', + success: false, + }); + }); + + it('should return object representation for WARNING', () => { + // Arrange + const status = new DryRunStatus( + DryRunStatus.CODES.WARNING, + 'There are warnings' + ); + + // Act + const obj = status.toObject(); + + // Assert + expect(obj).toEqual({ + code: 2, + message: 'There are warnings', + success: false, + }); + }); + + it('should include success based on isSuccess method', () => { + // Arrange + const successStatus = new DryRunStatus(DryRunStatus.CODES.SUCCESS); + const errorStatus = new DryRunStatus(DryRunStatus.CODES.VALIDATION_ERROR); + + // Act + const successObj = successStatus.toObject(); + const errorObj = errorStatus.toObject(); + + // Assert + expect(successObj.success).toBe(true); + expect(errorObj.success).toBe(false); + }); + }); + + describe('static factory methods', () => { + describe('success', () => { + it('should create status with SUCCESS code', () => { + // Arrange & Act + const status = DryRunStatus.success(); + + // Assert + expect(status.code).toBe(DryRunStatus.CODES.SUCCESS); + expect(status.isSuccess()).toBe(true); + }); + + it('should use default success message', () => { + // Arrange & Act + const status = DryRunStatus.success(); + + // Assert + expect(status.message).toBe('Dry-run completed successfully'); + }); + + it('should use custom message when provided', () => { + // Arrange + const message = 'Custom success message'; + + // Act + const status = DryRunStatus.success(message); + + // Assert + expect(status.message).toBe(message); + }); + + it('should not have warnings', () => { + // Arrange & Act + const status = DryRunStatus.success(); + + // Assert + expect(status.hasWarnings()).toBe(false); + }); + + it('should not have errors', () => { + // Arrange & Act + const status = DryRunStatus.success(); + + // Assert + expect(status.hasErrors()).toBe(false); + }); + }); + + describe('withWarnings', () => { + it('should create status with WARNING code', () => { + // Arrange & Act + const status = DryRunStatus.withWarnings(); + + // Assert + expect(status.code).toBe(DryRunStatus.CODES.WARNING); + expect(status.hasWarnings()).toBe(true); + }); + + it('should use default warning message', () => { + // Arrange & Act + const status = DryRunStatus.withWarnings(); + + // Assert + expect(status.message).toBe('Dry-run completed with warnings'); + }); + + it('should use custom message when provided', () => { + // Arrange + const message = 'Custom warning message'; + + // Act + const status = DryRunStatus.withWarnings(message); + + // Assert + expect(status.message).toBe(message); + }); + + it('should not be success', () => { + // Arrange & Act + const status = DryRunStatus.withWarnings(); + + // Assert + expect(status.isSuccess()).toBe(false); + }); + + it('should not have errors', () => { + // Arrange & Act + const status = DryRunStatus.withWarnings(); + + // Assert + expect(status.hasErrors()).toBe(false); + }); + }); + + describe('validationError', () => { + it('should create status with VALIDATION_ERROR code', () => { + // Arrange & Act + const status = DryRunStatus.validationError(); + + // Assert + expect(status.code).toBe(DryRunStatus.CODES.VALIDATION_ERROR); + expect(status.hasErrors()).toBe(true); + }); + + it('should use default error message', () => { + // Arrange & Act + const status = DryRunStatus.validationError(); + + // Assert + expect(status.message).toBe('Dry-run failed validation'); + }); + + it('should use custom message when provided', () => { + // Arrange + const message = 'Custom error message'; + + // Act + const status = DryRunStatus.validationError(message); + + // Assert + expect(status.message).toBe(message); + }); + + it('should not be success', () => { + // Arrange & Act + const status = DryRunStatus.validationError(); + + // Assert + expect(status.isSuccess()).toBe(false); + }); + + it('should not have warnings', () => { + // Arrange & Act + const status = DryRunStatus.validationError(); + + // Assert + expect(status.hasWarnings()).toBe(false); + }); + }); + }); + + describe('immutability', () => { + it('should not allow modification of code', () => { + // Arrange + const status = DryRunStatus.success(); + const originalCode = status.code; + + // Act + try { + status.code = DryRunStatus.CODES.VALIDATION_ERROR; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(status.code).toBe(originalCode); + }); + + it('should not allow modification of message', () => { + // Arrange + const status = DryRunStatus.success(); + const originalMessage = status.message; + + // Act + try { + status.message = 'Modified message'; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(status.message).toBe(originalMessage); + }); + + it('should be frozen', () => { + // Arrange + const status = DryRunStatus.success(); + + // Act & Assert + expect(Object.isFrozen(status)).toBe(true); + }); + + it('should not allow adding new properties', () => { + // Arrange + const status = DryRunStatus.success(); + + // Act + try { + status.newProperty = 'test'; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(status.newProperty).toBeUndefined(); + }); + + it('should return new object from toObject each time', () => { + // Arrange + const status = DryRunStatus.success(); + + // Act + const obj1 = status.toObject(); + const obj2 = status.toObject(); + + // Assert + expect(obj1).not.toBe(obj2); + expect(obj1).toEqual(obj2); + }); + }); + + describe('status state transitions', () => { + it('should handle only one status method returning true', () => { + // Arrange + const successStatus = DryRunStatus.success(); + + // Act & Assert + expect(successStatus.isSuccess()).toBe(true); + expect(successStatus.hasWarnings()).toBe(false); + expect(successStatus.hasErrors()).toBe(false); + }); + + it('should handle warning status methods correctly', () => { + // Arrange + const warningStatus = DryRunStatus.withWarnings(); + + // Act & Assert + expect(warningStatus.isSuccess()).toBe(false); + expect(warningStatus.hasWarnings()).toBe(true); + expect(warningStatus.hasErrors()).toBe(false); + }); + + it('should handle error status methods correctly', () => { + // Arrange + const errorStatus = DryRunStatus.validationError(); + + // Act & Assert + expect(errorStatus.isSuccess()).toBe(false); + expect(errorStatus.hasWarnings()).toBe(false); + expect(errorStatus.hasErrors()).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle empty string message', () => { + // Arrange & Act + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS, ''); + + // Assert + expect(status.message).toBe(''); + }); + + it('should handle very long messages', () => { + // Arrange + const longMessage = 'A'.repeat(1000); + + // Act + const status = new DryRunStatus(DryRunStatus.CODES.SUCCESS, longMessage); + + // Assert + expect(status.message).toBe(longMessage); + expect(status.message).toHaveLength(1000); + }); + + it('should handle messages with special characters', () => { + // Arrange + const message = 'Error: \n\t"Special" & symbols!'; + + // Act + const status = new DryRunStatus(DryRunStatus.CODES.VALIDATION_ERROR, message); + + // Assert + expect(status.message).toBe(message); + }); + + it('should handle numeric codes at boundary values', () => { + // Arrange & Act + const status0 = new DryRunStatus(0); + const status1 = new DryRunStatus(1); + const status2 = new DryRunStatus(2); + + // Assert + expect(status0.code).toBe(0); + expect(status1.code).toBe(1); + expect(status2.code).toBe(2); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/ValidationResult.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/ValidationResult.test.js new file mode 100644 index 000000000..5f4023ed5 --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/domain/value-objects/ValidationResult.test.js @@ -0,0 +1,648 @@ +/** + * Tests for ValidationResult Value Object + */ + +const { ValidationResult } = require('./ValidationResult'); + +describe('ValidationResult', () => { + describe('constructor', () => { + it('should create validation result with valid true', () => { + // Arrange & Act + const result = new ValidationResult({ + valid: true, + errors: [], + warnings: [], + }); + + // Assert + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('should create validation result with valid false', () => { + // Arrange & Act + const result = new ValidationResult({ + valid: false, + errors: ['Error 1'], + warnings: ['Warning 1'], + }); + + // Assert + expect(result.valid).toBe(false); + expect(result.errors).toEqual(['Error 1']); + expect(result.warnings).toEqual(['Warning 1']); + }); + + it('should use default empty arrays for errors and warnings', () => { + // Arrange & Act + const result = new ValidationResult({ valid: true }); + + // Assert + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('should use default empty object for metadata', () => { + // Arrange & Act + const result = new ValidationResult({ valid: true }); + + // Assert + expect(result.metadata).toEqual({}); + }); + + it('should store metadata', () => { + // Arrange + const metadata = { stackName: 'test-stack', region: 'us-east-1' }; + + // Act + const result = new ValidationResult({ + valid: true, + metadata, + }); + + // Assert + expect(result.metadata).toEqual(metadata); + }); + + it('should store multiple errors', () => { + // Arrange + const errors = ['Error 1', 'Error 2', 'Error 3']; + + // Act + const result = new ValidationResult({ + valid: false, + errors, + }); + + // Assert + expect(result.errors).toEqual(errors); + }); + + it('should store multiple warnings', () => { + // Arrange + const warnings = ['Warning 1', 'Warning 2']; + + // Act + const result = new ValidationResult({ + valid: true, + warnings, + }); + + // Assert + expect(result.warnings).toEqual(warnings); + }); + + it('should reject non-boolean valid value', () => { + // Arrange & Act & Assert + expect(() => { + new ValidationResult({ valid: 'true' }); + }).toThrow('valid must be a boolean'); + }); + + it('should reject undefined valid value', () => { + // Arrange & Act & Assert + expect(() => { + new ValidationResult({ valid: undefined }); + }).toThrow('valid must be a boolean'); + }); + + it('should reject null valid value', () => { + // Arrange & Act & Assert + expect(() => { + new ValidationResult({ valid: null }); + }).toThrow('valid must be a boolean'); + }); + + it('should reject numeric valid value', () => { + // Arrange & Act & Assert + expect(() => { + new ValidationResult({ valid: 1 }); + }).toThrow('valid must be a boolean'); + }); + }); + + describe('hasErrors', () => { + it('should return true when errors array has items', () => { + // Arrange + const result = new ValidationResult({ + valid: false, + errors: ['Error 1'], + }); + + // Act & Assert + expect(result.hasErrors()).toBe(true); + }); + + it('should return true when errors array has multiple items', () => { + // Arrange + const result = new ValidationResult({ + valid: false, + errors: ['Error 1', 'Error 2', 'Error 3'], + }); + + // Act & Assert + expect(result.hasErrors()).toBe(true); + }); + + it('should return false when errors array is empty', () => { + // Arrange + const result = new ValidationResult({ + valid: true, + errors: [], + }); + + // Act & Assert + expect(result.hasErrors()).toBe(false); + }); + + it('should return false when errors is default', () => { + // Arrange + const result = new ValidationResult({ valid: true }); + + // Act & Assert + expect(result.hasErrors()).toBe(false); + }); + }); + + describe('hasWarnings', () => { + it('should return true when warnings array has items', () => { + // Arrange + const result = new ValidationResult({ + valid: true, + warnings: ['Warning 1'], + }); + + // Act & Assert + expect(result.hasWarnings()).toBe(true); + }); + + it('should return true when warnings array has multiple items', () => { + // Arrange + const result = new ValidationResult({ + valid: true, + warnings: ['Warning 1', 'Warning 2'], + }); + + // Act & Assert + expect(result.hasWarnings()).toBe(true); + }); + + it('should return false when warnings array is empty', () => { + // Arrange + const result = new ValidationResult({ + valid: true, + warnings: [], + }); + + // Act & Assert + expect(result.hasWarnings()).toBe(false); + }); + + it('should return false when warnings is default', () => { + // Arrange + const result = new ValidationResult({ valid: true }); + + // Act & Assert + expect(result.hasWarnings()).toBe(false); + }); + }); + + describe('toObject', () => { + it('should return object representation with all properties', () => { + // Arrange + const result = new ValidationResult({ + valid: true, + errors: [], + warnings: ['Warning 1'], + metadata: { key: 'value' }, + }); + + // Act + const obj = result.toObject(); + + // Assert + expect(obj).toEqual({ + valid: true, + errors: [], + warnings: ['Warning 1'], + metadata: { key: 'value' }, + }); + }); + + it('should return object with errors', () => { + // Arrange + const result = new ValidationResult({ + valid: false, + errors: ['Error 1', 'Error 2'], + }); + + // Act + const obj = result.toObject(); + + // Assert + expect(obj.errors).toEqual(['Error 1', 'Error 2']); + }); + + it('should return new arrays for errors and warnings', () => { + // Arrange + const result = new ValidationResult({ + valid: true, + errors: ['Error 1'], + warnings: ['Warning 1'], + }); + + // Act + const obj = result.toObject(); + + // Assert + expect(obj.errors).not.toBe(result.errors); + expect(obj.warnings).not.toBe(result.warnings); + }); + + it('should return new object for metadata', () => { + // Arrange + const metadata = { key: 'value' }; + const result = new ValidationResult({ + valid: true, + metadata, + }); + + // Act + const obj = result.toObject(); + + // Assert + expect(obj.metadata).not.toBe(result.metadata); + expect(obj.metadata).toEqual(metadata); + }); + }); + + describe('static factory methods', () => { + describe('success', () => { + it('should create successful validation result', () => { + // Arrange & Act + const result = ValidationResult.success(); + + // Assert + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.metadata).toEqual({}); + }); + + it('should create successful validation result with metadata', () => { + // Arrange + const metadata = { stackName: 'test-stack' }; + + // Act + const result = ValidationResult.success(metadata); + + // Assert + expect(result.valid).toBe(true); + expect(result.metadata).toEqual(metadata); + }); + + it('should not have errors', () => { + // Arrange & Act + const result = ValidationResult.success(); + + // Assert + expect(result.hasErrors()).toBe(false); + }); + + it('should not have warnings', () => { + // Arrange & Act + const result = ValidationResult.success(); + + // Assert + expect(result.hasWarnings()).toBe(false); + }); + }); + + describe('failure', () => { + it('should create failed validation result with errors', () => { + // Arrange + const errors = ['Error 1', 'Error 2']; + + // Act + const result = ValidationResult.failure(errors); + + // Assert + expect(result.valid).toBe(false); + expect(result.errors).toEqual(errors); + expect(result.warnings).toEqual([]); + }); + + it('should create failed validation result with errors and warnings', () => { + // Arrange + const errors = ['Error 1']; + const warnings = ['Warning 1']; + + // Act + const result = ValidationResult.failure(errors, warnings); + + // Assert + expect(result.valid).toBe(false); + expect(result.errors).toEqual(errors); + expect(result.warnings).toEqual(warnings); + }); + + it('should create failed validation result with metadata', () => { + // Arrange + const errors = ['Error 1']; + const metadata = { attemptedAction: 'deploy' }; + + // Act + const result = ValidationResult.failure(errors, [], metadata); + + // Assert + expect(result.valid).toBe(false); + expect(result.metadata).toEqual(metadata); + }); + + it('should have errors', () => { + // Arrange + const errors = ['Error 1']; + + // Act + const result = ValidationResult.failure(errors); + + // Assert + expect(result.hasErrors()).toBe(true); + }); + + it('should handle multiple errors', () => { + // Arrange + const errors = ['Error 1', 'Error 2', 'Error 3']; + + // Act + const result = ValidationResult.failure(errors); + + // Assert + expect(result.errors).toHaveLength(3); + }); + }); + + describe('withWarnings', () => { + it('should create valid result with warnings', () => { + // Arrange + const warnings = ['Warning 1']; + + // Act + const result = ValidationResult.withWarnings(warnings); + + // Assert + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual(warnings); + }); + + it('should create valid result with multiple warnings', () => { + // Arrange + const warnings = ['Warning 1', 'Warning 2', 'Warning 3']; + + // Act + const result = ValidationResult.withWarnings(warnings); + + // Assert + expect(result.warnings).toHaveLength(3); + expect(result.hasWarnings()).toBe(true); + }); + + it('should create valid result with warnings and metadata', () => { + // Arrange + const warnings = ['Warning 1']; + const metadata = { stackName: 'test-stack' }; + + // Act + const result = ValidationResult.withWarnings(warnings, metadata); + + // Assert + expect(result.valid).toBe(true); + expect(result.warnings).toEqual(warnings); + expect(result.metadata).toEqual(metadata); + }); + + it('should not have errors', () => { + // Arrange + const warnings = ['Warning 1']; + + // Act + const result = ValidationResult.withWarnings(warnings); + + // Assert + expect(result.hasErrors()).toBe(false); + }); + + it('should have warnings', () => { + // Arrange + const warnings = ['Warning 1']; + + // Act + const result = ValidationResult.withWarnings(warnings); + + // Assert + expect(result.hasWarnings()).toBe(true); + }); + }); + }); + + describe('immutability', () => { + it('should not allow modification of valid', () => { + // Arrange + const result = ValidationResult.success(); + const originalValue = result.valid; + + // Act + try { + result.valid = false; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(result.valid).toBe(originalValue); + }); + + it('should not allow modification of errors array', () => { + // Arrange + const result = ValidationResult.failure(['Error 1']); + + // Act & Assert + expect(() => { + result.errors.push('Error 2'); + }).toThrow(); + }); + + it('should not allow modification of warnings array', () => { + // Arrange + const result = ValidationResult.withWarnings(['Warning 1']); + + // Act & Assert + expect(() => { + result.warnings.push('Warning 2'); + }).toThrow(); + }); + + it('should not allow modification of metadata object', () => { + // Arrange + const result = ValidationResult.success({ key: 'value' }); + const originalMetadata = { ...result.metadata }; + + // Act + try { + result.metadata.newKey = 'newValue'; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(result.metadata).toEqual(originalMetadata); + expect(result.metadata.newKey).toBeUndefined(); + }); + + it('should be frozen', () => { + // Arrange + const result = ValidationResult.success(); + + // Act & Assert + expect(Object.isFrozen(result)).toBe(true); + }); + + it('should have frozen errors array', () => { + // Arrange + const result = ValidationResult.failure(['Error 1']); + + // Act & Assert + expect(Object.isFrozen(result.errors)).toBe(true); + }); + + it('should have frozen warnings array', () => { + // Arrange + const result = ValidationResult.withWarnings(['Warning 1']); + + // Act & Assert + expect(Object.isFrozen(result.warnings)).toBe(true); + }); + + it('should have frozen metadata object', () => { + // Arrange + const result = ValidationResult.success({ key: 'value' }); + + // Act & Assert + expect(Object.isFrozen(result.metadata)).toBe(true); + }); + + it('should not allow adding new properties', () => { + // Arrange + const result = ValidationResult.success(); + + // Act + try { + result.newProperty = 'test'; + } catch (e) { + // Expected in strict mode + } + + // Assert + expect(result.newProperty).toBeUndefined(); + }); + + it('should create new copy of input arrays', () => { + // Arrange + const errors = ['Error 1']; + const warnings = ['Warning 1']; + + // Act + const result = new ValidationResult({ + valid: false, + errors, + warnings, + }); + errors.push('Error 2'); + warnings.push('Warning 2'); + + // Assert + expect(result.errors).toEqual(['Error 1']); + expect(result.warnings).toEqual(['Warning 1']); + }); + + it('should create new copy of input metadata', () => { + // Arrange + const metadata = { key: 'value' }; + + // Act + const result = new ValidationResult({ + valid: true, + metadata, + }); + metadata.key = 'modified'; + metadata.newKey = 'newValue'; + + // Assert + expect(result.metadata).toEqual({ key: 'value' }); + }); + }); + + describe('edge cases', () => { + it('should handle empty errors array', () => { + // Arrange + const result = ValidationResult.failure([]); + + // Act & Assert + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(false); + }); + + it('should handle empty warnings array', () => { + // Arrange + const result = ValidationResult.withWarnings([]); + + // Act & Assert + expect(result.valid).toBe(true); + expect(result.hasWarnings()).toBe(false); + }); + + it('should allow valid false with no errors', () => { + // Arrange & Act + const result = new ValidationResult({ + valid: false, + errors: [], + }); + + // Assert + expect(result.valid).toBe(false); + expect(result.hasErrors()).toBe(false); + }); + + it('should allow valid true with errors', () => { + // Arrange & Act + const result = new ValidationResult({ + valid: true, + errors: ['Error 1'], + }); + + // Assert + expect(result.valid).toBe(true); + expect(result.hasErrors()).toBe(true); + }); + + it('should handle complex metadata structures', () => { + // Arrange + const metadata = { + stackName: 'test-stack', + resources: { + added: ['Resource1', 'Resource2'], + modified: ['Resource3'], + }, + timestamp: Date.now(), + }; + + // Act + const result = ValidationResult.success(metadata); + + // Assert + expect(result.metadata).toEqual(metadata); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/deploy-command/dry-run/infrastructure/adapters/DryRunReporter.test.js b/packages/devtools/frigg-cli/deploy-command/dry-run/infrastructure/adapters/DryRunReporter.test.js new file mode 100644 index 000000000..6e9bdf660 --- /dev/null +++ b/packages/devtools/frigg-cli/deploy-command/dry-run/infrastructure/adapters/DryRunReporter.test.js @@ -0,0 +1,702 @@ +/** + * DryRunReporter Tests + * + * Test-Driven Development for the presentation layer + */ + +const { DryRunReporter } = require('./DryRunReporter'); +const { DryRunReport } = require('../../domain/entities/DryRunReport'); +const { DryRunStatus } = require('../../domain/value-objects/DryRunStatus'); +const { ValidationResult } = require('../../domain/value-objects/ValidationResult'); +const { captureConsoleOutput } = require('../../__tests__/helpers/test-utils'); + +describe('DryRunReporter', () => { + describe('constructor', () => { + it('should create reporter with default console format', () => { + const reporter = new DryRunReporter(); + + expect(reporter).toBeDefined(); + expect(reporter.format).toBe('console'); + }); + + it('should create reporter with specified format', () => { + const consoleReporter = new DryRunReporter({ format: 'console' }); + const jsonReporter = new DryRunReporter({ format: 'json' }); + + expect(consoleReporter.format).toBe('console'); + expect(jsonReporter.format).toBe('json'); + }); + + it('should throw error for invalid format', () => { + expect(() => new DryRunReporter({ format: 'xml' })).toThrow('Invalid format'); + }); + }); + + describe('report - console format', () => { + let reporter; + let consoleCapture; + + beforeEach(() => { + reporter = new DryRunReporter({ format: 'console' }); + consoleCapture = captureConsoleOutput(); + }); + + afterEach(() => { + consoleCapture.restore(); + }); + + it('should display complete successful report with all sections', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + + expect(output).toContain('Frigg Deploy Dry-Run'); + expect(output).toContain('App Configuration'); + expect(output).toContain('Environment Variables'); + expect(output).toContain('AWS Resource Discovery'); + expect(output).toContain('Generated Template Summary'); + expect(output).toContain('CloudFormation Change Set Preview'); + expect(output).toContain('Deployment Impact'); + expect(output).toContain('Dry-Run Summary'); + expect(output).toContain('Next Steps'); + }); + + it('should display app configuration section', () => { + const report = createBasicReport({ + stackName: 'my-app-production', + region: 'us-east-1', + stage: 'production', + }); + + const output = reporter.report(report); + + expect(output).toContain('App Configuration'); + expect(output).toContain('my-app-production'); + expect(output).toContain('production'); + expect(output).toContain('us-east-1'); + }); + + it('should display environment variables with present variables', () => { + const report = createBasicReport(); + report.setEnvironmentResult(ValidationResult.success({ + required: { + present: ['AWS_REGION', 'STAGE', 'DB_URI'], + missing: [], + }, + optional: { + present: ['SENTRY_DSN'], + missing: [], + }, + })); + + const output = reporter.report(report); + + expect(output).toContain('Environment Variables'); + expect(output).toContain('3 required variables present'); + }); + + it('should display environment variables with warnings for missing optional vars', () => { + const report = createBasicReport(); + report.setEnvironmentResult( + ValidationResult.withWarnings( + ['2 optional variables missing'], + { + required: { present: ['AWS_REGION', 'STAGE'], missing: [] }, + optional: { present: [], missing: ['SENTRY_DSN', 'NEW_RELIC_KEY'] }, + } + ) + ); + + const output = reporter.report(report); + + expect(output).toContain('Environment Variables'); + expect(output).toContain('2 required variables present'); + expect(output).toContain('2 optional variables missing'); + expect(output).toContain('SENTRY_DSN'); + expect(output).toContain('NEW_RELIC_KEY'); + }); + + it('should display environment variables with errors for missing required vars', () => { + const report = createBasicReport(); + report.setEnvironmentResult( + ValidationResult.failure( + ['Missing required environment variables'], + [], + { + required: { + present: ['AWS_REGION'], + missing: ['DB_URI', 'ENCRYPTION_KEY'], + }, + optional: { present: [], missing: [] }, + } + ) + ); + + const output = reporter.report(report); + + expect(output).toContain('Environment Variables'); + expect(output).toContain('2 required variables missing'); + expect(output).toContain('DB_URI'); + expect(output).toContain('ENCRYPTION_KEY'); + }); + + it('should display AWS resource discovery section when present', () => { + const report = createBasicReport(); + report.setDiscoveryResult({ + vpc: { id: 'vpc-12345678', cidr: '10.0.0.0/16' }, + subnets: ['subnet-11111111', 'subnet-22222222'], + securityGroups: ['sg-12345678'], + kmsKey: 'arn:aws:kms:us-east-1:123456789012:key/abc-123', + }); + + const output = reporter.report(report); + + expect(output).toContain('AWS Resource Discovery'); + expect(output).toContain('vpc-12345678'); + expect(output).toContain('10.0.0.0/16'); + expect(output).toContain('subnet-11111111'); + expect(output).toContain('sg-12345678'); + }); + + it('should skip AWS resource discovery section when not present', () => { + const report = createBasicReport(); + + const output = reporter.report(report); + + expect(output).not.toContain('AWS Resource Discovery'); + }); + + it('should display template summary section', () => { + const report = createBasicReport(); + report.setTemplateResult({ + service: 'my-integration-production', + functions: { + count: 3, + names: ['health', 'user', 'integration-hubspot'], + details: [ + { name: 'health', memory: 256, timeout: 30 }, + { name: 'user', memory: 512, timeout: 30 }, + { name: 'integration-hubspot', memory: 1024, timeout: 60 }, + ], + }, + endpoints: { + count: 2, + methods: ['GET /health', 'POST /api/integrations'], + }, + }); + + const output = reporter.report(report); + + expect(output).toContain('Generated Template Summary'); + expect(output).toContain('Functions: 3'); + expect(output).toContain('health'); + expect(output).toContain('256MB'); + expect(output).toContain('API Endpoints: 2'); + expect(output).toContain('GET /health'); + }); + + it('should display change set with no changes', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'my-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [], + summary: { add: 0, modify: 0, remove: 0, replace: 0 }, + }); + + const output = reporter.report(report); + + expect(output).toContain('CloudFormation Change Set Preview'); + expect(output).toContain('No changes'); + }); + + it('should display change set with additions', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'my-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [ + { + action: 'Add', + logicalId: 'HealthLambdaFunction', + resourceType: 'AWS::Lambda::Function', + }, + { + action: 'Add', + logicalId: 'ApiGatewayRestApi', + resourceType: 'AWS::ApiGateway::RestApi', + }, + ], + summary: { add: 2, modify: 0, remove: 0, replace: 0 }, + }); + + const output = reporter.report(report); + + expect(output).toContain('CloudFormation Change Set Preview'); + expect(output).toContain('Add (2)'); + expect(output).toContain('HealthLambdaFunction'); + expect(output).toContain('AWS::Lambda::Function'); + expect(output).toContain('ApiGatewayRestApi'); + }); + + it('should display change set with modifications', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'my-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [ + { + action: 'Modify', + logicalId: 'HealthLambdaFunction', + resourceType: 'AWS::Lambda::Function', + details: [ + { + target: 'Properties', + attribute: 'VpcConfig.SubnetIds', + changeSource: 'DirectModification', + }, + ], + }, + ], + summary: { add: 0, modify: 1, remove: 0, replace: 0 }, + }); + + const output = reporter.report(report); + + expect(output).toContain('Modify (1)'); + expect(output).toContain('HealthLambdaFunction'); + expect(output).toContain('VpcConfig.SubnetIds'); + }); + + it('should display change set with replacements', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'my-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [ + { + action: 'Modify', + logicalId: 'DatabaseSecurityGroup', + resourceType: 'AWS::EC2::SecurityGroup', + replacement: 'True', + replacementReason: 'VpcId property change requires replacement', + }, + ], + summary: { add: 0, modify: 0, remove: 0, replace: 1 }, + }); + + const output = reporter.report(report); + + expect(output).toContain('Replace (1)'); + expect(output).toContain('DatabaseSecurityGroup'); + expect(output).toContain('VpcId property change requires replacement'); + }); + + it('should display change set with removals', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'my-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [ + { + action: 'Remove', + logicalId: 'OldLambdaFunction', + resourceType: 'AWS::Lambda::Function', + }, + ], + summary: { add: 0, modify: 0, remove: 1, replace: 0 }, + }); + + const output = reporter.report(report); + + expect(output).toContain('Remove (1)'); + expect(output).toContain('OldLambdaFunction'); + }); + + it('should display deployment impact section when present', () => { + const report = createBasicReport(); + report.setImpactResult({ + downtime: '2-3 minutes', + functionsAffected: 12, + coldStarts: true, + breakingChanges: false, + }); + + const output = reporter.report(report); + + expect(output).toContain('Deployment Impact'); + expect(output).toContain('2-3 minutes'); + expect(output).toContain('12'); + expect(output).toContain('Cold Starts Expected'); + }); + + it('should display successful summary with no errors or warnings', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + + expect(output).toContain('Dry-Run Summary'); + expect(output).toContain('completed successfully'); + }); + + it('should display summary with warnings', () => { + const report = createReportWithWarnings(); + + const output = reporter.report(report); + + expect(output).toContain('completed with warnings'); + expect(output).toContain('2 optional environment variables missing'); + }); + + it('should display summary with errors', () => { + const report = createReportWithErrors(); + + const output = reporter.report(report); + + expect(output).toContain('failed validation'); + expect(output).toContain('Missing required environment variables'); + }); + + it('should display next steps section', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + + expect(output).toContain('Next Steps'); + expect(output).toContain('frigg deploy'); + expect(output).toContain('--stage'); + }); + + it('should not suggest execution when there are errors', () => { + const report = createReportWithErrors(); + + const output = reporter.report(report); + + expect(output).toContain('Next Steps'); + expect(output).toContain('Fix the errors'); + }); + }); + + describe('report - JSON format', () => { + let reporter; + + beforeEach(() => { + reporter = new DryRunReporter({ format: 'json' }); + }); + + it('should return valid JSON string', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + + expect(() => JSON.parse(output)).not.toThrow(); + }); + + it('should include all required fields', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.dryRun).toBe(true); + expect(json.timestamp).toBeDefined(); + expect(json.stackName).toBe('test-app-dev'); + expect(json.region).toBe('us-east-1'); + expect(json.stage).toBe('dev'); + expect(json.status).toBeDefined(); + expect(json.exitCode).toBeDefined(); + }); + + it('should include environment validation results', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.environment).toBeDefined(); + expect(json.environment.valid).toBe(true); + expect(json.environment.metadata).toBeDefined(); + }); + + it('should include discovery results when present', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.discovery).toBeDefined(); + expect(json.discovery.vpc).toBeDefined(); + expect(json.discovery.vpc.id).toBe('vpc-12345678'); + }); + + it('should include template summary', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.template).toBeDefined(); + expect(json.template.functions).toBeDefined(); + expect(json.template.functions.count).toBe(3); + }); + + it('should include change set preview', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.changeSet).toBeDefined(); + expect(json.changeSet.summary).toBeDefined(); + expect(json.changeSet.changes).toBeDefined(); + }); + + it('should include impact analysis when present', () => { + const report = createSuccessfulReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.impact).toBeDefined(); + expect(json.impact.downtime).toBe('2-3 minutes'); + }); + + it('should include proper exit code', () => { + const successReport = createSuccessfulReport(); + const warningReport = createReportWithWarnings(); + const errorReport = createReportWithErrors(); + + const successOutput = JSON.parse(reporter.report(successReport)); + const warningOutput = JSON.parse(reporter.report(warningReport)); + const errorOutput = JSON.parse(reporter.report(errorReport)); + + expect(successOutput.exitCode).toBe(0); + expect(warningOutput.exitCode).toBe(2); + expect(errorOutput.exitCode).toBe(1); + }); + + it('should handle null/undefined fields gracefully', () => { + const report = createBasicReport(); + + const output = reporter.report(report); + const json = JSON.parse(output); + + expect(json.discovery).toBeNull(); + expect(json.template).toBeNull(); + expect(json.changeSet).toBeNull(); + expect(json.impact).toBeNull(); + }); + }); + + describe('display method', () => { + it('should write console output to console.log', () => { + const reporter = new DryRunReporter({ format: 'console' }); + const report = createSuccessfulReport(); + const consoleCapture = captureConsoleOutput(); + + reporter.display(report); + + expect(consoleCapture.logs.length).toBeGreaterThan(0); + const fullOutput = consoleCapture.logs.join('\n'); + expect(fullOutput).toContain('Frigg Deploy Dry-Run'); + + consoleCapture.restore(); + }); + + it('should write JSON output to console.log', () => { + const reporter = new DryRunReporter({ format: 'json' }); + const report = createSuccessfulReport(); + const consoleCapture = captureConsoleOutput(); + + reporter.display(report); + + expect(consoleCapture.logs.length).toBe(1); + expect(() => JSON.parse(consoleCapture.logs[0])).not.toThrow(); + + consoleCapture.restore(); + }); + }); + + describe('edge cases', () => { + it('should handle missing environment result', () => { + const report = createBasicReport(); + const reporter = new DryRunReporter(); + + expect(() => reporter.report(report)).not.toThrow(); + const output = reporter.report(report); + expect(output).toBeDefined(); + }); + + it('should handle empty change set', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'test-stack', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [], + summary: { add: 0, modify: 0, remove: 0, replace: 0 }, + }); + const reporter = new DryRunReporter(); + + const output = reporter.report(report); + + expect(output).toContain('No changes'); + }); + + it('should handle very long resource names', () => { + const report = createBasicReport(); + report.setChangeSetResult({ + stackName: 'test-stack', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [ + { + action: 'Add', + logicalId: 'VeryLongResourceNameThatExceedsNormalLengthForDisplayPurposes', + resourceType: 'AWS::Lambda::Function', + }, + ], + summary: { add: 1, modify: 0, remove: 0, replace: 0 }, + }); + const reporter = new DryRunReporter(); + + expect(() => reporter.report(report)).not.toThrow(); + }); + + it('should handle reports with all sections empty', () => { + const report = createBasicReport(); + const reporter = new DryRunReporter(); + + const output = reporter.report(report); + + expect(output).toContain('App Configuration'); + expect(output).toContain('Dry-Run Summary'); + }); + }); +}); + +// Helper functions to create test reports + +function createBasicReport(overrides = {}) { + return new DryRunReport({ + stackName: 'test-app-dev', + region: 'us-east-1', + stage: 'dev', + status: DryRunStatus.success(), + timestamp: new Date('2025-10-28T14:30:22Z'), + ...overrides, + }); +} + +function createSuccessfulReport() { + const report = createBasicReport(); + + // Environment validation + report.setEnvironmentResult( + ValidationResult.success({ + required: { present: ['AWS_REGION', 'STAGE', 'DB_URI'], missing: [] }, + optional: { present: ['SENTRY_DSN'], missing: [] }, + }) + ); + + // Discovery results + report.setDiscoveryResult({ + vpc: { id: 'vpc-12345678', cidr: '10.0.0.0/16' }, + subnets: ['subnet-11111111', 'subnet-22222222'], + securityGroups: ['sg-12345678'], + kmsKey: 'arn:aws:kms:us-east-1:123456789012:key/abc-123', + }); + + // Template generation + report.setTemplateResult({ + service: 'my-integration-dev', + functions: { + count: 3, + names: ['health', 'user', 'integration-hubspot'], + details: [ + { name: 'health', memory: 256, timeout: 30 }, + { name: 'user', memory: 512, timeout: 30 }, + { name: 'integration-hubspot', memory: 1024, timeout: 60 }, + ], + }, + endpoints: { + count: 2, + methods: ['GET /health', 'POST /api/integrations'], + }, + }); + + // Change set + report.setChangeSetResult({ + stackName: 'test-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [ + { + action: 'Add', + logicalId: 'HealthLambdaFunction', + resourceType: 'AWS::Lambda::Function', + }, + ], + summary: { add: 1, modify: 0, remove: 0, replace: 0 }, + }); + + // Impact + report.setImpactResult({ + downtime: '2-3 minutes', + functionsAffected: 12, + coldStarts: true, + breakingChanges: false, + }); + + return report; +} + +function createReportWithWarnings() { + const report = createBasicReport({ + status: DryRunStatus.withWarnings(), + }); + + report.setEnvironmentResult( + ValidationResult.withWarnings( + ['2 optional environment variables missing'], + { + required: { present: ['AWS_REGION', 'STAGE'], missing: [] }, + optional: { present: [], missing: ['SENTRY_DSN', 'NEW_RELIC_KEY'] }, + } + ) + ); + + report.setChangeSetResult({ + stackName: 'test-app-dev', + changeSetId: 'arn:aws:cloudformation:...', + status: 'CREATE_COMPLETE', + changes: [], + summary: { add: 0, modify: 0, remove: 0, replace: 0 }, + }); + + return report; +} + +function createReportWithErrors() { + const report = createBasicReport({ + status: DryRunStatus.validationError(), + }); + + report.setEnvironmentResult( + ValidationResult.failure( + ['Missing required environment variables'], + [], + { + required: { present: ['AWS_REGION'], missing: ['DB_URI', 'ENCRYPTION_KEY'] }, + optional: { present: [], missing: [] }, + } + ) + ); + + return report; +} diff --git a/packages/devtools/frigg-cli/deploy-command/index.js b/packages/devtools/frigg-cli/deploy-command/index.js index 5f56d0da6..75faf47cb 100644 --- a/packages/devtools/frigg-cli/deploy-command/index.js +++ b/packages/devtools/frigg-cli/deploy-command/index.js @@ -268,15 +268,53 @@ async function runPostDeploymentHealthCheck(stackName, options) { } async function deployCommand(options) { + const stage = options.stage || 'dev'; + const region = process.env.AWS_REGION || 'us-east-1'; + + if (options.dryRun) { + console.log('šŸ” Running deployment dry-run...\n'); + + try { + const { executeDryRun } = require('./dry-run'); + const appPath = process.cwd(); + const appDefinition = loadAppDefinition(); + + if (!appDefinition) { + console.error('āŒ Could not load app definition from index.js'); + process.exit(1); + } + + const stackName = getStackName(appDefinition, options); + if (!stackName) { + console.error('āŒ Could not determine stack name from app definition'); + process.exit(1); + } + + const report = await executeDryRun({ + appPath, + stackName, + region, + stage, + options, + }); + + process.exit(report.getExitCode()); + } catch (error) { + console.error(`\nāŒ Dry-run failed: ${error.message}`); + if (options.verbose) { + console.error(error.stack); + } + process.exit(1); + } + } + console.log('Deploying the serverless application...'); const appDefinition = loadAppDefinition(); const environment = validateAndBuildEnvironment(appDefinition, options); - // Execute deployment const exitCode = await executeServerlessDeployment(environment, options); - // Check if deployment was successful if (exitCode !== 0) { console.error(`\nāœ— Deployment failed with exit code ${exitCode}`); process.exit(exitCode); @@ -286,7 +324,6 @@ async function deployCommand(options) { const skipHealthCheck = options.skipDoctor || appDefinition?.deployment?.skipPostDeploymentHealthCheck; - // Run post-deployment health check (unless disabled) if (!skipHealthCheck) { const stackName = getStackName(appDefinition, options); diff --git a/packages/devtools/frigg-cli/index.js b/packages/devtools/frigg-cli/index.js index 12d9d712d..be378f4d8 100755 --- a/packages/devtools/frigg-cli/index.js +++ b/packages/devtools/frigg-cli/index.js @@ -123,6 +123,9 @@ program .option('-v, --verbose', 'enable verbose output') .option('-f, --force', 'force deployment (bypasses caching for layers and functions)') .option('--skip-doctor', 'skip post-deployment health check') + .option('--skip-env-validation', 'skip environment variable validation') + .option('--dry-run', 'preview deployment changes without executing') + .option('--output ', 'output format for dry-run (console or json)', 'console') .action(deployCommand); program diff --git a/packages/devtools/frigg-cli/jest.config.js b/packages/devtools/frigg-cli/jest.config.js index e98ccc82b..12ccd38b3 100644 --- a/packages/devtools/frigg-cli/jest.config.js +++ b/packages/devtools/frigg-cli/jest.config.js @@ -4,7 +4,8 @@ module.exports = { '/__tests__/**/*.test.js', '/__tests__/**/*.spec.js', '/**/start-command.test.js', - '/**/__tests__/**/*.test.js' + '/**/__tests__/**/*.test.js', + '/deploy-command/**/*.test.js' ], // Exclude utility files and config from being treated as tests testPathIgnorePatterns: [