package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" ) // testAdminAuth is a chi middleware that injects an admin API key into the // request context so auth.RequireScope passes in tests. func testAdminAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := auth.WithAPIKey(r.Context(), &domain.APIKey{ Scopes: []domain.Scope{domain.ScopeAdmin}, }) next.ServeHTTP(w, r.WithContext(ctx)) }) } // mockBuildAudit implements port.BuildAudit for testing. type mockBuildAudit struct { entries map[string]*domain.BuildAuditEntry err error } func newMockBuildAudit() *mockBuildAudit { return &mockBuildAudit{ entries: make(map[string]*domain.BuildAuditEntry), } } func (m *mockBuildAudit) Record(_ context.Context, entry *domain.BuildAuditEntry) error { if m.err != nil { return m.err } m.entries[entry.TaskID] = entry return nil } func (m *mockBuildAudit) Update(_ context.Context, taskID string, result *domain.BuildResult) error { if m.err != nil { return m.err } entry, ok := m.entries[taskID] if !ok { return domain.ErrBuildNotFound } entry.Result = result if result.Success { entry.Status = domain.BuildStatusCompleted } else { entry.Status = domain.BuildStatusFailed } now := time.Now() entry.CompletedAt = &now return nil } func (m *mockBuildAudit) Get(_ context.Context, taskID string) (*domain.BuildAuditEntry, error) { if m.err != nil { return nil, m.err } entry, ok := m.entries[taskID] if !ok { return nil, domain.ErrBuildNotFound } return entry, nil } func (m *mockBuildAudit) List(_ context.Context, filter port.BuildAuditFilter) ([]*domain.BuildAuditEntry, error) { if m.err != nil { return nil, m.err } var result []*domain.BuildAuditEntry for _, entry := range m.entries { if filter.ProjectID != "" && entry.ProjectID != filter.ProjectID { continue } result = append(result, entry) if filter.Limit > 0 && len(result) >= filter.Limit { break } } return result, nil } func TestBuildsHandler_StartBuild(t *testing.T) { queue := newMockWorkQueue() audit := newMockBuildAudit() buildService := service.NewBuildService(queue, audit, nil) handler := NewBuildsHandler(buildService) router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) tests := []struct { name string projectID string body StartBuildRequest wantStatus int }{ { name: "valid_build", projectID: "my-project", body: StartBuildRequest{ Prompt: "Build a landing page with Next.js", Template: "nextjs-landing", AutoCommit: true, AutoPush: true, }, wantStatus: http.StatusCreated, }, { name: "missing_prompt", projectID: "my-project", body: StartBuildRequest{ Template: "nextjs-landing", }, wantStatus: http.StatusBadRequest, }, { name: "minimal_build", projectID: "test-project", body: StartBuildRequest{ Prompt: "Add a footer component", }, wantStatus: http.StatusCreated, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/projects/"+tt.projectID+"/builds", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } if tt.wantStatus == http.StatusCreated { var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data to be map, got %T", resp["data"]) } if data["task_id"] == nil || data["task_id"] == "" { t.Error("expected task_id in response") } if data["project_id"] != tt.projectID { t.Errorf("got project_id=%v, want %s", data["project_id"], tt.projectID) } if data["status"] != "pending" { t.Errorf("got status=%v, want pending", data["status"]) } } }) } } func TestBuildsHandler_GetBuild(t *testing.T) { queue := newMockWorkQueue() audit := newMockBuildAudit() buildService := service.NewBuildService(queue, audit, nil) handler := NewBuildsHandler(buildService) // Pre-populate an audit entry audit.entries["task-1"] = &domain.BuildAuditEntry{ TaskID: "task-1", ProjectID: "my-project", WorkerID: "worker-1", Spec: domain.BuildSpec{ Prompt: "Build landing page", Template: "nextjs-landing", }, Status: domain.BuildStatusCompleted, StartedAt: time.Now().Add(-5 * time.Minute), Result: &domain.BuildResult{ Success: true, CommitSHA: "abc123", DurationMs: 30000, }, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) tests := []struct { name string taskID string wantStatus int }{ { name: "existing_build", taskID: "task-1", wantStatus: http.StatusOK, }, { name: "not_found", taskID: "nonexistent", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/builds/"+tt.taskID, nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } if tt.wantStatus == http.StatusOK { var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data to be map, got %T", resp["data"]) } if data["task_id"] != "task-1" { t.Errorf("got task_id=%v, want task-1", data["task_id"]) } if data["status"] != "completed" { t.Errorf("got status=%v, want completed", data["status"]) } } }) } } func TestBuildsHandler_ListBuilds(t *testing.T) { queue := newMockWorkQueue() audit := newMockBuildAudit() buildService := service.NewBuildService(queue, audit, nil) handler := NewBuildsHandler(buildService) // Pre-populate audit entries audit.entries["task-1"] = &domain.BuildAuditEntry{ TaskID: "task-1", ProjectID: "project-a", Status: domain.BuildStatusCompleted, Spec: domain.BuildSpec{Prompt: "Build page"}, StartedAt: time.Now().Add(-10 * time.Minute), } audit.entries["task-2"] = &domain.BuildAuditEntry{ TaskID: "task-2", ProjectID: "project-a", Status: domain.BuildStatusRunning, Spec: domain.BuildSpec{Prompt: "Add footer"}, StartedAt: time.Now().Add(-5 * time.Minute), } audit.entries["task-3"] = &domain.BuildAuditEntry{ TaskID: "task-3", ProjectID: "project-b", Status: domain.BuildStatusPending, Spec: domain.BuildSpec{Prompt: "Other project"}, StartedAt: time.Now(), } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) t.Run("list_builds_for_project", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/project-a/builds", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("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("failed to unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data to be map, got %T", resp["data"]) } totalF, ok := data["total"].(float64) if !ok { t.Fatalf("expected total to be float64, got %T", data["total"]) } if int(totalF) != 2 { t.Errorf("got total=%d, want 2", int(totalF)) } if data["project_id"] != "project-a" { t.Errorf("got project_id=%v, want project-a", data["project_id"]) } }) t.Run("list_with_limit", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/project-a/builds?limit=1", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data to be map, got %T", resp["data"]) } totalF, ok := data["total"].(float64) if !ok { t.Fatalf("expected total to be float64, got %T", data["total"]) } if int(totalF) != 1 { t.Errorf("got total=%d, want 1 (limited)", int(totalF)) } }) t.Run("invalid_limit", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/project-a/builds?limit=abc", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) } }) }