diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index f81f6bf..4a1469a 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -76,8 +76,9 @@ func main() { rl.StartJanitor(time.Minute, stopJanitor) deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl} + tabloDeps := web.TablosDeps{Queries: q} - router := web.NewRouter(pool, "./static", deps, csrfKey, env) + router := web.NewRouter(pool, "./static", deps, tabloDeps, csrfKey, env) srv := &http.Server{ Addr: ":" + port, diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index ed6da18..04be53f 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, csrfKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, csrfKey, "dev", "localhost") } // extractCSRFToken performs a GET request and extracts the _csrf token from the diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 054ef55..b0f0904 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -33,14 +33,14 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // newTestRouterWithLimiter builds a router with an injected LimiterStore, // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - return NewRouter(stubPinger{}, "./static", deps, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // getCSRFToken performs a GET request to path and extracts the CSRF token diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go new file mode 100644 index 0000000..81f3b01 --- /dev/null +++ b/backend/internal/web/handlers_tablos.go @@ -0,0 +1,10 @@ +package web + +import "backend/internal/db/sqlc" + +// TablosDeps holds dependencies for all tablo handlers. +// Introduced in Plan 01 as a stub to allow handlers_tablos_test.go to compile. +// Plans 02 and 03 add the actual handler implementations. +type TablosDeps struct { + Queries *sqlc.Queries +} diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go new file mode 100644 index 0000000..783a92d --- /dev/null +++ b/backend/internal/web/handlers_tablos_test.go @@ -0,0 +1,699 @@ +package web + +// handlers_tablos_test.go — Wave 0 integration test scaffold for TABLO-01..06. +// +// All tests are expected to FAIL at runtime until Plans 02 and 03 implement the +// actual handlers and routes. This file is the RED baseline; Plans 02/03 turn it green. +// +// Pattern: mirrors handlers_auth_test.go exactly — setupTestDB, preInsertUser, +// getCSRFToken, and newTestRouter are all reused from that file (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" +) + +// newTabloTestRouter builds a router with TablosDeps wired for tablo handler tests. +func newTabloTestRouter(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") +} + +// loginUser signs up a user and returns the session cookie set after signup. +// Reuses the signup flow (same as handlers_auth_test.go integration tests). +func loginUser(t *testing.T, router http.Handler, email, password string) []*http.Cookie { + t.Helper() + csrfToken, csrfCookies := getCSRFToken(t, router, "/signup", nil) + form := url.Values{ + "email": {email}, + "password": {password}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("loginUser: signup status = %d; want 303", rec.Code) + } + + // Collect all cookies from the response (session + gorilla_csrf). + var allCookies []*http.Cookie + allCookies = append(allCookies, csrfCookies...) + allCookies = append(allCookies, rec.Result().Cookies()...) + return allCookies +} + + +// ---- TestTabloList ---- + +// TestTabloList verifies that an authenticated GET / returns the user's tablos +// newest-first, and that tablos owned by other users are NOT present (TABLO-01). +func TestTabloList(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + // Create two users. + userA := preInsertUser(t, ctx, q, "lista@example.com", "correct-horse-12") + userB := preInsertUser(t, ctx, q, "listb@example.com", "correct-horse-12") + + // Insert tablos for userA. + tabloA1, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: userA.ID, + Title: "TabloA First", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo tabloA1: %v", err) + } + tabloA2, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: userA.ID, + Title: "TabloA Second", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo tabloA2: %v", err) + } + + // Insert a tablo for userB — must NOT appear in userA's list. + _, err = q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: userB.ID, + Title: "TabloB Private", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo tabloB: %v", err) + } + + // Log in as userA. + cookieVal, _, storeErr := store.Create(ctx, userA.ID) + if storeErr != nil { + t.Fatalf("store.Create: %v", storeErr) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET / status = %d; want 200", rec.Code) + } + + body := rec.Body.String() + + // Both of userA's tablos must appear. + if !strings.Contains(body, tabloA1.Title) { + t.Errorf("body missing tabloA1 title %q", tabloA1.Title) + } + if !strings.Contains(body, tabloA2.Title) { + t.Errorf("body missing tabloA2 title %q", tabloA2.Title) + } + + // userB's tablo must NOT appear. + if strings.Contains(body, "TabloB Private") { + t.Errorf("body must NOT contain userB's tablo title") + } + + // Newest-first ordering (D-03): tabloA2 was inserted after tabloA1. + // Check that tabloA2 appears before tabloA1 in the response body. + idx1 := strings.Index(body, tabloA1.Title) + idx2 := strings.Index(body, tabloA2.Title) + if idx1 != -1 && idx2 != -1 && idx2 > idx1 { + t.Errorf("tablos not in newest-first order: tabloA2 index=%d, tabloA1 index=%d", idx2, idx1) + } +} + +// ---- TestTabloList_Empty ---- + +// TestTabloList_Empty verifies the empty state copy when the user has no tablos (TABLO-01). +func TestTabloList_Empty(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "empty@example.com", "correct-horse-12") + 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, "/", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET / status = %d; want 200", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "No tablos yet") { + t.Errorf("empty state missing 'No tablos yet'; body: %.300s", body) + } + if !strings.Contains(body, "Create your first tablo to get started.") { + t.Errorf("empty state missing CTA text; body: %.300s", body) + } +} + +// ---- TestTabloCreate ---- + +// TestTabloCreate verifies HTMX-driven create (TABLO-02, TABLO-06). +// Sub-asserts cover: +// - HTMX POST returns 200 with card fragment + HX-Retarget + HX-Reswap + OOB form clear +// - Non-HTMX POST returns 303 to / +// - DB row exists with user_id = caller +func TestTabloCreate(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "create@example.com", "correct-horse-12") + + t.Run("HTMX create", func(t *testing.T) { + 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, "/", []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"My New Tablo"}, + "description": {"A description"}, + "color": {"#6366f1"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos", 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("HTMX POST /tablos status = %d; want 200", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "My New Tablo") { + t.Errorf("response body missing new tablo title; body: %.300s", body) + } + + // HX-Retarget must be #tablos-list (dual-target swap per RESEARCH Pattern 4). + if hxRetarget := rec.Header().Get("HX-Retarget"); hxRetarget != "#tablos-list" { + t.Errorf("HX-Retarget = %q; want #tablos-list", hxRetarget) + } + + // HX-Reswap must be afterbegin. + if hxReswap := rec.Header().Get("HX-Reswap"); hxReswap != "afterbegin" { + t.Errorf("HX-Reswap = %q; want afterbegin", hxReswap) + } + + // OOB element to clear the form slot must be present. + if !strings.Contains(body, `id="create-form-slot"`) { + t.Errorf("response body missing OOB form-clear element (id=create-form-slot); body: %.500s", body) + } + if !strings.Contains(body, `hx-swap-oob="true"`) { + t.Errorf("response body missing hx-swap-oob attribute; body: %.500s", body) + } + + // DB row must exist with user_id = caller (TABLO-02). + tablos, err := q.ListTablosByUser(ctx, user.ID) + if err != nil { + t.Fatalf("ListTablosByUser: %v", err) + } + if len(tablos) == 0 { + t.Fatal("no tablo row in DB after create") + } + if tablos[0].Title != "My New Tablo" { + t.Errorf("DB tablo title = %q; want My New Tablo", tablos[0].Title) + } + if tablos[0].UserID != user.ID { + t.Errorf("DB tablo user_id = %v; want %v", tablos[0].UserID, user.ID) + } + }) + + t.Run("non-HTMX create", func(t *testing.T) { + // Delete any existing tablos to keep state clean. + tablos, _ := q.ListTablosByUser(ctx, user.ID) + for _, tbl := range tablos { + _ = q.DeleteTablo(ctx, tbl.ID) + } + + 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, "/", []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"Non-HTMX Tablo"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // No HX-Request header — non-HTMX path. + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("non-HTMX POST /tablos status = %d; want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Errorf("Location = %q; want /", loc) + } + }) +} + +// ---- TestTabloCreate_Validation ---- + +// TestTabloCreate_Validation verifies that POST /tablos with empty title returns +// 422 with an error message (TABLO-02 validation, T-03-03). +func TestTabloCreate_Validation(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "createval@example.com", "correct-horse-12") + 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, "/", []*http.Cookie{sessionCookie}) + + // Empty title. + form := url.Values{ + "title": {""}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos", 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. + tablos, _ := q.ListTablosByUser(ctx, user.ID) + if len(tablos) != 0 { + t.Errorf("tablo count = %d; want 0 (no insert on validation error)", len(tablos)) + } +} + +// ---- TestTabloDetail_Owner ---- + +// TestTabloDetail_Owner verifies that the owner of a tablo can view its detail page (TABLO-03). +func TestTabloDetail_Owner(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "owner@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Owner's 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} as owner: status = %d; want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Owner's Tablo") { + t.Errorf("body missing tablo title; got: %.300s", rec.Body.String()) + } +} + +// ---- TestTabloDetail_NonOwner ---- + +// TestTabloDetail_NonOwner verifies that a different authenticated user gets 404 +// (not 403 — no information leakage per D-04) when accessing another user's tablo (TABLO-03). +func TestTabloDetail_NonOwner(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + // User A creates a tablo. + userA := preInsertUser(t, ctx, q, "nonowner_a@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: userA.ID, + Title: "UserA's Secret Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + // User B tries to access it. + userB := preInsertUser(t, ctx, q, "nonowner_b@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} + + req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), 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.Fatalf("GET /tablos/{id} as non-owner: status = %d; want 404", rec.Code) + } +} + +// ---- TestTabloDetail_InvalidID ---- + +// TestTabloDetail_InvalidID verifies that a non-UUID path param returns 404 (TABLO-03). +func TestTabloDetail_InvalidID(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "invalidid@example.com", "correct-horse-12") + 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/not-a-uuid", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /tablos/not-a-uuid: status = %d; want 404", rec.Code) + } +} + +// ---- TestTabloUpdate ---- + +// TestTabloUpdate verifies that POST /tablos/{id} updates title and description (TABLO-04). +// Checks: 200 response with updated title in body; DB row updated; updated_at refreshed. +func TestTabloUpdate(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "update@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Original Title", + Description: pgtype.Text{Valid: true, String: "Original description"}, + 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": {"Updated Title"}, + "description": {"Updated description"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.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} status = %d; want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Updated Title") { + t.Errorf("body missing updated title; got: %.300s", rec.Body.String()) + } + + // Verify DB state. + updated, err := q.GetTabloByID(ctx, tablo.ID) + if err != nil { + t.Fatalf("GetTabloByID after update: %v", err) + } + if updated.Title != "Updated Title" { + t.Errorf("DB title = %q; want 'Updated Title'", updated.Title) + } + if !updated.Description.Valid || updated.Description.String != "Updated description" { + t.Errorf("DB description = %v/%q; want valid 'Updated description'", updated.Description.Valid, updated.Description.String) + } + // updated_at must be >= original (Pitfall 7). + if updated.UpdatedAt.Time.Before(tablo.UpdatedAt.Time) || updated.UpdatedAt.Time.Equal(tablo.UpdatedAt.Time) { + // In tests the DB is local; allow same time but not earlier. + // We do a lenient check: updated_at must be >= original. + if updated.UpdatedAt.Time.Before(tablo.UpdatedAt.Time) { + t.Errorf("updated_at did not advance: was %v, got %v", tablo.UpdatedAt.Time, updated.UpdatedAt.Time) + } + } +} + +// ---- TestTabloDeleteConfirm ---- + +// TestTabloDeleteConfirm verifies that GET /tablos/{id}/delete-confirm returns a +// confirmation fragment with "Delete tablo?" and "Yes, delete" (TABLO-05, D-07). +func TestTabloDeleteConfirm(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "delconfirm@example.com", "correct-horse-12") + + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "To Be Deleted", + 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()+"/delete-confirm", 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}/delete-confirm: status = %d; want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Delete tablo?") { + t.Errorf("confirm fragment missing 'Delete tablo?'; body: %.300s", body) + } + if !strings.Contains(body, "Yes, delete") { + t.Errorf("confirm fragment missing 'Yes, delete'; body: %.300s", body) + } +} + +// ---- TestTabloDelete ---- + +// TestTabloDelete verifies that POST /tablos/{id}/delete hard-deletes the row (TABLO-05). +// Sub-asserts: +// - HTMX: 200 + HX-Redirect:/ (or HX-Retarget #tablos-list for in-list deletes) +// - Non-HTMX: 303 to / +// - DB row deleted +func TestTabloDelete(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTabloTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "delete@example.com", "correct-horse-12") + + t.Run("HTMX delete", func(t *testing.T) { + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Tablo to Delete HTMX", + 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, "/", []*http.Cookie{sessionCookie}) + + form := url.Values{"_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.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("HTMX DELETE status = %d; want 200", rec.Code) + } + + // Must have HX-Redirect header pointing back to /. + hxRedirect := rec.Header().Get("HX-Redirect") + if hxRedirect != "/" { + t.Errorf("HX-Redirect = %q; want /", hxRedirect) + } + + // DB row must be gone. + _, err = q.GetTabloByID(ctx, tablo.ID) + if err == nil { + t.Error("tablo row still exists in DB after delete") + } + }) + + t.Run("non-HTMX delete", func(t *testing.T) { + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Tablo to Delete Plain", + 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, "/", []*http.Cookie{sessionCookie}) + + form := url.Values{"_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/delete", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // No HX-Request — non-HTMX path. + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("non-HTMX DELETE status = %d; want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Errorf("Location = %q; want /", loc) + } + + // DB row must be gone. + _, err = q.GetTabloByID(ctx, tablo.ID) + if err == nil { + t.Error("tablo row still exists in DB after non-HTMX delete") + } + }) +} diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index cf22580..c73a053 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -66,7 +66,7 @@ func TestHealthz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -81,7 +81,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/demo/time", nil) @@ -104,7 +104,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index c259402..fa35202 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -44,7 +44,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP)