feat(03-03): detail/edit/delete handlers + router wiring; all 10 TABLO tests green
- loadOwnedTablo helper: uuid.Parse, GetTabloByID, ownership check (D-04: 404 not 403)
- TabloDetailHandler: GET /tablos/{id} renders detail page
- TabloEditTitleHandler/ShowTitleHandler: GET /tablos/{id}/edit-title|show-title fragments
- TabloEditDescHandler/ShowDescHandler: GET /tablos/{id}/edit-desc|show-desc fragments
- TabloUpdateHandler: POST /tablos/{id} — validates, updates DB, renders matching zone fragment
- TabloDeleteConfirmHandler/CancelHandler: GET /tablos/{id}/delete-confirm|delete-cancel
- TabloDeleteHandler: POST /tablos/{id}/delete — deletes row, HX-Redirect:/ or 303
- router.go: 9 new routes in RequireAuth group, static-before-parametric order preserved
- Fix [Rule 1 - Bug]: test title "Owner's Tablo" caused HTML entity mismatch — changed to "Owners Detail Tablo"
- go test ./internal/web/... -run TestTablo: 10/10 PASS; full suite: all PASS
This commit is contained in:
parent
6f167e2956
commit
ab6937c1aa
3 changed files with 253 additions and 2 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -9,7 +10,10 @@ import (
|
|||
"backend/internal/db/sqlc"
|
||||
"backend/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
|
|
@ -129,6 +133,242 @@ func TablosCreateHandler(deps TablosDeps) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// loadOwnedTablo is a shared preamble for all /tablos/{id}* handlers.
|
||||
// It:
|
||||
// 1. Parses the {id} URL param as a UUID → 404 on parse failure (T-03-03-03).
|
||||
// 2. Fetches the tablo by ID → 404 on pgx.ErrNoRows, 500 on other errors.
|
||||
// 3. Checks ownership → 404 (not 403) when tablo.UserID != user.ID (T-03-03-01, D-04).
|
||||
//
|
||||
// Returns (tablo, user, true) on success. On failure the helper writes the
|
||||
// appropriate HTTP response and returns (zero, nil, false); callers must return
|
||||
// immediately when ok is false.
|
||||
func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sqlc.Tablo, *auth.User, bool) {
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
|
||||
// Step 1: parse the URL parameter.
|
||||
tabloID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return sqlc.Tablo{}, nil, false
|
||||
}
|
||||
|
||||
// Step 2: fetch from DB.
|
||||
tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
return sqlc.Tablo{}, nil, false
|
||||
}
|
||||
slog.Default().Error("tablos: GetTabloByID failed", "id", tabloID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return sqlc.Tablo{}, nil, false
|
||||
}
|
||||
|
||||
// Step 3: ownership check (D-04: 404 not 403 — no existence leakage).
|
||||
if tablo.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return sqlc.Tablo{}, nil, false
|
||||
}
|
||||
|
||||
return tablo, user, true
|
||||
}
|
||||
|
||||
// TabloDetailHandler handles GET /tablos/{id}.
|
||||
// Renders the tablo detail page for the authenticated owner; 404 for non-owner
|
||||
// and invalid UUIDs (TABLO-03, T-03-03-01, T-03-03-03).
|
||||
func TabloDetailHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, user, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloEditTitleHandler handles GET /tablos/{id}/edit-title.
|
||||
// Returns the title edit fragment for HTMX outerHTML swap.
|
||||
func TabloEditTitleHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloTitleEditFragment(tablo, templates.TabloUpdateErrors{}, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloShowTitleHandler handles GET /tablos/{id}/show-title.
|
||||
// Returns the title display fragment — used by the "Discard changes" path.
|
||||
func TabloShowTitleHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloTitleDisplay(tablo, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloEditDescHandler handles GET /tablos/{id}/edit-desc.
|
||||
// Returns the description edit fragment for HTMX outerHTML swap.
|
||||
func TabloEditDescHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloDescEditFragment(tablo, templates.TabloUpdateErrors{}, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloShowDescHandler handles GET /tablos/{id}/show-desc.
|
||||
// Returns the description display fragment — used by the "Discard changes" path.
|
||||
func TabloShowDescHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloDescDisplay(tablo, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloUpdateHandler handles POST /tablos/{id}.
|
||||
// Validates, updates the DB row (title + description), and returns the
|
||||
// matching display fragment based on the hidden _zone field.
|
||||
//
|
||||
// Security invariants:
|
||||
// - Form values read via r.PostFormValue only (gorilla/csrf body — Pitfall 2)
|
||||
// - Title validated non-empty and <= 255 chars (T-03-03-05)
|
||||
// - Description written as pgtype.Text{Valid: s != ""} (T-03-03-06)
|
||||
// - HTMX path: 200 + display fragment; non-HTMX path: 303 to /tablos/{id}
|
||||
func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
// Read form values — always r.PostFormValue (Pitfall 2).
|
||||
title := strings.TrimSpace(r.PostFormValue("title"))
|
||||
description := r.PostFormValue("description")
|
||||
zone := r.PostFormValue("_zone") // "title" or "desc"
|
||||
|
||||
var errs templates.TabloUpdateErrors
|
||||
|
||||
// Validate title.
|
||||
if title == "" {
|
||||
errs.Title = "Title is required."
|
||||
} else if len(title) > 255 {
|
||||
errs.Title = "Title must be 255 characters or fewer."
|
||||
}
|
||||
|
||||
if errs.Title != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
_ = templates.TabloTitleEditFragment(tablo, errs, csrf.Token(r)).Render(ctx, w)
|
||||
return
|
||||
}
|
||||
// Non-HTMX: render full detail page with errors surfaced.
|
||||
_ = templates.TabloDetailPage(nil, csrf.Token(r), tablo).Render(ctx, w)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the DB row (UpdateTablo sets updated_at = now() per sqlc query — Pitfall 7).
|
||||
updated, err := deps.Queries.UpdateTablo(ctx, sqlc.UpdateTabloParams{
|
||||
ID: tablo.ID,
|
||||
Title: title,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Default().Error("tablos update: query failed", "id", tablo.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Success: return appropriate display fragment or redirect.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
// Render the display fragment matching the zone that was edited.
|
||||
if zone == "desc" {
|
||||
_ = templates.TabloDescDisplay(updated, csrf.Token(r)).Render(ctx, w)
|
||||
return
|
||||
}
|
||||
// Default to title display.
|
||||
_ = templates.TabloTitleDisplay(updated, csrf.Token(r)).Render(ctx, w)
|
||||
return
|
||||
}
|
||||
|
||||
// Non-HTMX: 303 redirect to detail page so GET renders fresh state.
|
||||
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloDeleteConfirmHandler handles GET /tablos/{id}/delete-confirm.
|
||||
// Returns the delete confirmation fragment for HTMX outerHTML swap.
|
||||
func TabloDeleteConfirmHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloDeleteConfirmFragment(tablo, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloDeleteCancelHandler handles GET /tablos/{id}/delete-cancel.
|
||||
// Returns the delete button fragment, restoring the original button after
|
||||
// the user cancels the confirmation dialog.
|
||||
func TabloDeleteCancelHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloDeleteButtonFragment(tablo, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TabloDeleteHandler handles POST /tablos/{id}/delete.
|
||||
// Hard-deletes the tablo and redirects to /:
|
||||
// - HTMX: 200 + HX-Redirect: / header (HTMX client-side navigation)
|
||||
// - Non-HTMX: 303 to / (Pitfall 9 — POST/Redirect/GET, NOT 302)
|
||||
//
|
||||
// Security: loadOwnedTablo enforces auth + ownership; CSRF middleware validates
|
||||
// the _csrf field before this handler runs (T-03-03-09).
|
||||
func TabloDeleteHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, deps)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := deps.Queries.DeleteTablo(r.Context(), tablo.ID); err != nil {
|
||||
slog.Default().Error("tablos delete: query failed", "id", tablo.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// renderTabloCreateError writes a tablo create validation-error response.
|
||||
// For HTMX requests it renders only the form fragment; for plain requests it
|
||||
// renders the full dashboard (mirrors the auth handler pattern).
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ func TestTabloDetail_Owner(t *testing.T) {
|
|||
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: "Owner's Tablo",
|
||||
Title: "Owners Detail Tablo",
|
||||
Description: pgtype.Text{Valid: false},
|
||||
Color: pgtype.Text{Valid: false},
|
||||
})
|
||||
|
|
@ -391,7 +391,7 @@ func TestTabloDetail_Owner(t *testing.T) {
|
|||
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") {
|
||||
if !strings.Contains(rec.Body.String(), "Owners Detail Tablo") {
|
||||
t.Errorf("body missing tablo title; got: %.300s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,8 +77,19 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD
|
|||
r.Use(auth.RequireAuth)
|
||||
r.Get("/", TablosListHandler(tabloDeps))
|
||||
r.Post("/logout", LogoutHandler(deps))
|
||||
// Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution).
|
||||
r.Get("/tablos/new", TablosNewHandler(tabloDeps))
|
||||
r.Post("/tablos", TablosCreateHandler(tabloDeps))
|
||||
// Parametric routes — must come after /tablos/new and /tablos POST.
|
||||
r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
|
||||
r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
|
||||
r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
|
||||
r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
|
||||
r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
|
||||
r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
|
||||
r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
|
||||
r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
|
||||
r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
|
||||
})
|
||||
|
||||
r.Get("/healthz", HealthzHandler(pinger))
|
||||
|
|
|
|||
Loading…
Reference in a new issue