test(03-01): add TablosDeps stub and RED integration test scaffold for TABLO-01..06
- handlers_tablos.go: TablosDeps stub type enabling test compilation - handlers_tablos_test.go: 10 integration tests (RED baseline) for all TABLO-01..06 paths - TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation - TestTabloDetail_Owner, TestTabloDetail_NonOwner, TestTabloDetail_InvalidID - TestTabloUpdate, TestTabloDeleteConfirm, TestTabloDelete - router.go: NewRouter accepts TablosDeps as second deps parameter - handlers_auth_test.go, handlers_test.go, csrf_test.go: update NewRouter call sites - cmd/web/main.go: construct and pass TablosDeps to NewRouter
This commit is contained in:
parent
f1b8d6e629
commit
c8f44b1ad2
7 changed files with 718 additions and 8 deletions
|
|
@ -76,8 +76,9 @@ func main() {
|
||||||
rl.StartJanitor(time.Minute, stopJanitor)
|
rl.StartJanitor(time.Minute, stopJanitor)
|
||||||
|
|
||||||
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
|
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{
|
srv := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
csrfKey[i] = byte(i + 1)
|
csrfKey[i] = byte(i + 1)
|
||||||
}
|
}
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
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
|
// extractCSRFToken performs a GET request and extracts the _csrf token from the
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,14 @@ var testCSRFKey = func() []byte {
|
||||||
// Referer header are accepted.
|
// Referer header are accepted.
|
||||||
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
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,
|
// newTestRouterWithLimiter builds a router with an injected LimiterStore,
|
||||||
// enabling rate-limit tests to use a fake clock.
|
// enabling rate-limit tests to use a fake clock.
|
||||||
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
|
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
|
// getCSRFToken performs a GET request to path and extracts the CSRF token
|
||||||
|
|
|
||||||
10
backend/internal/web/handlers_tablos.go
Normal file
10
backend/internal/web/handlers_tablos.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
699
backend/internal/web/handlers_tablos_test.go
Normal file
699
backend/internal/web/handlers_tablos_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ func TestHealthz_Down(t *testing.T) {
|
||||||
// was public. The HTMX demo content is tested by
|
// was public. The HTMX demo content is tested by
|
||||||
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
||||||
func TestIndex_UnauthRedirects(t *testing.T) {
|
func TestIndex_UnauthRedirects(t *testing.T) {
|
||||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, testCSRFKey, "dev")
|
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ func TestIndex_UnauthRedirects(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDemoTime_Fragment(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()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/demo/time", nil)
|
req := httptest.NewRequest(http.MethodGet, "/demo/time", nil)
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ func TestDemoTime_Fragment(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestID_HeaderSet(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()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ type Pinger interface {
|
||||||
// trustedOrigins is an optional list of additional origins for the CSRF
|
// trustedOrigins is an optional list of additional origins for the CSRF
|
||||||
// referer check (used in integration tests to allow localhost requests without
|
// referer check (used in integration tests to allow localhost requests without
|
||||||
// a Referer header). In production, pass no extra args — leave empty.
|
// 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 := chi.NewRouter()
|
||||||
r.Use(RequestIDMiddleware)
|
r.Use(RequestIDMiddleware)
|
||||||
r.Use(chimw.RealIP)
|
r.Use(chimw.RealIP)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue