xtablo-source/backend/internal/web/handlers_tablos_test.go
Arthur Belleville 7bea525c1b
test(15-01): add Wave 0 RED test stubs for DASH-01, DASH-02, DASH-03
- TestTablosDashboard_Sidebar: asserts dashboard-sidebar + sidebar-nav-shell in GET / body
- TestTablosDashboard_ProjectCards: asserts project-card in GET / body with a pre-inserted tablo
- TestTablosDashboard_EmptyState: asserts ui-empty-state in GET / body with zero tablos
- All three skip without TEST_DATABASE_URL; compile cleanly; existing TestTablos* tests unaffected
2026-05-16 21:38:50 +02:00

824 lines
26 KiB
Go

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"
"os"
"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}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTabloTestRouter: " + err.Error())
}
return router
}
// 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, sqlc.DeleteTabloParams{ID: tbl.ID, UserID: tbl.UserID})
}
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: "Owners Detail 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(), "Owners Detail 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, sqlc.GetTabloByIDParams{ID: tablo.ID, UserID: user.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)
}
}
// ---- TestTablosDashboard_Sidebar ----
// TestTablosDashboard_Sidebar verifies that authenticated GET / renders the dashboard
// sidebar shell (DASH-01). Fails RED until AppLayout renders the sidebar components.
func TestTablosDashboard_Sidebar(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, "sidebar@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, "dashboard-sidebar") {
t.Errorf("body missing 'dashboard-sidebar'; body: %.300s", body)
}
if !strings.Contains(body, "sidebar-nav-shell") {
t.Errorf("body missing 'sidebar-nav-shell'; body: %.300s", body)
}
}
// ---- TestTablosDashboard_ProjectCards ----
// TestTablosDashboard_ProjectCards verifies that authenticated GET / with at least
// one tablo renders project-card elements (DASH-02). Fails RED until TabloProjectCard
// template is implemented.
func TestTablosDashboard_ProjectCards(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, "projectcards@example.com", "correct-horse-12")
_, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
UserID: user.ID,
Title: "Project Card 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, "/", 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, "project-card") {
t.Errorf("body missing 'project-card'; body: %.300s", body)
}
}
// ---- TestTablosDashboard_EmptyState ----
// TestTablosDashboard_EmptyState verifies that authenticated GET / with zero tablos
// renders a ui-empty-state element (DASH-03). Fails RED until TablosEmptyState uses
// @ui.EmptyState.
func TestTablosDashboard_EmptyState(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, "emptystate@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, "ui-empty-state") {
t.Errorf("body missing 'ui-empty-state'; 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, sqlc.GetTabloByIDParams{ID: tablo.ID, UserID: user.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, sqlc.GetTabloByIDParams{ID: tablo.ID, UserID: user.ID})
if err == nil {
t.Error("tablo row still exists in DB after non-HTMX delete")
}
})
}