package handlers import ( "net/http" "net/http/httptest" "testing" "task-dashboard/internal/models" ) func TestBuildChainSummaries_ReturnsPositionAndCurrentTask(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() chain, err := db.CreateChain("Track", []models.ChainTaskInput{ {Content: "Step 1"}, {Content: "Step 2"}, {Content: "Step 3"}, }) if err != nil { t.Fatal(err) } summaries, err := BuildChainSummaries(db) if err != nil { t.Fatalf("BuildChainSummaries: %v", err) } if len(summaries) != 1 { t.Fatalf("len(summaries) = %d, want 1", len(summaries)) } s := summaries[0] if s.Chain.ID != chain.ID || s.ProjectName != "Track" { t.Errorf("summary = %+v", s) } if s.Total != 3 || s.Position != 1 { t.Errorf("Position/Total = %d/%d, want 1/3", s.Position, s.Total) } if s.CurrentTask == nil || s.CurrentTask.Content != "Step 1" { t.Errorf("CurrentTask = %+v, want Step 1", s.CurrentTask) } } func TestBuildChainSummaries_ExcludesAbandonedAndCompleted(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() chain, err := db.CreateChain("Track", []models.ChainTaskInput{{Content: "Only step"}}) if err != nil { t.Fatal(err) } if err := db.SetChainStatus(chain.ID, "abandoned"); err != nil { t.Fatal(err) } summaries, err := BuildChainSummaries(db) if err != nil { t.Fatal(err) } if len(summaries) != 0 { t.Errorf("summaries = %+v, want none (abandoned chain excluded)", summaries) } } func TestHandleChainDetailView_RendersFullOrderedList(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() chain, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}}) if err != nil { t.Fatal(err) } req := withURLParam(httptest.NewRequest("GET", "/chains/"+chain.ID, nil), "id", chain.ID) w := httptest.NewRecorder() h.HandleChainDetailView(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) } mock := h.renderer.(*MockRenderer) if len(mock.Calls) == 0 { t.Fatal("expected renderer to be called") } lastCall := mock.Calls[len(mock.Calls)-1] if lastCall.Name != "chain-detail" { t.Errorf("template = %q, want chain-detail", lastCall.Name) } data, ok := lastCall.Data.(struct { Chain models.Chain ProjectName string Tasks []models.Task }) if !ok { t.Fatalf("unexpected data type %T", lastCall.Data) } if data.ProjectName != "Track" || len(data.Tasks) != 2 { t.Errorf("data = %+v", data) } if data.Tasks[0].Content != "Step 1" || data.Tasks[1].Content != "Step 2" { t.Errorf("tasks out of order: %+v", data.Tasks) } } func TestHandleChainDetailView_UnknownID_Returns404(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} req := withURLParam(httptest.NewRequest("GET", "/chains/nope", nil), "id", "nope") w := httptest.NewRecorder() h.HandleChainDetailView(w, req) if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } } func TestHandleChainPauseResumeAbandon_Web(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} chain, err := db.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}}) if err != nil { t.Fatal(err) } req := withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/pause", nil), "id", chain.ID) w := httptest.NewRecorder() h.HandleChainPause(w, req) if w.Code != http.StatusOK { t.Fatalf("pause status = %d, want 200", w.Code) } if w.Header().Get("HX-Trigger") != "refresh-tasks" { t.Errorf("HX-Trigger = %q, want refresh-tasks", w.Header().Get("HX-Trigger")) } paused, err := db.GetChain(chain.ID) if err != nil || paused.Status != "paused" { t.Fatalf("chain after pause = %+v, err=%v", paused, err) } req = withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/resume", nil), "id", chain.ID) w = httptest.NewRecorder() h.HandleChainResume(w, req) if w.Code != http.StatusOK { t.Fatalf("resume status = %d, want 200", w.Code) } req = withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/abandon", nil), "id", chain.ID) w = httptest.NewRecorder() h.HandleChainAbandon(w, req) if w.Code != http.StatusOK { t.Fatalf("abandon status = %d, want 200", w.Code) } abandoned, err := db.GetChain(chain.ID) if err != nil || abandoned.Status != "abandoned" { t.Fatalf("chain after abandon = %+v, err=%v", abandoned, err) } } func TestHandleChainPause_UnknownID_Returns404(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} req := withURLParam(httptest.NewRequest("POST", "/chains/nope/pause", nil), "id", "nope") w := httptest.NewRecorder() h.HandleChainPause(w, req) if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } } func TestHandleTabTasks_IncludesChainSummaries(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() if _, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}}); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/tabs/tasks", nil) w := httptest.NewRecorder() h.HandleTabTasks(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) } mock := h.renderer.(*MockRenderer) lastCall := mock.Calls[len(mock.Calls)-1] if lastCall.Name != "tasks-tab" { t.Errorf("template = %q, want tasks-tab", lastCall.Name) } data, ok := lastCall.Data.(struct { Atoms []models.Atom FutureAtoms []models.Atom Boards []models.Board Chains []models.ChainSummary Buckets []models.BucketSummary Projects []models.Project Labels []models.LabelColor Today string }) if !ok { t.Fatalf("unexpected data type %T", lastCall.Data) } if len(data.Chains) != 1 || data.Chains[0].CurrentTask == nil || data.Chains[0].CurrentTask.Content != "Step 1" { t.Errorf("Chains = %+v", data.Chains) } for _, a := range data.Atoms { if a.Title == "Step 1" { t.Error("chain task should not also appear in the flat Atoms list") } } }