package handlers import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" ) func setupCheckoutTest() (*CheckoutHandler, *mockCheckoutRepository, *mockProjectRepo) { checkoutRepo := newMockCheckoutRepository() projectRepo := newMockProjectRepo() gitRepo := newMockGitRepository() projectRepo.projects["test-project"] = &domain.Project{ ID: "test-project", Name: "test-project", PodName: "test-project-0", Status: domain.ProjectStatusRunning, } checkoutService := service.NewCheckoutService( checkoutRepo, gitRepo, projectRepo, service.CheckoutServiceConfig{ GiteaURL: "https://git.threesix.ai", DefaultOwner: "threesix", DefaultExpiry: 24 * time.Hour, }, ) handler := NewCheckoutHandler(checkoutService) return handler, checkoutRepo, projectRepo } func TestCheckoutHandler_ListBranches(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) t.Run("list_branches_success", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/branches", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } branches, ok := data["branches"].([]any) if !ok { t.Fatalf("expected branches array, got %T", data["branches"]) } if len(branches) != 2 { t.Fatalf("got %d branches, want 2", len(branches)) } names := make(map[string]bool) for _, b := range branches { bm := b.(map[string]any) names[bm["name"].(string)] = true } if !names["main"] { t.Error("expected 'main' branch in response") } if !names["develop"] { t.Error("expected 'develop' branch in response") } }) t.Run("list_branches_project_not_found", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/checkout/branches", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) } func TestCheckoutHandler_Create(t *testing.T) { t.Run("create_existing_branch", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"branch": "develop"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["id"] == nil || data["id"] == "" { t.Error("expected non-empty id") } if data["clone_url"] == nil || data["clone_url"] == "" { t.Error("expected non-empty clone_url") } if data["instructions"] == nil || data["instructions"] == "" { t.Error("expected non-empty instructions") } if data["branch"] != "develop" { t.Errorf("expected branch=develop, got %v", data["branch"]) } if data["project_id"] != "test-project" { t.Errorf("expected project_id=test-project, got %v", data["project_id"]) } if data["status"] != "active" { t.Errorf("expected status=active, got %v", data["status"]) } }) t.Run("create_new_branch", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"new_branch": "feature-test"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["branch"] != "feature-test" { t.Errorf("expected branch=feature-test, got %v", data["branch"]) } }) t.Run("create_branch_conflict", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"branch": "develop", "new_branch": "feature-test"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("create_no_branch", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("create_protected_branch", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"branch": "main"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("create_project_not_found", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"branch": "develop"}` req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) t.Run("create_expiry_7d", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"branch": "develop", "expires_in": "7d"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } // Verify expiry is approximately 7 days from now. expiresAtStr, ok := data["expires_at"].(string) if !ok || expiresAtStr == "" { t.Fatal("expected non-empty expires_at") } expiresAt, err := time.Parse(time.RFC3339, expiresAtStr) if err != nil { t.Fatalf("parse expires_at: %v", err) } expectedExpiry := time.Now().Add(7 * 24 * time.Hour) diff := expiresAt.Sub(expectedExpiry) if diff < -time.Minute || diff > time.Minute { t.Errorf("expires_at off by %v, expected ~7 days from now", diff) } }) } func TestCheckoutHandler_List(t *testing.T) { t.Run("list_empty", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } checkouts, ok := data["checkouts"].([]any) if !ok { t.Fatalf("expected checkouts array, got %T", data["checkouts"]) } if len(checkouts) != 0 { t.Errorf("got %d checkouts, want 0", len(checkouts)) } }) t.Run("list_with_results", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() // Seed a checkout for the test project. checkoutRepo.checkouts["checkout-list-1"] = &domain.Checkout{ ID: "checkout-list-1", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } checkouts, ok := data["checkouts"].([]any) if !ok { t.Fatalf("expected checkouts array, got %T", data["checkouts"]) } if len(checkouts) != 1 { t.Errorf("got %d checkouts, want 1", len(checkouts)) } }) } func TestCheckoutHandler_Get(t *testing.T) { t.Run("get_success", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() checkoutRepo.checkouts["checkout-123"] = &domain.Checkout{ ID: "checkout-123", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/checkout-123", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["id"] != "checkout-123" { t.Errorf("expected id=checkout-123, got %v", data["id"]) } if data["branch"] != "develop" { t.Errorf("expected branch=develop, got %v", data["branch"]) } if data["project_id"] != "test-project" { t.Errorf("expected project_id=test-project, got %v", data["project_id"]) } }) t.Run("get_wrong_project", func(t *testing.T) { handler, checkoutRepo, projectRepo := setupCheckoutTest() // Add a second project so the URL is valid. projectRepo.projects["other-project"] = &domain.Project{ ID: "other-project", Name: "other-project", PodName: "other-project-0", Status: domain.ProjectStatusRunning, } checkoutRepo.checkouts["checkout-123"] = &domain.Checkout{ ID: "checkout-123", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/projects/other-project/checkout/checkout-123", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) t.Run("get_not_found", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/nonexistent", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) } func TestCheckoutHandler_Checkin(t *testing.T) { t.Run("checkin_with_review", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() checkoutRepo.checkouts["checkout-checkin"] = &domain.Checkout{ ID: "checkout-checkin", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-checkin/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["checkout_id"] != "checkout-checkin" { t.Errorf("expected checkout_id=checkout-checkin, got %v", data["checkout_id"]) } if data["status"] != "checked_in" { t.Errorf("expected status=checked_in, got %v", data["status"]) } if data["message"] == nil || data["message"] == "" { t.Error("expected non-empty message") } }) t.Run("checkin_skip_review", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() checkoutRepo.checkouts["checkout-skip"] = &domain.Checkout{ ID: "checkout-skip", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"skip_review": true}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-skip/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["status"] != "checked_in" { t.Errorf("expected status=checked_in, got %v", data["status"]) } // With skip_review=true and no work queue, no review task should be set. if data["review_task_id"] != nil && data["review_task_id"] != "" { t.Errorf("expected empty review_task_id with skip_review, got %v", data["review_task_id"]) } }) t.Run("checkin_not_active", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() now := time.Now() checkoutRepo.checkouts["checkout-done"] = &domain.Checkout{ ID: "checkout-done", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: now.Add(-1 * time.Hour), ExpiresAt: now.Add(23 * time.Hour), Status: domain.CheckoutStatusCheckedIn, CheckedInAt: &now, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-done/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("checkin_not_found", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/nonexistent/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) t.Run("checkin_wrong_project", func(t *testing.T) { handler, checkoutRepo, projectRepo := setupCheckoutTest() projectRepo.projects["other-project"] = &domain.Project{ ID: "other-project", Name: "other-project", PodName: "other-project-0", Status: domain.ProjectStatusRunning, } checkoutRepo.checkouts["checkout-wrong"] = &domain.Checkout{ ID: "checkout-wrong", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/other-project/checkout/checkout-wrong/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) } func TestCheckoutHandler_Revoke(t *testing.T) { t.Run("revoke_success", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() checkoutRepo.checkouts["checkout-revoke"] = &domain.Checkout{ ID: "checkout-revoke", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/checkout-revoke", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["status"] != "revoked" { t.Errorf("expected status=revoked, got %v", data["status"]) } if data["id"] != "checkout-revoke" { t.Errorf("expected id=checkout-revoke, got %v", data["id"]) } }) t.Run("revoke_not_active", func(t *testing.T) { handler, checkoutRepo, _ := setupCheckoutTest() checkoutRepo.checkouts["checkout-already-revoked"] = &domain.Checkout{ ID: "checkout-already-revoked", ProjectID: "test-project", Branch: "develop", GiteaTokenID: 12345, CloneURL: "https://git.threesix.ai/threesix/test-project.git", CheckedOutBy: "test", CheckedOutAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.CheckoutStatusRevoked, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/checkout-already-revoked", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) t.Run("revoke_not_found", func(t *testing.T) { handler, _, _ := setupCheckoutTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/nonexistent", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) }