From e5f083d2a86fd407760aaadca18f716802ed4067 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 00:30:52 +0200 Subject: [PATCH] test(10-02): add failing event mutation tests --- backend/internal/web/handlers_events_test.go | 263 +++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/backend/internal/web/handlers_events_test.go b/backend/internal/web/handlers_events_test.go index f50ab0a..8db8ae3 100644 --- a/backend/internal/web/handlers_events_test.go +++ b/backend/internal/web/handlers_events_test.go @@ -2,16 +2,20 @@ package web import ( "context" + "errors" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" + "time" "backend/internal/auth" "backend/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -51,6 +55,46 @@ func sessionCookieForUser(t *testing.T, ctx context.Context, store *auth.Store, return &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal} } +func eventTestDate(t *testing.T, raw string) pgtype.Date { + t.Helper() + parsed, err := time.Parse("2006-01-02", raw) + if err != nil { + t.Fatalf("eventTestDate(%q): %v", raw, err) + } + return pgtype.Date{Time: parsed, Valid: true} +} + +func eventTestTime(t *testing.T, raw string) pgtype.Time { + t.Helper() + parsed, err := time.Parse("15:04", raw) + if err != nil { + t.Fatalf("eventTestTime(%q): %v", raw, err) + } + micros := int64(parsed.Hour())*int64(time.Hour/time.Microsecond) + int64(parsed.Minute())*int64(time.Minute/time.Microsecond) + return pgtype.Time{Microseconds: micros, Valid: true} +} + +func insertEventTestEvent(t *testing.T, ctx context.Context, q *sqlc.Queries, tabloID uuid.UUID, title, date, start, end, location, description string) sqlc.Event { + t.Helper() + var endTime pgtype.Time + if end != "" { + endTime = eventTestTime(t, end) + } + event, err := q.CreateEvent(ctx, sqlc.CreateEventParams{ + TabloID: tabloID, + Title: title, + EventDate: eventTestDate(t, date), + StartTime: eventTestTime(t, start), + EndTime: endTime, + Description: pgtype.Text{String: description, Valid: description != ""}, + Location: pgtype.Text{String: location, Valid: location != ""}, + }) + if err != nil { + t.Fatalf("CreateEvent: %v", err) + } + return event +} + func TestEventsTabRendersMonthGrid(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() @@ -182,3 +226,222 @@ func TestEventsTabOwnershipReturns404(t *testing.T) { t.Fatalf("non-owner GET /tablos/{id}/events status = %d; want 404", rec.Code) } } + +func TestEventEditRendersInlineForm(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEventTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "eventedit@example.com", "correct-horse-12") + tablo := insertEventTestTablo(t, ctx, q, user, "Event Edit Tablo") + event := insertEventTestEvent(t, ctx, q, tablo.ID, "Planning Review", "2026-05-21", "09:30", "10:30", "Studio", "Discuss schedule") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/events/"+event.ID.String()+"/edit?month=2026-05", 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 event edit status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"Save event changes", "Planning Review", "2026-05-21", "09:30", "Studio", "Discuss schedule"} { + if !strings.Contains(body, want) { + t.Errorf("edit form missing %q; body: %.800s", want, body) + } + } +} + +func TestEventUpdateChangesCalendarPlacement(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEventTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "eventupdate@example.com", "correct-horse-12") + tablo := insertEventTestTablo(t, ctx, q, user, "Event Update Tablo") + event := insertEventTestEvent(t, ctx, q, tablo.ID, "Original Review", "2026-05-21", "09:30", "", "Studio", "Discuss schedule") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/events?month=2026-05", []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"Moved Review"}, + "event_date": {"2026-05-24"}, + "start_time": {"14:15"}, + "end_time": {"15:00"}, + "location": {"HQ"}, + "description": {"Updated details"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/events/"+event.ID.String()+"?month=2026-05", 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 event update status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "Moved Review") { + t.Fatalf("updated event title missing from refreshed calendar; body: %.800s", body) + } + if strings.Contains(body, "Original Review") { + t.Fatalf("old event title still present after update; body: %.800s", body) + } +} + +func TestEventUpdateRejectsInvalidEndTime(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEventTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "eventupdateinvalid@example.com", "correct-horse-12") + tablo := insertEventTestTablo(t, ctx, q, user, "Event Update Invalid Tablo") + event := insertEventTestEvent(t, ctx, q, tablo.ID, "Invalid Update", "2026-05-21", "09:30", "", "", "") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/events?month=2026-05", []*http.Cookie{sessionCookie}) + + form := url.Values{ + "title": {"Invalid Update"}, + "event_date": {"2026-05-21"}, + "start_time": {"10:00"}, + "end_time": {"10:00"}, + "_csrf": {csrfToken}, + } + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/events/"+event.ID.String()+"?month=2026-05", 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("invalid event update status = %d; want 422", rec.Code) + } + if !strings.Contains(rec.Body.String(), "End time must be after the start time.") { + t.Fatalf("validation copy missing; body: %.800s", rec.Body.String()) + } +} + +func TestEventDeleteRemovesFromCalendarAndDatabase(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEventTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "eventdelete@example.com", "correct-horse-12") + tablo := insertEventTestTablo(t, ctx, q, user, "Event Delete Tablo") + event := insertEventTestEvent(t, ctx, q, tablo.ID, "Delete Me", "2026-05-21", "09:30", "", "", "") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/events?month=2026-05", []*http.Cookie{sessionCookie}) + + confirmReq := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/events/"+event.ID.String()+"/delete-confirm?month=2026-05", nil) + confirmReq.Header.Set("HX-Request", "true") + confirmReq.AddCookie(sessionCookie) + confirmRec := httptest.NewRecorder() + router.ServeHTTP(confirmRec, confirmReq) + if confirmRec.Code != http.StatusOK { + t.Fatalf("GET delete confirm status = %d; want 200; body: %.500s", confirmRec.Code, confirmRec.Body.String()) + } + if !strings.Contains(confirmRec.Body.String(), "Delete event?") { + t.Fatalf("delete confirmation copy missing; body: %.800s", confirmRec.Body.String()) + } + + form := url.Values{"_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/events/"+event.ID.String()+"/delete?month=2026-05", 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 event delete status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "Delete Me") { + t.Fatalf("deleted event title still present in refreshed calendar; body: %.800s", rec.Body.String()) + } + _, err := q.GetEventByID(ctx, sqlc.GetEventByIDParams{ID: event.ID, TabloID: tablo.ID}) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("GetEventByID after delete err = %v; want pgx.ErrNoRows", err) + } +} + +func TestEventMutationOwnershipReturns404(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newEventTestRouter(q, store) + + owner := preInsertUser(t, ctx, q, "eventmutationowner@example.com", "correct-horse-12") + nonOwner := preInsertUser(t, ctx, q, "eventmutationnonowner@example.com", "correct-horse-12") + tablo := insertEventTestTablo(t, ctx, q, owner, "Private Mutation Tablo") + event := insertEventTestEvent(t, ctx, q, tablo.ID, "Private Event", "2026-05-21", "09:30", "", "", "") + nonOwnerCookie := sessionCookieForUser(t, ctx, store, nonOwner) + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/new", []*http.Cookie{nonOwnerCookie}) + + getPaths := []string{ + "/tablos/" + tablo.ID.String() + "/events/" + event.ID.String() + "/edit?month=2026-05", + "/tablos/" + tablo.ID.String() + "/events/" + event.ID.String() + "/delete-confirm?month=2026-05", + } + for _, path := range getPaths { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(nonOwnerCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("non-owner GET %s status = %d; want 404", path, rec.Code) + } + } + + postBodies := map[string]url.Values{ + "/tablos/" + tablo.ID.String() + "/events/" + event.ID.String() + "?month=2026-05": { + "title": {"Private Event"}, + "event_date": {"2026-05-21"}, + "start_time": {"09:30"}, + "_csrf": {csrfToken}, + }, + "/tablos/" + tablo.ID.String() + "/events/" + event.ID.String() + "/delete?month=2026-05": { + "_csrf": {csrfToken}, + }, + } + for path, form := range postBodies { + req := httptest.NewRequest(http.MethodPost, path, 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.StatusNotFound { + t.Fatalf("non-owner POST %s status = %d; want 404", path, rec.Code) + } + } +}