// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios // ABOUTME: Tests planning logic, state comparison, and various deployment scenarios package v1 import ( "context" "os" "path/filepath" "testing" "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // MockEdgeConnectClient is a mock implementation of the EdgeConnect client type MockEdgeConnectClient struct { mock.Mock } func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) { args := m.Called(ctx, appKey, region) if args.Get(0) == nil { return edgeconnect.App{}, args.Error(1) } return args.Get(0).(edgeconnect.App), args.Error(1) } func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return edgeconnect.AppInstance{}, args.Error(1) } return args.Get(0).(edgeconnect.AppInstance), args.Error(1) } func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { args := m.Called(ctx, input) return args.Error(0) } func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { args := m.Called(ctx, input) return args.Error(0) } func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { args := m.Called(ctx, appKey, region) return args.Error(0) } func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { args := m.Called(ctx, input) return args.Error(0) } func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { args := m.Called(ctx, input) return args.Error(0) } func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { args := m.Called(ctx, instanceKey, region) return args.Error(0) } func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { args := m.Called(ctx, appKey, region) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]edgeconnect.App), args.Error(1) } func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]edgeconnect.AppInstance), args.Error(1) } func TestNewPlanner(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) assert.NotNil(t, planner) assert.IsType(t, &EdgeConnectPlanner{}, planner) } func TestDefaultPlanOptions(t *testing.T) { opts := DefaultPlanOptions() assert.False(t, opts.DryRun) assert.False(t, opts.Force) assert.False(t, opts.SkipStateCheck) assert.True(t, opts.ParallelQueries) assert.Equal(t, 30*time.Second, opts.Timeout) } func createTestConfig(t *testing.T) *config.EdgeConnectConfig { // Create temporary manifest file tempDir := t.TempDir() manifestFile := filepath.Join(tempDir, "test-manifest.yaml") manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" err := os.WriteFile(manifestFile, []byte(manifestContent), 0644) require.NoError(t, err) return &config.EdgeConnectConfig{ Kind: "edgeconnect-deployment", Metadata: config.Metadata{ Name: "test-app", AppVersion: "1.0.0", Organization: "testorg", }, Spec: config.Spec{ K8sApp: &config.K8sApp{ ManifestFile: manifestFile, }, InfraTemplate: []config.InfraTemplate{ { Region: "US", CloudletOrg: "TestCloudletOrg", CloudletName: "TestCloudlet", FlavorName: "small", }, }, Network: &config.NetworkConfig{ OutboundConnections: []config.OutboundConnection{ { Protocol: "tcp", PortRangeMin: 80, PortRangeMax: 80, RemoteCIDR: "0.0.0.0/0", }, }, }, }, } } func TestPlanNewDeployment(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) testConfig := createTestConfig(t) // Mock API calls to return "not found" errors mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Plan) require.NoError(t, result.Error) plan := result.Plan assert.Equal(t, "test-app", plan.ConfigName) assert.Equal(t, ActionCreate, plan.AppAction.Type) assert.Equal(t, "Application does not exist", plan.AppAction.Reason) require.Len(t, plan.InstanceActions, 1) assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason) assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance assert.False(t, plan.IsEmpty()) mockClient.AssertExpectations(t) } func TestPlanExistingDeploymentNoChanges(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) testConfig := createTestConfig(t) // Note: We would calculate expected manifest hash here when API supports it // Mock existing app with same manifest hash and outbound connections manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" existingApp := &edgeconnect.App{ Key: edgeconnect.AppKey{ Organization: "testorg", Name: "test-app", Version: "1.0.0", }, Deployment: "kubernetes", DeploymentManifest: manifestContent, RequiredOutboundConnections: []edgeconnect.SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, PortRangeMax: 80, RemoteCIDR: "0.0.0.0/0", }, }, // Note: Manifest hash tracking would be implemented when API supports annotations } // Mock existing instance existingInstance := &edgeconnect.AppInstance{ Key: edgeconnect.AppInstanceKey{ Organization: "testorg", Name: "test-app-1.0.0-instance", CloudletKey: edgeconnect.CloudletKey{ Organization: "TestCloudletOrg", Name: "TestCloudlet", }, }, AppKey: edgeconnect.AppKey{ Organization: "testorg", Name: "test-app", Version: "1.0.0", }, Flavor: edgeconnect.Flavor{ Name: "small", }, State: "Ready", PowerState: "PowerOn", } mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). Return(*existingApp, nil) mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). Return(*existingInstance, nil) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Plan) plan := result.Plan assert.Equal(t, ActionNone, plan.AppAction.Type) assert.Len(t, plan.InstanceActions, 1) assert.Equal(t, ActionNone, plan.InstanceActions[0].Type) assert.Equal(t, 0, plan.TotalActions) assert.True(t, plan.IsEmpty()) assert.Contains(t, plan.Summary, "No changes required") mockClient.AssertExpectations(t) } func TestPlanWithOptions(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) testConfig := createTestConfig(t) opts := PlanOptions{ DryRun: true, SkipStateCheck: true, Timeout: 10 * time.Second, } ctx := context.Background() result, err := planner.PlanWithOptions(ctx, testConfig, opts) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Plan) plan := result.Plan assert.True(t, plan.DryRun) assert.Equal(t, ActionCreate, plan.AppAction.Type) assert.Contains(t, plan.AppAction.Reason, "state check skipped") // No API calls should be made when SkipStateCheck is true mockClient.AssertNotCalled(t, "ShowApp") mockClient.AssertNotCalled(t, "ShowAppInstance") } func TestPlanMultipleInfrastructures(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) testConfig := createTestConfig(t) // Add a second infrastructure target testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{ Region: "EU", CloudletOrg: "EUCloudletOrg", CloudletName: "EUCloudlet", FlavorName: "medium", }) // Mock API calls to return "not found" errors mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Plan) plan := result.Plan assert.Equal(t, ActionCreate, plan.AppAction.Type) // Should have 2 instance actions, one for each infrastructure require.Len(t, plan.InstanceActions, 2) assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type) assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances // Test cloudlet and region aggregation cloudlets := plan.GetTargetCloudlets() regions := plan.GetTargetRegions() assert.Len(t, cloudlets, 2) assert.Len(t, regions, 2) mockClient.AssertExpectations(t) } func TestCalculateManifestHash(t *testing.T) { planner := &EdgeConnectPlanner{} tempDir := t.TempDir() // Create test file testFile := filepath.Join(tempDir, "test.yaml") content := "test content for hashing" err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) hash1, err := planner.calculateManifestHash(testFile) require.NoError(t, err) assert.NotEmpty(t, hash1) assert.Len(t, hash1, 64) // SHA256 hex string length // Same content should produce same hash hash2, err := planner.calculateManifestHash(testFile) require.NoError(t, err) assert.Equal(t, hash1, hash2) // Different content should produce different hash err = os.WriteFile(testFile, []byte("different content"), 0644) require.NoError(t, err) hash3, err := planner.calculateManifestHash(testFile) require.NoError(t, err) assert.NotEqual(t, hash1, hash3) // Empty file path should return empty hash hash4, err := planner.calculateManifestHash("") require.NoError(t, err) assert.Empty(t, hash4) // Non-existent file should return error _, err = planner.calculateManifestHash("/non/existent/file") assert.Error(t, err) } func TestCompareAppStates(t *testing.T) { planner := &EdgeConnectPlanner{} current := &AppState{ Name: "test-app", Version: "1.0.0", AppType: AppTypeK8s, ManifestHash: "old-hash", } desired := &AppState{ Name: "test-app", Version: "1.0.0", AppType: AppTypeK8s, ManifestHash: "new-hash", } changes, manifestChanged := planner.compareAppStates(current, desired) assert.Len(t, changes, 1) assert.True(t, manifestChanged) assert.Contains(t, changes[0], "Manifest hash changed") // Test no changes desired.ManifestHash = "old-hash" changes, manifestChanged = planner.compareAppStates(current, desired) assert.Empty(t, changes) assert.False(t, manifestChanged) // Test app type change desired.AppType = AppTypeDocker changes, manifestChanged = planner.compareAppStates(current, desired) assert.Len(t, changes, 1) assert.False(t, manifestChanged) assert.Contains(t, changes[0], "App type changed") } func TestCompareAppStatesOutboundConnections(t *testing.T) { planner := &EdgeConnectPlanner{} // Test with no outbound connections current := &AppState{ Name: "test-app", Version: "1.0.0", AppType: AppTypeK8s, OutboundConnections: nil, } desired := &AppState{ Name: "test-app", Version: "1.0.0", AppType: AppTypeK8s, OutboundConnections: nil, } changes, _ := planner.compareAppStates(current, desired) assert.Empty(t, changes, "No changes expected when both have no outbound connections") // Test adding outbound connections desired.OutboundConnections = []SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, PortRangeMax: 80, RemoteCIDR: "0.0.0.0/0", }, } changes, _ = planner.compareAppStates(current, desired) assert.Len(t, changes, 1) assert.Contains(t, changes[0], "Outbound connections changed") // Test identical outbound connections current.OutboundConnections = []SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, PortRangeMax: 80, RemoteCIDR: "0.0.0.0/0", }, } changes, _ = planner.compareAppStates(current, desired) assert.Empty(t, changes, "No changes expected when outbound connections are identical") // Test different outbound connections (different port) desired.OutboundConnections[0].PortRangeMin = 443 desired.OutboundConnections[0].PortRangeMax = 443 changes, _ = planner.compareAppStates(current, desired) assert.Len(t, changes, 1) assert.Contains(t, changes[0], "Outbound connections changed") // Test same connections but different order (should be considered equal) current.OutboundConnections = []SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, PortRangeMax: 80, RemoteCIDR: "0.0.0.0/0", }, { Protocol: "tcp", PortRangeMin: 443, PortRangeMax: 443, RemoteCIDR: "0.0.0.0/0", }, } desired.OutboundConnections = []SecurityRule{ { Protocol: "tcp", PortRangeMin: 443, PortRangeMax: 443, RemoteCIDR: "0.0.0.0/0", }, { Protocol: "tcp", PortRangeMin: 80, PortRangeMax: 80, RemoteCIDR: "0.0.0.0/0", }, } changes, _ = planner.compareAppStates(current, desired) assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order") // Test removing outbound connections desired.OutboundConnections = nil changes, _ = planner.compareAppStates(current, desired) assert.Len(t, changes, 1) assert.Contains(t, changes[0], "Outbound connections changed") } func TestCompareInstanceStates(t *testing.T) { planner := &EdgeConnectPlanner{} current := &InstanceState{ Name: "test-instance", FlavorName: "small", CloudletName: "oldcloudlet", CloudletOrg: "oldorg", } desired := &InstanceState{ Name: "test-instance", FlavorName: "medium", CloudletName: "newcloudlet", CloudletOrg: "neworg", } changes := planner.compareInstanceStates(current, desired) assert.Len(t, changes, 3) assert.Contains(t, changes[0], "Flavor changed") assert.Contains(t, changes[1], "Cloudlet changed") assert.Contains(t, changes[2], "Cloudlet org changed") // Test no changes desired.FlavorName = "small" desired.CloudletName = "oldcloudlet" desired.CloudletOrg = "oldorg" changes = planner.compareInstanceStates(current, desired) assert.Empty(t, changes) } func TestDeploymentPlanMethods(t *testing.T) { plan := &DeploymentPlan{ ConfigName: "test-plan", AppAction: AppAction{ Type: ActionCreate, Desired: &AppState{Name: "test-app"}, }, InstanceActions: []InstanceAction{ { Type: ActionCreate, Target: config.InfraTemplate{ CloudletOrg: "org1", CloudletName: "cloudlet1", Region: "US", }, InstanceName: "instance1", Desired: &InstanceState{Name: "instance1"}, }, { Type: ActionUpdate, Target: config.InfraTemplate{ CloudletOrg: "org2", CloudletName: "cloudlet2", Region: "EU", }, InstanceName: "instance2", Desired: &InstanceState{Name: "instance2"}, }, }, } // Test IsEmpty assert.False(t, plan.IsEmpty()) // Test GetTargetCloudlets cloudlets := plan.GetTargetCloudlets() assert.Len(t, cloudlets, 2) assert.Contains(t, cloudlets, "org1:cloudlet1") assert.Contains(t, cloudlets, "org2:cloudlet2") // Test GetTargetRegions regions := plan.GetTargetRegions() assert.Len(t, regions, 2) assert.Contains(t, regions, "US") assert.Contains(t, regions, "EU") // Test GenerateSummary summary := plan.GenerateSummary() assert.Contains(t, summary, "test-plan") assert.Contains(t, summary, "CREATE application") assert.Contains(t, summary, "CREATE 1 instance") assert.Contains(t, summary, "UPDATE 1 instance") // Test Validate err := plan.Validate() assert.NoError(t, err) // Test validation failure plan.AppAction.Desired = nil err = plan.Validate() assert.Error(t, err) assert.Contains(t, err.Error(), "must have desired state") } func TestEstimateDeploymentDuration(t *testing.T) { planner := &EdgeConnectPlanner{} plan := &DeploymentPlan{ AppAction: AppAction{Type: ActionCreate}, InstanceActions: []InstanceAction{ {Type: ActionCreate}, {Type: ActionUpdate}, }, } duration := planner.estimateDeploymentDuration(plan) assert.Greater(t, duration, time.Duration(0)) assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound // Test with no actions emptyPlan := &DeploymentPlan{ AppAction: AppAction{Type: ActionNone}, InstanceActions: []InstanceAction{}, } emptyDuration := planner.estimateDeploymentDuration(emptyPlan) assert.Greater(t, emptyDuration, time.Duration(0)) assert.Less(t, emptyDuration, duration) // Should be less than plan with actions } func TestIsResourceNotFoundError(t *testing.T) { tests := []struct { name string err error expected bool }{ {"nil error", nil, false}, {"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, {"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isResourceNotFoundError(tt.err) assert.Equal(t, tt.expected, result) }) } } func TestPlanErrorHandling(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) testConfig := createTestConfig(t) // Mock API call to return a non-404 error mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) assert.Error(t, err) assert.NotNil(t, result) assert.NotNil(t, result.Error) assert.Contains(t, err.Error(), "failed to query current app state") mockClient.AssertExpectations(t) }