diff --git a/.planning/phases/09-etapes/09-UAT.md b/.planning/phases/09-etapes/09-UAT.md index fec8cd2..b819bfb 100644 --- a/.planning/phases/09-etapes/09-UAT.md +++ b/.planning/phases/09-etapes/09-UAT.md @@ -1,5 +1,5 @@ --- -status: complete +status: diagnosed phase: 09-etapes source: - 09-01-SUMMARY.md @@ -7,7 +7,7 @@ source: - 09-03-SUMMARY.md - 09-04-PLAN.md started: 2026-05-15T21:38:18Z -updated: 2026-05-15T21:44:16Z +updated: 2026-05-15T21:46:09Z --- ## Current Test @@ -54,7 +54,16 @@ blocked: 0 reason: "User reported: even when selecting the etape, the created task ends up in the current etape viewed in the etape search param" severity: major test: 2 - root_cause: "" - artifacts: [] - missing: [] - debug_session: "" + root_cause: "The task create form rendered a hidden etape_id for the active filter before the visible Etape selector. TaskCreateHandler used PostFormValue(\"etape_id\"), which returns the first submitted value, so the active filter could override the user's selected etape." + artifacts: + - path: "backend/templates/tasks.templ" + issue: "TaskCreateFormFragment emitted duplicate etape_id controls." + - path: "backend/internal/web/handlers_tasks.go" + issue: "TaskCreateHandler read the first etape_id value from duplicated form fields." + - path: "backend/internal/web/handlers_tasks_test.go" + issue: "No regression covered selected etape winning over the active filter." + missing: + - "Remove the hidden etape_id input from the create form." + - "Read the last submitted etape_id defensively when duplicate values are present." + - "Add a regression for creating a task while filtered to one etape but selecting another." + debug_session: "inline:$gsd-verify-work-09/test-2" diff --git a/backend/internal/web/handlers_tasks.go b/backend/internal/web/handlers_tasks.go index e4e3db8..3734180 100644 --- a/backend/internal/web/handlers_tasks.go +++ b/backend/internal/web/handlers_tasks.go @@ -174,7 +174,8 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { title := strings.TrimSpace(r.PostFormValue("title")) statusStr := r.PostFormValue("status") status := parseTaskStatus(statusStr) - etapeID, _, err := parseOwnedEtapeID(r, deps.Queries, tablo.ID, r.PostFormValue("etape_id")) + submittedEtapeID := lastPostFormValue(r, "etape_id") + etapeID, _, err := parseOwnedEtapeID(r, deps.Queries, tablo.ID, submittedEtapeID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.NotFound(w, r) @@ -210,7 +211,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID}, errs, csrf.Token(r), filter, @@ -236,7 +237,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID}, errs, csrf.Token(r), filter, @@ -259,7 +260,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID}, errs, csrf.Token(r), filter, @@ -288,7 +289,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID}, errs, csrf.Token(r), filter, @@ -316,6 +317,14 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { } } +func lastPostFormValue(r *http.Request, key string) string { + values := r.PostForm[key] + if len(values) == 0 { + return "" + } + return values[len(values)-1] +} + // TaskShowHandler handles GET /tablos/{id}/tasks/{task_id}/show. // Returns the TaskCard fragment — used by the cancel paths after edit or delete-confirm. func TaskShowHandler(deps TasksDeps) http.HandlerFunc { diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 9ff3d38..ad403df 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -230,6 +230,83 @@ func TestTaskCreateRefreshesEtapeCounts(t *testing.T) { } } +func TestTaskCreateUsesSelectedEtapeOverActiveFilter(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, "taskcreateselector@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Task Selected Etape Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + filterEtape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Filtered", + Description: pgtype.Text{Valid: false}, + Position: 100, + }) + if err != nil { + t.Fatalf("InsertEtape filter: %v", err) + } + selectedEtape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: "Selected", + Description: pgtype.Text{Valid: false}, + Position: 200, + }) + if err != nil { + t.Fatalf("InsertEtape selected: %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="+filterEtape.ID.String(), []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"Selected Wins"}, + "status": {"todo"}, + // Mirrors browser order from the rendered form before this regression: + // hidden active-filter etape_id first, visible selector value second. + "etape_id": {filterEtape.ID.String(), selectedEtape.ID.String()}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks?etape="+filterEtape.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 task create status = %d; want 200", rec.Code) + } + tasks, err := q.ListTasksByTablo(ctx, tablo.ID) + if err != nil { + t.Fatalf("ListTasksByTablo: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("task count = %d; want 1", len(tasks)) + } + if !tasks[0].EtapeID.Valid || tasks[0].EtapeID.Bytes != selectedEtape.ID { + t.Fatalf("task etape_id = %+v; want selected etape %s", tasks[0].EtapeID, selectedEtape.ID) + } +} + // ---- TestTaskCreateValidation (TASK-02) ---- // TestTaskCreateValidation verifies that POST /tablos/{id}/tasks with an empty diff --git a/backend/templates/tasks.templ b/backend/templates/tasks.templ index a350a8e..6f8b43e 100644 --- a/backend/templates/tasks.templ +++ b/backend/templates/tasks.templ @@ -263,7 +263,6 @@ templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form Tas class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2" > - @ui.CSRFField(csrfToken)