package worker import ( "context" "testing" "time" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "github.com/orchard9/rdev/internal/adapter/deployer" ) // mockProjectChecker simulates DB project existence checks. type mockProjectChecker struct { projects map[string]bool } func (m *mockProjectChecker) projectExists(_ context.Context, projectID string) (bool, error) { return m.projects[projectID], nil } func newTestDeployerForGC(client *fake.Clientset) *deployer.Deployer { return deployer.NewDeployer(client, deployer.Config{Namespace: "projects"}) } func createDeployment(t *testing.T, client *fake.Clientset, name, project string, createdAt time.Time) { t.Helper() replicas := int32(1) _, err := client.AppsV1().Deployments("projects").Create(context.Background(), &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "projects", Labels: map[string]string{"app": name, "project": project}, CreationTimestamp: metav1.NewTime(createdAt), }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: name, Image: "test:latest"}}}, }, }, }, metav1.CreateOptions{}) if err != nil { t.Fatalf("failed to create deployment %s: %v", name, err) } } func TestResourceGC_DeletesOrphans(t *testing.T) { client := fake.NewSimpleClientset() d := newTestDeployerForGC(client) ctx := context.Background() // Create resources for two projects, both old enough oldTime := time.Now().Add(-2 * time.Hour) createDeployment(t, client, "orphan-proj", "orphan-proj", oldTime) createDeployment(t, client, "valid-proj", "valid-proj", oldTime) checker := &mockProjectChecker{ projects: map[string]bool{ "valid-proj": true, // exists in DB // orphan-proj is NOT in DB }, } gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{ MinAge: 1 * time.Hour, ReconcileInterval: 1 * time.Hour, // won't tick during test }) gc.runReconciliation() // Orphan should be deleted deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{}) if len(deps.Items) != 1 { t.Fatalf("expected 1 deployment, got %d", len(deps.Items)) } if deps.Items[0].Name != "valid-proj" { t.Errorf("remaining deployment = %q, want %q", deps.Items[0].Name, "valid-proj") } } func TestResourceGC_SkipsValidProjects(t *testing.T) { client := fake.NewSimpleClientset() d := newTestDeployerForGC(client) ctx := context.Background() oldTime := time.Now().Add(-2 * time.Hour) createDeployment(t, client, "proj-a", "proj-a", oldTime) createDeployment(t, client, "proj-b", "proj-b", oldTime) checker := &mockProjectChecker{ projects: map[string]bool{ "proj-a": true, "proj-b": true, }, } gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{ MinAge: 1 * time.Hour, ReconcileInterval: 1 * time.Hour, }) gc.runReconciliation() // Both should remain deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{}) if len(deps.Items) != 2 { t.Errorf("expected 2 deployments, got %d", len(deps.Items)) } } func TestResourceGC_SkipsYoungOrphans(t *testing.T) { client := fake.NewSimpleClientset() d := newTestDeployerForGC(client) ctx := context.Background() // Create a recent deployment (younger than minAge) recentTime := time.Now().Add(-5 * time.Minute) createDeployment(t, client, "new-orphan", "new-orphan", recentTime) checker := &mockProjectChecker{ projects: map[string]bool{}, // not in DB } gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{ MinAge: 1 * time.Hour, ReconcileInterval: 1 * time.Hour, }) gc.runReconciliation() // Should NOT be deleted (too young) deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{}) if len(deps.Items) != 1 { t.Errorf("expected 1 deployment (young orphan preserved), got %d", len(deps.Items)) } } func TestResourceGC_DeletesMonorepoComponents(t *testing.T) { client := fake.NewSimpleClientset() d := newTestDeployerForGC(client) ctx := context.Background() oldTime := time.Now().Add(-2 * time.Hour) createDeployment(t, client, "myapp", "myapp", oldTime) createDeployment(t, client, "myapp-studio-ui", "myapp", oldTime) createDeployment(t, client, "myapp-studio-api", "myapp", oldTime) checker := &mockProjectChecker{ projects: map[string]bool{}, // not in DB } gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{ MinAge: 1 * time.Hour, ReconcileInterval: 1 * time.Hour, }) gc.runReconciliation() // All 3 should be deleted deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{}) if len(deps.Items) != 0 { t.Errorf("expected 0 deployments, got %d", len(deps.Items)) } } func TestResourceGC_StartStop(t *testing.T) { client := fake.NewSimpleClientset() d := newTestDeployerForGC(client) checker := &mockProjectChecker{projects: map[string]bool{}} gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{ MinAge: 1 * time.Hour, ReconcileInterval: 24 * time.Hour, // long interval so ticker won't fire }) gc.Start() // Give the goroutine time to run initial reconciliation time.Sleep(50 * time.Millisecond) gc.Stop() // should not hang }