package deployer import ( "context" "fmt" "testing" "time" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime/schema" "github.com/orchard9/rdev/internal/domain" ) func TestBuildIngress_WithBasePath(t *testing.T) { d := &Deployer{config: Config{IngressClass: "traefik", TLSIssuer: "letsencrypt"}} pathType := networkingv1.PathTypePrefix tests := []struct { name string spec domain.DeploySpec expectedPath string }{ { name: "uses BasePath when set", spec: domain.DeploySpec{ ProjectName: "myapp", Domain: "myapp.threesix.ai", Port: 8080, BasePath: "/api/auth", }, expectedPath: "/api/auth", }, { name: "defaults to / when BasePath empty", spec: domain.DeploySpec{ ProjectName: "myapp", Domain: "myapp.threesix.ai", Port: 8080, BasePath: "", }, expectedPath: "/", }, { name: "root path explicit", spec: domain.DeploySpec{ ProjectName: "myapp", Domain: "myapp.threesix.ai", Port: 3000, BasePath: "/", }, expectedPath: "/", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ingress := d.buildIngress(tc.spec, "projects", pathType, "traefik", "myapp-tls", nil) if len(ingress.Spec.Rules) != 1 { t.Fatalf("expected 1 rule, got %d", len(ingress.Spec.Rules)) } if len(ingress.Spec.Rules[0].HTTP.Paths) != 1 { t.Fatalf("expected 1 path, got %d", len(ingress.Spec.Rules[0].HTTP.Paths)) } actualPath := ingress.Spec.Rules[0].HTTP.Paths[0].Path if actualPath != tc.expectedPath { t.Errorf("path = %q, want %q", actualPath, tc.expectedPath) } }) } } func TestBuildIngress_Labels(t *testing.T) { d := &Deployer{config: Config{IngressClass: "traefik"}} pathType := networkingv1.PathTypePrefix t.Run("includes component label for monorepo component", func(t *testing.T) { spec := domain.DeploySpec{ ProjectName: "myapp", ComponentPath: "services/api", Domain: "myapp.threesix.ai", Port: 8080, } ingress := d.buildIngress(spec, "projects", pathType, "traefik", "myapp-tls", nil) if ingress.Labels["component"] != "services-api" { t.Errorf("component label = %q, want %q", ingress.Labels["component"], "services-api") } if ingress.Labels["project"] != "myapp" { t.Errorf("project label = %q, want %q", ingress.Labels["project"], "myapp") } }) t.Run("no component label for single-app project", func(t *testing.T) { spec := domain.DeploySpec{ ProjectName: "myapp", Domain: "myapp.threesix.ai", Port: 8080, } ingress := d.buildIngress(spec, "projects", pathType, "traefik", "myapp-tls", nil) if _, ok := ingress.Labels["component"]; ok { t.Error("component label should not be present for single-app project") } }) } func TestSortIngressPaths(t *testing.T) { tests := []struct { name string input []string expected []string }{ { name: "sorts by length descending", input: []string{"/", "/api", "/api/auth"}, expected: []string{"/api/auth", "/api", "/"}, }, { name: "same length sorted alphabetically", input: []string{"/api", "/web"}, expected: []string{"/api", "/web"}, }, { name: "already sorted", input: []string{"/api/auth", "/api", "/"}, expected: []string{"/api/auth", "/api", "/"}, }, { name: "empty list", input: []string{}, expected: []string{}, }, { name: "single item", input: []string{"/api"}, expected: []string{"/api"}, }, { name: "reverse order", input: []string{"/", "/a", "/ab", "/abc"}, expected: []string{"/abc", "/ab", "/a", "/"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { pathType := networkingv1.PathTypePrefix paths := make([]networkingv1.HTTPIngressPath, len(tc.input)) for i, p := range tc.input { paths[i] = networkingv1.HTTPIngressPath{ Path: p, PathType: &pathType, } } sortIngressPaths(paths) for i, expected := range tc.expected { if paths[i].Path != expected { t.Errorf("paths[%d] = %q, want %q", i, paths[i].Path, expected) } } }) } } func TestSanitizeLabelValue(t *testing.T) { tests := []struct { input string expected string }{ {"services/api", "services-api"}, {"apps/web", "apps-web"}, {"services/auth-api", "services-auth-api"}, {"simple", "simple"}, {"", ""}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { result := sanitizeLabelValue(tc.input) if result != tc.expected { t.Errorf("sanitizeLabelValue(%q) = %q, want %q", tc.input, result, tc.expected) } }) } } // ============================================================================= // AddIngressPath Tests // ============================================================================= func TestAddIngressPath_CreatesNewIngress(t *testing.T) { mock := newMockIngressClient() d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", TLSIssuer: "letsencrypt", }) ctx := context.Background() err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api/auth", "myapp-auth", 8001) if err != nil { t.Fatalf("AddIngressPath failed: %v", err) } ingress, err := mock.GetIngress(ctx, "projects", "myapp") if err != nil { t.Fatalf("Failed to get created ingress: %v", err) } // Verify ingress structure if ingress.Name != "myapp" { t.Errorf("ingress name = %q, want %q", ingress.Name, "myapp") } if ingress.Labels["project"] != "myapp" { t.Errorf("project label = %q, want %q", ingress.Labels["project"], "myapp") } if len(ingress.Spec.Rules) != 1 { t.Fatalf("expected 1 rule, got %d", len(ingress.Spec.Rules)) } if ingress.Spec.Rules[0].Host != "myapp.threesix.ai" { t.Errorf("host = %q, want %q", ingress.Spec.Rules[0].Host, "myapp.threesix.ai") } if len(ingress.Spec.Rules[0].HTTP.Paths) != 1 { t.Fatalf("expected 1 path, got %d", len(ingress.Spec.Rules[0].HTTP.Paths)) } path := ingress.Spec.Rules[0].HTTP.Paths[0] if path.Path != "/api/auth" { t.Errorf("path = %q, want %q", path.Path, "/api/auth") } if path.Backend.Service.Name != "myapp-auth" { t.Errorf("service name = %q, want %q", path.Backend.Service.Name, "myapp-auth") } if path.Backend.Service.Port.Number != 8001 { t.Errorf("port = %d, want %d", path.Backend.Service.Port.Number, 8001) } // Verify TLS if len(ingress.Spec.TLS) != 1 { t.Fatalf("expected 1 TLS entry, got %d", len(ingress.Spec.TLS)) } if ingress.Spec.TLS[0].Hosts[0] != "myapp.threesix.ai" { t.Errorf("TLS host = %q, want %q", ingress.Spec.TLS[0].Hosts[0], "myapp.threesix.ai") } // Verify annotations if ingress.Annotations["cert-manager.io/cluster-issuer"] != "letsencrypt" { t.Errorf("cluster-issuer annotation = %q, want %q", ingress.Annotations["cert-manager.io/cluster-issuer"], "letsencrypt") } } func TestAddIngressPath_AddsPathToExistingIngress(t *testing.T) { mock := newMockIngressClient() d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Create initial path err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/", "myapp-web", 3000) if err != nil { t.Fatalf("initial AddIngressPath failed: %v", err) } // Add second path err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080) if err != nil { t.Fatalf("second AddIngressPath failed: %v", err) } ingress, err := mock.GetIngress(ctx, "projects", "myapp") if err != nil { t.Fatalf("Failed to get ingress: %v", err) } // Should have 2 paths, sorted by specificity (longer first) paths := ingress.Spec.Rules[0].HTTP.Paths if len(paths) != 2 { t.Fatalf("expected 2 paths, got %d", len(paths)) } if paths[0].Path != "/api" { t.Errorf("first path = %q, want %q (longer path first)", paths[0].Path, "/api") } if paths[1].Path != "/" { t.Errorf("second path = %q, want %q", paths[1].Path, "/") } } func TestAddIngressPath_UpdatesExistingPath(t *testing.T) { mock := newMockIngressClient() d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Create initial path err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api-v1", 8080) if err != nil { t.Fatalf("initial AddIngressPath failed: %v", err) } // Update same path with different service err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api-v2", 8081) if err != nil { t.Fatalf("update AddIngressPath failed: %v", err) } ingress, err := mock.GetIngress(ctx, "projects", "myapp") if err != nil { t.Fatalf("Failed to get ingress: %v", err) } // Should still have only 1 path (updated, not duplicated) paths := ingress.Spec.Rules[0].HTTP.Paths if len(paths) != 1 { t.Fatalf("expected 1 path, got %d", len(paths)) } if paths[0].Backend.Service.Name != "myapp-api-v2" { t.Errorf("service = %q, want %q", paths[0].Backend.Service.Name, "myapp-api-v2") } if paths[0].Backend.Service.Port.Number != 8081 { t.Errorf("port = %d, want %d", paths[0].Backend.Service.Port.Number, 8081) } } func TestAddIngressPath_RetriesOnConflict(t *testing.T) { // Use conflictNTimesClient which returns conflict errors N times then succeeds conflictClient := newConflictNTimesClient(2) // Fail twice, succeed on third d := NewDeployerWithIngressClient(nil, conflictClient, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Pre-create ingress so update is called (not create) _, err := conflictClient.CreateIngress(ctx, "projects", &networkingv1.Ingress{ ObjectMeta: networkingv1.Ingress{}.ObjectMeta, }) // Reset - manually create the ingress via the underlying mock conflictClient.mockIngressClient.mu.Lock() pathType := networkingv1.PathTypePrefix conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{ ObjectMeta: networkingv1.Ingress{}.ObjectMeta, Spec: networkingv1.IngressSpec{ Rules: []networkingv1.IngressRule{ { Host: "myapp.threesix.ai", IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ {Path: "/", PathType: &pathType}, }, }, }, }, }, }, } conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp" conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1" conflictClient.mockIngressClient.mu.Unlock() err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080) if err != nil { t.Fatalf("should succeed after retries: %v", err) } // Should have called update 3 times (2 failures + 1 success) if conflictClient.UpdateCallCount() != 3 { t.Errorf("expected 3 update calls (2 failures + 1 success), got %d", conflictClient.UpdateCallCount()) } } func TestAddIngressPath_FailsAfterMaxRetries(t *testing.T) { // Use conflictNTimesClient which always returns conflict conflictClient := newConflictNTimesClient(maxIngressRetries + 1) // Always fail d := NewDeployerWithIngressClient(nil, conflictClient, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Pre-create ingress conflictClient.mockIngressClient.mu.Lock() pathType := networkingv1.PathTypePrefix conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{ Spec: networkingv1.IngressSpec{ Rules: []networkingv1.IngressRule{ { Host: "myapp.threesix.ai", IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ {Path: "/", PathType: &pathType}, }, }, }, }, }, }, } conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp" conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1" conflictClient.mockIngressClient.mu.Unlock() err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080) if err == nil { t.Fatal("expected error after max retries") } if conflictClient.UpdateCallCount() != maxIngressRetries { t.Errorf("expected %d update calls, got %d", maxIngressRetries, conflictClient.UpdateCallCount()) } } func TestAddIngressPath_DoesNotRetryNonConflictErrors(t *testing.T) { mock := newMockIngressClient() // Set a non-conflict error mock.getErr = fmt.Errorf("network error") d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080) if err == nil { t.Fatal("expected error") } // Should only call Get once (no retry for non-conflict errors) if mock.getCalls.Load() != 1 { t.Errorf("expected 1 get call, got %d", mock.getCalls.Load()) } } // ============================================================================= // RemoveIngressPath Tests // ============================================================================= func TestRemoveIngressPath_RemovesPath(t *testing.T) { mock := newMockIngressClient() d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Create ingress with two paths _ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/", "myapp-web", 3000) _ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080) // Remove one path err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api") if err != nil { t.Fatalf("RemoveIngressPath failed: %v", err) } ingress, _ := mock.GetIngress(ctx, "projects", "myapp") paths := ingress.Spec.Rules[0].HTTP.Paths if len(paths) != 1 { t.Fatalf("expected 1 path remaining, got %d", len(paths)) } if paths[0].Path != "/" { t.Errorf("remaining path = %q, want %q", paths[0].Path, "/") } } func TestRemoveIngressPath_DeletesIngressWhenEmpty(t *testing.T) { mock := newMockIngressClient() d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Create ingress with single path _ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080) // Remove the only path err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api") if err != nil { t.Fatalf("RemoveIngressPath failed: %v", err) } // Ingress should be deleted _, err = mock.GetIngress(ctx, "projects", "myapp") if !errors.IsNotFound(err) { t.Errorf("expected NotFound error, got %v", err) } } func TestRemoveIngressPath_NotFoundIsNoOp(t *testing.T) { mock := newMockIngressClient() d := NewDeployerWithIngressClient(nil, mock, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Remove from non-existent ingress - should not error err := d.RemoveIngressPath(ctx, "nonexistent", "nonexistent.threesix.ai", "/api") if err != nil { t.Errorf("expected no error for non-existent ingress, got %v", err) } } func TestRemoveIngressPath_RetriesOnConflict(t *testing.T) { conflictClient := newConflictNTimesClient(1) // Fail once, succeed on second d := NewDeployerWithIngressClient(nil, conflictClient, Config{ Namespace: "projects", IngressClass: "traefik", }) ctx := context.Background() // Pre-create ingress with two paths conflictClient.mockIngressClient.mu.Lock() pathType := networkingv1.PathTypePrefix conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{ Spec: networkingv1.IngressSpec{ Rules: []networkingv1.IngressRule{ { Host: "myapp.threesix.ai", IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ {Path: "/", PathType: &pathType}, {Path: "/api", PathType: &pathType}, }, }, }, }, }, }, } conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp" conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1" conflictClient.mockIngressClient.mu.Unlock() err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api") if err != nil { t.Fatalf("should succeed after retry: %v", err) } // Should have called update 2 times (1 failure + 1 success) if conflictClient.UpdateCallCount() != 2 { t.Errorf("expected 2 update calls, got %d", conflictClient.UpdateCallCount()) } } // ============================================================================= // retryOnConflict Tests // ============================================================================= func TestRetryOnConflict_SucceedsImmediately(t *testing.T) { calls := 0 err := retryOnConflict(context.Background(), func() error { calls++ return nil }) if err != nil { t.Errorf("unexpected error: %v", err) } if calls != 1 { t.Errorf("expected 1 call, got %d", calls) } } func TestRetryOnConflict_RetriesConflictErrors(t *testing.T) { calls := 0 err := retryOnConflict(context.Background(), func() error { calls++ if calls < 3 { return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict")) } return nil }) if err != nil { t.Errorf("unexpected error: %v", err) } if calls != 3 { t.Errorf("expected 3 calls, got %d", calls) } } func TestRetryOnConflict_FailsAfterMaxRetries(t *testing.T) { calls := 0 err := retryOnConflict(context.Background(), func() error { calls++ return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict")) }) if err == nil { t.Error("expected error") } if calls != maxIngressRetries { t.Errorf("expected %d calls, got %d", maxIngressRetries, calls) } } func TestRetryOnConflict_DoesNotRetryNonConflictErrors(t *testing.T) { calls := 0 err := retryOnConflict(context.Background(), func() error { calls++ return fmt.Errorf("some other error") }) if err == nil { t.Error("expected error") } if calls != 1 { t.Errorf("expected 1 call (no retry for non-conflict), got %d", calls) } } func TestRetryOnConflict_RespectsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) calls := 0 // Cancel after first call go func() { time.Sleep(50 * time.Millisecond) cancel() }() err := retryOnConflict(ctx, func() error { calls++ return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict")) }) if err != context.Canceled { t.Errorf("expected context.Canceled, got %v", err) } }