package web import ( "context" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "backend/internal/auth" "backend/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" ) func newEtapeTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q} router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newEtapeTestRouter: " + err.Error()) } return router } func TestEtapeCreateRendersChipAndCount(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, "etapecreate@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Etape Create Tablo", Description: pgtype.Text{Valid: false}, Color: pgtype.Text{Valid: false}, }) if err != nil { t.Fatalf("InsertTablo: %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"}, "_csrf": {csrfToken}, } req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/etapes", 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 status = %d; want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{"Design", "Unassigned", "All"} { if !strings.Contains(body, want) { t.Errorf("response body missing %q; body: %.500s", want, body) } } } func TestTaskCreateAssignsEtape(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, "tasketapecreate@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Task Etape Create 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) } 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": {"Assigned Task"}, "status": {"todo"}, "etape_id": {etape.ID.String()}, "_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) } 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 != etape.ID { t.Fatalf("task etape_id = %+v; want %s", tasks[0].EtapeID, etape.ID) } } func TestEtapeFilterRendersExistingKanbanColumns(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, "etapefilter@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Etape Filter 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) } _, err = q.InsertTask(ctx, sqlc.InsertTaskParams{ TabloID: tablo.ID, Title: "Assigned Task", Description: pgtype.Text{Valid: false}, Status: sqlc.TaskStatusTodo, Position: 100, EtapeID: pgtype.UUID{Bytes: etape.ID, Valid: true}, }) if err != nil { t.Fatalf("InsertTask assigned: %v", err) } _, err = q.InsertTask(ctx, sqlc.InsertTaskParams{ TabloID: tablo.ID, Title: "Unassigned Task", Description: pgtype.Text{Valid: false}, Status: sqlc.TaskStatusTodo, Position: 200, EtapeID: pgtype.UUID{Valid: false}, }) if err != nil { t.Fatalf("InsertTask unassigned: %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} req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/tasks?etape="+etape.ID.String(), nil) req.Header.Set("HX-Request", "true") req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET /tablos/{id}/tasks?etape status = %d; want 200", rec.Code) } body := rec.Body.String() for _, col := range []string{"To do", "In progress", "In review", "Done"} { if !strings.Contains(body, col) { t.Errorf("filtered kanban missing column header %q", col) } } if !strings.Contains(body, "Assigned Task") { t.Errorf("filtered body missing assigned task; body: %.500s", body) } if strings.Contains(body, "Unassigned Task") { 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) } }) } }