test(04-01): add RED test scaffold and task form structs
- handlers_tasks_test.go: 9 TestTask* functions (TASK-01..07 + IDOR) all skip - TasksDeps stub struct declared in test file for Plan 02 wiring - tasks_forms.go: TaskCreateForm, TaskCreateErrors, TaskUpdateForm, TaskUpdateErrors structs - go build ./... passes; go test -run TestTask exits 0 with all 9 SKIP
This commit is contained in:
parent
c9c826247a
commit
8b9543db6f
2 changed files with 722 additions and 0 deletions
692
backend/internal/web/handlers_tasks_test.go
Normal file
692
backend/internal/web/handlers_tasks_test.go
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
package web
|
||||
|
||||
// handlers_tasks_test.go — Wave 0 RED test scaffold for TASK-01..07 + IDOR test.
|
||||
//
|
||||
// All test functions call t.Skip("handlers_tasks not yet implemented") so they
|
||||
// compile but do NOT fail before Plan 02 implements the handlers and routes.
|
||||
// This file is the RED baseline; Plan 02 turns it green.
|
||||
//
|
||||
// Pattern: mirrors handlers_tablos_test.go exactly — setupTestDB, loginUser,
|
||||
// preInsertUser, getCSRFToken, and testCSRFKey are reused from the existing
|
||||
// test files in the same package.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backend/internal/auth"
|
||||
"backend/internal/db/sqlc"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// TasksDeps holds dependencies for task handlers.
|
||||
// Stub declared here so tests compile; will be moved to handlers_tasks.go in Plan 02.
|
||||
type TasksDeps struct {
|
||||
Queries *sqlc.Queries
|
||||
}
|
||||
|
||||
// newTaskTestRouter builds a router with both TablosDeps and TasksDeps wired.
|
||||
// In Plan 02, TasksDeps will be passed to NewRouter; for now we pass only
|
||||
// TablosDeps (which is what NewRouter currently accepts) so tests compile.
|
||||
// The Task routes will be added to NewRouter in Plan 02.
|
||||
func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||
tabloDeps := TablosDeps{Queries: q}
|
||||
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, testCSRFKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// insertTestTablo is a helper that creates a tablo owned by the given user for
|
||||
// use in task tests.
|
||||
func insertTestTablo(t *testing.T, ctx context.Context, q *sqlc.Queries, userID interface{ GetID() interface{} }) sqlc.Tablo {
|
||||
t.Helper()
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
return sqlc.Tablo{}
|
||||
}
|
||||
|
||||
// ---- TestTasksKanbanRenders (TASK-01) ----
|
||||
|
||||
// TestTasksKanbanRenders verifies that GET /tablos/{id} by the owner renders
|
||||
// all four kanban column headers: Todo, In Progress, In Review, Done (TASK-01).
|
||||
func TestTasksKanbanRenders(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "kanban@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Kanban Test Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tablos/{id} status = %d; want 200", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, col := range []string{"Todo", "In Progress", "In Review", "Done"} {
|
||||
if !strings.Contains(body, col) {
|
||||
t.Errorf("kanban board missing column header %q", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskCreate (TASK-02) ----
|
||||
|
||||
// TestTaskCreate verifies that POST /tablos/{id}/tasks creates a task and
|
||||
// returns a 200 response with an HTMX card fragment (TASK-02).
|
||||
func TestTaskCreate(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskcreate@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Task Create Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
form := url.Values{
|
||||
"title": {"My Test Task"},
|
||||
"status": {"todo"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks", 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}/tasks status = %d; want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "My Test Task") {
|
||||
t.Errorf("response body missing task title; body: %.300s", rec.Body.String())
|
||||
}
|
||||
|
||||
// DB row must exist.
|
||||
tasks, err := q.ListTasksByTablo(ctx, tablo.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTablo: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("no task row in DB after create")
|
||||
}
|
||||
if tasks[0].Title != "My Test Task" {
|
||||
t.Errorf("DB task title = %q; want 'My Test Task'", tasks[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskCreateValidation (TASK-02) ----
|
||||
|
||||
// TestTaskCreateValidation verifies that POST /tablos/{id}/tasks with an empty
|
||||
// title returns 422 with an error message (TASK-02 validation).
|
||||
func TestTaskCreateValidation(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskcreatevalidation@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Task Validation Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
form := url.Values{
|
||||
"title": {""},
|
||||
"status": {"todo"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks", 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.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d; want 422", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Title is required") {
|
||||
t.Errorf("body missing 'Title is required'; got: %.300s", rec.Body.String())
|
||||
}
|
||||
|
||||
// No DB row should have been inserted.
|
||||
tasks, _ := q.ListTasksByTablo(ctx, tablo.ID)
|
||||
if len(tasks) != 0 {
|
||||
t.Errorf("task count = %d; want 0 (no insert on validation error)", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskUpdate (TASK-03) ----
|
||||
|
||||
// TestTaskUpdate verifies that POST /tablos/{id}/tasks/{task_id} updates the
|
||||
// task title and description and returns a card fragment (TASK-03).
|
||||
func TestTaskUpdate(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskupdate@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Task Update Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Original Task Title",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
form := url.Values{
|
||||
"title": {"Updated Task Title"},
|
||||
"description": {"Updated description"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/"+task.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}/tasks/{task_id} status = %d; want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Updated Task Title") {
|
||||
t.Errorf("response body missing updated title; body: %.300s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Verify DB state.
|
||||
updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskByID after update: %v", err)
|
||||
}
|
||||
if updated.Title != "Updated Task Title" {
|
||||
t.Errorf("DB task title = %q; want 'Updated Task Title'", updated.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskReorderCrossColumn (TASK-04) ----
|
||||
|
||||
// TestTaskReorderCrossColumn verifies that POST /tablos/{id}/tasks/reorder
|
||||
// changes the task's column (status) when dragged across columns (TASK-04).
|
||||
func TestTaskReorderCrossColumn(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskreordercross@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Reorder Cross Column Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Cross Column Task",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
// Move the task from "todo" to "in_progress".
|
||||
form := url.Values{
|
||||
"task_id": {task.ID.String()},
|
||||
"status": {"in_progress"},
|
||||
"_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 /tablos/{id}/tasks/reorder status = %d; want 200", rec.Code)
|
||||
}
|
||||
|
||||
// Verify DB: task status should have changed to in_progress.
|
||||
updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskByID after reorder: %v", err)
|
||||
}
|
||||
if updated.Status != sqlc.TaskStatusInProgress {
|
||||
t.Errorf("task status = %q; want 'in_progress'", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskReorderSameColumn (TASK-05) ----
|
||||
|
||||
// TestTaskReorderSameColumn verifies that POST /tablos/{id}/tasks/reorder
|
||||
// changes the position of a task within the same column (TASK-05).
|
||||
func TestTaskReorderSameColumn(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskreordersame@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Reorder Same Column Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
task1, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Task Position 100",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask task1: %v", err)
|
||||
}
|
||||
|
||||
task2, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Task Position 200",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask task2: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
// Move task1 after task2 within the same column.
|
||||
form := url.Values{
|
||||
"task_id": {task1.ID.String()},
|
||||
"status": {"todo"},
|
||||
"position": {"300"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
_ = task2 // referenced for test context
|
||||
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 /tablos/{id}/tasks/reorder status = %d; want 200", rec.Code)
|
||||
}
|
||||
|
||||
// Verify: task1 position updated.
|
||||
updated, err := q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task1.ID, TabloID: tablo.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskByID after same-column reorder: %v", err)
|
||||
}
|
||||
if updated.Position == 100 {
|
||||
t.Error("task position did not change after same-column reorder")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskDelete (TASK-06) ----
|
||||
|
||||
// TestTaskDelete verifies that POST /tablos/{id}/tasks/{task_id}/delete removes
|
||||
// the task and returns an empty div (TASK-06).
|
||||
func TestTaskDelete(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskdelete@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Task Delete Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Task To Delete",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
form := url.Values{"_csrf": {csrfToken}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/"+task.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 /tablos/{id}/tasks/{task_id}/delete status = %d; want 200", rec.Code)
|
||||
}
|
||||
|
||||
// DB row must be gone.
|
||||
_, err = q.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ID: task.ID, TabloID: tablo.ID})
|
||||
if err == nil {
|
||||
t.Error("task row still exists in DB after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskOrderPersists (TASK-07) ----
|
||||
|
||||
// TestTaskOrderPersists verifies that after a reorder, a subsequent GET
|
||||
// /tablos/{id} renders tasks in the new position order (TASK-07).
|
||||
func TestTaskOrderPersists(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
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, "taskorderpersists@example.com", "correct-horse-12")
|
||||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Task Order Persists Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
|
||||
task1, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Task First",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask task1: %v", err)
|
||||
}
|
||||
task2, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "Task Second",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask task2: %v", err)
|
||||
}
|
||||
|
||||
cookieVal, _, storeErr := store.Create(ctx, user.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String(), []*http.Cookie{sessionCookie})
|
||||
|
||||
// Reorder: move task2 to position 50 (before task1 at 100).
|
||||
form := url.Values{
|
||||
"task_id": {task2.ID.String()},
|
||||
"status": {"todo"},
|
||||
"position": {"50"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
reorderReq := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks/reorder", strings.NewReader(form.Encode()))
|
||||
reorderReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
reorderReq.Header.Set("HX-Request", "true")
|
||||
for _, c := range csrfCookies {
|
||||
reorderReq.AddCookie(c)
|
||||
}
|
||||
reorderRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(reorderRec, reorderReq)
|
||||
|
||||
if reorderRec.Code != http.StatusOK {
|
||||
t.Fatalf("reorder POST status = %d; want 200", reorderRec.Code)
|
||||
}
|
||||
|
||||
// Now fetch the tablo page and verify order.
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil)
|
||||
getReq.AddCookie(sessionCookie)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tablos/{id} status = %d; want 200", getRec.Code)
|
||||
}
|
||||
body := getRec.Body.String()
|
||||
|
||||
// task2 ("Task Second") should appear before task1 ("Task First") after reorder.
|
||||
idx1 := strings.Index(body, task1.Title)
|
||||
idx2 := strings.Index(body, task2.Title)
|
||||
if idx1 != -1 && idx2 != -1 && idx2 > idx1 {
|
||||
t.Errorf("task order not persisted: task2 index=%d, task1 index=%d (task2 should be first)", idx2, idx1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TestTaskOwnership (T-04-IDOR) ----
|
||||
|
||||
// TestTaskOwnership verifies that GET and POST task routes accessed by a
|
||||
// non-owner return 404 (no information leakage, D-04) (T-04-IDOR).
|
||||
func TestTaskOwnership(t *testing.T) {
|
||||
t.Skip("handlers_tasks not yet implemented")
|
||||
|
||||
pool, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
q := sqlc.New(pool)
|
||||
store := auth.NewStore(q)
|
||||
router := newTaskTestRouter(q, store)
|
||||
|
||||
// User A creates a tablo + task.
|
||||
userA := preInsertUser(t, ctx, q, "taskiodra@example.com", "correct-horse-12")
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: userA.ID,
|
||||
Title: "UserA Secret Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTablo: %v", err)
|
||||
}
|
||||
task, err := q.InsertTask(ctx, sqlc.InsertTaskParams{
|
||||
TabloID: tablo.ID,
|
||||
Title: "UserA Secret Task",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Status: sqlc.TaskStatusTodo,
|
||||
Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTask: %v", err)
|
||||
}
|
||||
|
||||
// User B tries to access userA's task routes.
|
||||
userB := preInsertUser(t, ctx, q, "taskiodorb@example.com", "correct-horse-12")
|
||||
cookieVal, _, storeErr := store.Create(ctx, userB.ID)
|
||||
if storeErr != nil {
|
||||
t.Fatalf("store.Create for userB: %v", storeErr)
|
||||
}
|
||||
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
|
||||
|
||||
routes := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/tablos/" + tablo.ID.String() + "/tasks/" + task.ID.String()},
|
||||
{http.MethodPost, "/tablos/" + tablo.ID.String() + "/tasks/" + task.ID.String() + "/delete"},
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
req := httptest.NewRequest(route.method, route.path, nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
// D-04: 404 (not 403) — no information leakage.
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s as non-owner: status = %d; want 404", route.method, route.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
backend/templates/tasks_forms.go
Normal file
30
backend/templates/tasks_forms.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package templates
|
||||
|
||||
// TaskCreateForm carries submitted field values back to the template for
|
||||
// repopulation on validation failure.
|
||||
type TaskCreateForm struct {
|
||||
Title string
|
||||
Status string // holds the column status value, e.g. "todo"
|
||||
}
|
||||
|
||||
// TaskCreateErrors holds per-field and general error messages for the task
|
||||
// create form. An empty string means "no error for this field".
|
||||
type TaskCreateErrors struct {
|
||||
Title string
|
||||
General string
|
||||
}
|
||||
|
||||
// TaskUpdateForm carries submitted field values back to the template for
|
||||
// repopulation on validation failure.
|
||||
type TaskUpdateForm struct {
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
// TaskUpdateErrors holds per-field and general error messages for the task
|
||||
// update/edit form. An empty string means "no error for this field".
|
||||
type TaskUpdateErrors struct {
|
||||
Title string
|
||||
Description string
|
||||
General string
|
||||
}
|
||||
Loading…
Reference in a new issue