diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index a5c200b..1c80b30 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -688,6 +688,174 @@ func TestTaskReorderCrossColumn(t *testing.T) { } } +func TestTaskReorderPreservesEtapeAssignment(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "reorderpreserveetape@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Reorder Preserve Etape", + 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: "Move Me", + 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(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "task_id": {task.ID.String()}, + "status": {"done"}, + "position": {"100"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/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 status = %d; want 200", rec.Code) + } + updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetTaskByID: %v", err) + } + if !updated.EtapeID.Valid || updated.EtapeID.Bytes != etape.ID { + t.Fatalf("etape assignment changed after reorder: %+v", updated.EtapeID) + } +} + +func TestFilteredTaskReorderPreservesHiddenTasks(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "filteredreorderhidden@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Filtered Reorder", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + firstEtape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Design", + Description: pgtype.Text{Valid: false}, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertEtape first: %v", err) + } + secondEtape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Build", + Description: pgtype.Text{Valid: false}, + Position: 200, + }) + if err != nil { + t.Fatalf("InsertEtape second: %v", err) + } + visible, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Visible", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + EtapeID: pgtype.UUID{Bytes: firstEtape.ID, Valid: true}, + }) + if err != nil { + t.Fatalf("InsertTask visible: %v", err) + } + hidden, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Hidden", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 200, + EtapeID: pgtype.UUID{Bytes: secondEtape.ID, Valid: true}, + }) + if err != nil { + t.Fatalf("InsertTask hidden: %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?etape="+firstEtape.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "task_id": {visible.ID.String()}, + "status": {"in_progress"}, + "position": {"100"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/reorder?etape="+firstEtape.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 filtered reorder status = %d; want 200", rec.Code) + } + hiddenAfter, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: hidden.ID, TabloID: tablo.ID}) + if err != nil { + t.Fatalf("GetTaskByID hidden: %v", err) + } + if !hiddenAfter.EtapeID.Valid || hiddenAfter.EtapeID.Bytes != secondEtape.ID { + t.Fatalf("hidden task etape changed: %+v", hiddenAfter.EtapeID) + } +} + // ---- TestTaskReorderSameColumn (TASK-05) ---- // TestTaskReorderSameColumn verifies that POST /tablos/{id}/tasks/reorder @@ -931,6 +1099,101 @@ func TestTaskOrderPersists(t *testing.T) { } } +func TestTaskOrderPersistsWithEtapeFilter(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTaskTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "orderfilteretape@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Order Filter", + 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) + } + first, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "First Filtered", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 100, + EtapeID: pgtype.UUID{Bytes: etape.ID, Valid: true}, + }) + if err != nil { + t.Fatalf("InsertTask first: %v", err) + } + second, err := q.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: "Second Filtered", + Description: pgtype.Text{Valid: false}, + Status: sqlc.TaskStatusTodo, + Position: 200, + EtapeID: pgtype.UUID{Bytes: etape.ID, Valid: true}, + }) + if err != nil { + t.Fatalf("InsertTask 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?etape="+etape.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "task_id": {second.ID.String()}, + "status": {"todo"}, + "position": {"50"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/reorder?etape="+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("reorder status = %d; want 200", rec.Code) + } + + getReq := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/tasks?etape="+etape.ID.String(), nil) + getReq.Header.Set("HX-Request", "true") + getReq.AddCookie(sessionCookie) + getRec := httptest.NewRecorder() + router.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("filtered GET status = %d; want 200", getRec.Code) + } + body := getRec.Body.String() + idxFirst := strings.Index(body, first.Title) + idxSecond := strings.Index(body, second.Title) + if idxFirst == -1 || idxSecond == -1 { + t.Fatalf("filtered body missing task titles; body: %.500s", body) + } + if idxSecond > idxFirst { + t.Fatalf("filtered order did not persist: second index=%d first index=%d", idxSecond, idxFirst) + } +} + // ---- TestTaskOwnership (T-04-IDOR) ---- // TestTaskOwnership verifies that GET and POST task routes accessed by a