diff --git a/backend/internal/web/handlers_etapes_test.go b/backend/internal/web/handlers_etapes_test.go index 62680c9..8ce27d0 100644 --- a/backend/internal/web/handlers_etapes_test.go +++ b/backend/internal/web/handlers_etapes_test.go @@ -223,3 +223,283 @@ func TestEtapeFilterRendersExistingKanbanColumns(t *testing.T) { t.Errorf("filtered body includes unassigned task; body: %.500s", body) } } + +func TestEtapeUpdateChangesTitleAndDescription(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEtapeTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "etapeupdate@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Etape Update Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + etape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Draft", + Description: pgtype.Text{String: "Old", Valid: true}, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertEtape: %v", err) + } + + cookieVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/tasks", []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"Design"}, + "description": {"Ready for build"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/etapes/"+etape.ID.String(), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /tablos/{id}/etapes/{etape_id} status = %d; want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Design") { + t.Fatalf("response body missing updated title; body: %.500s", body) + } + updated, err := q.GetEtapeByID(ctx, sqlc.GetEtapeByIDParams{ID: etape.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetEtapeByID: %v", err) + } + if updated.Title != "Design" || !updated.Description.Valid || updated.Description.String != "Ready for build" { + t.Fatalf("updated etape = %+v; want new title and description", updated) + } +} + +func TestEtapeDeleteUnassignsTasks(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEtapeTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "etapedelete@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Etape Delete Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + etape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Design", + Description: pgtype.Text{Valid: false}, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertEtape: %v", err) + } + task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Survives", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + EtapeID: pgtype.UUID{Bytes: etape.ID, Valid: true}, + }) + if err != nil { + t.Fatalf("InsertTask: %v", err) + } + + cookieVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/tasks", []*http.Cookie{sessionCookie}) + + form := url.Values{"_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/etapes/"+etape.ID.String()+"/delete", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST delete etape status = %d; want 200", rec.Code) + } + remaining, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetTaskByID after etape delete: %v", err) + } + if remaining.EtapeID.Valid { + t.Fatalf("task etape_id valid after etape delete; want null") + } +} + +func TestEtapeReorderPersistsPosition(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEtapeTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "etapereorder@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Etape Reorder Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + first, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "First", + Description: pgtype.Text{Valid: false}, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertEtape first: %v", err) + } + second, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Second", + Description: pgtype.Text{Valid: false}, + Position: 200, + }) + if err != nil { + t.Fatalf("InsertEtape second: %v", err) + } + + cookieVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/tasks", []*http.Cookie{sessionCookie}) + + form := url.Values{ + "etape_id": {second.ID.String(), first.ID.String()}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/etapes/reorder", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST reorder etapes status = %d; want 200", rec.Code) + } + etapes, err := q.ListEtapesByTablo(ctx, tablo.ID) + if err != nil { + t.Fatalf("ListEtapesByTablo: %v", err) + } + if len(etapes) != 2 || etapes[0].ID != second.ID || etapes[1].ID != first.ID { + t.Fatalf("etape order = %+v; want second then first", etapes) + } +} + +func TestEtapeOwnershipReturns404(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEtapeTestRouter(q, store) + + owner := preInsertUser(t, ctx, q, "etapeowner@example.com", "correct-horse-12") + nonOwner := preInsertUser(t, ctx, q, "etapenonowner@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: owner.ID, + Title: "Owned Elsewhere", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + etape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Private", + Description: pgtype.Text{Valid: false}, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertEtape: %v", err) + } + + cookieVal, _, err := store.Create(ctx, nonOwner.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + csrfToken, csrfCookies := getCSRFToken(t, router, "/", []*http.Cookie{sessionCookie}) + + cases := []struct { + name string + path string + form url.Values + }{ + { + name: "update", + path: "/tablos/" + tablo.ID.String() + "/etapes/" + etape.ID.String(), + form: url.Values{"title": {"Nope"}, "_csrf": {csrfToken}}, + }, + { + name: "delete", + path: "/tablos/" + tablo.ID.String() + "/etapes/" + etape.ID.String() + "/delete", + form: url.Values{"_csrf": {csrfToken}}, + }, + { + name: "reorder", + path: "/tablos/" + tablo.ID.String() + "/etapes/reorder", + form: url.Values{"etape_id": {etape.ID.String()}, "_csrf": {csrfToken}}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("%s status = %d; want 404", tc.name, rec.Code) + } + }) + } +}