diff --git a/backend/internal/web/handlers_planning_test.go b/backend/internal/web/handlers_planning_test.go new file mode 100644 index 0000000..05eefbc --- /dev/null +++ b/backend/internal/web/handlers_planning_test.go @@ -0,0 +1,271 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "backend/internal/auth" + "backend/internal/db/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func newPlanningTestRouter(q *sqlc.Queries, store *auth.Store, now func() time.Time) http.Handler { + authDeps := AuthDeps{Queries: q, Store: store, Secure: false} + tabloDeps := TablosDeps{Queries: q} + taskDeps := TasksDeps{Queries: q} + etapeDeps := EtapesDeps{Queries: q} + eventDeps := EventsDeps{Queries: q} + planningDeps := PlanningDeps{Queries: q, Now: now} + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + if err != nil { + panic("newPlanningTestRouter: " + err.Error()) + } + return router +} + +func fixedPlanningNow() time.Time { + return time.Date(2026, time.May, 16, 9, 0, 0, 0, time.Local) +} + +func insertPlanningTestTablo(t *testing.T, ctx context.Context, q *sqlc.Queries, user sqlc.User, title, color string) sqlc.Tablo { + t.Helper() + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: title, + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{String: color, Valid: color != ""}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + return tablo +} + +func TestPlanningRequiresAuth(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + req := httptest.NewRequest(http.MethodGet, "/planning", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("GET /planning status = %d; want 303", rec.Code) + } + if got := rec.Header().Get("Location"); got != "/login" { + t.Fatalf("GET /planning Location = %q; want /login", got) + } +} + +func TestPlanningDefaultsToUpcomingFourteenDays(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + user := preInsertUser(t, ctx, q, "planningdefault@example.com", "correct-horse-12") + tablo := insertPlanningTestTablo(t, ctx, q, user, "Default Window", "#2563eb") + insertEventTestEvent(t, ctx, q, tablo.ID, "Starts Today", "2026-05-16", "09:30", "", "", "") + insertEventTestEvent(t, ctx, q, tablo.ID, "Ends Window", "2026-05-29", "10:00", "", "", "") + insertEventTestEvent(t, ctx, q, tablo.ID, "Outside Window", "2026-05-30", "10:00", "", "", "") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + req := httptest.NewRequest(http.MethodGet, "/planning", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /planning status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"Planning", "May 16 - May 29, 2026", "Starts Today", "Ends Window"} { + if !strings.Contains(body, want) { + t.Fatalf("planning default range missing %q; body: %.800s", want, body) + } + } + if strings.Contains(body, "Outside Window") { + t.Fatalf("planning default range included event outside 14-day window; body: %.800s", body) + } +} + +func TestPlanningListsOwnedEventsChronologically(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + user := preInsertUser(t, ctx, q, "planningorder@example.com", "correct-horse-12") + blueTablo := insertPlanningTestTablo(t, ctx, q, user, "Blue Launch", "#123abc") + redTablo := insertPlanningTestTablo(t, ctx, q, user, "Red Studio", "") + insertEventTestEvent(t, ctx, q, redTablo.ID, "Gamma", "2026-05-18", "09:00", "", "Studio", "Hidden description") + insertEventTestEvent(t, ctx, q, blueTablo.ID, "Beta", "2026-05-17", "11:00", "12:00", "Office", "") + insertEventTestEvent(t, ctx, q, blueTablo.ID, "Alpha", "2026-05-17", "11:00", "", "", "") + insertEventTestEvent(t, ctx, q, blueTablo.ID, "Early", "2026-05-17", "09:30", "", "HQ", "") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + req := httptest.NewRequest(http.MethodGet, "/planning?start=2026-05-16", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /planning status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + assertBodyOrder(t, body, "Early", "Alpha", "Beta", "Gamma") + for _, want := range []string{"09:30", "11:00", "11:00-12:00", "Blue Launch", "#123abc", "Red Studio", "Office", "HQ", "/tablos/" + blueTablo.ID.String() + "/events?month=2026-05", "/tablos/" + redTablo.ID.String() + "/events?month=2026-05"} { + if !strings.Contains(body, want) { + t.Fatalf("planning agenda missing %q; body: %.1200s", want, body) + } + } + if strings.Contains(body, "Hidden description") { + t.Fatalf("planning agenda rendered description snippet; body: %.1200s", body) + } +} + +func TestPlanningDoesNotLeakOtherUsersEvents(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + owner := preInsertUser(t, ctx, q, "planningowner@example.com", "correct-horse-12") + other := preInsertUser(t, ctx, q, "planningother@example.com", "correct-horse-12") + ownedTablo := insertPlanningTestTablo(t, ctx, q, owner, "Owned Planning", "#2563eb") + foreignTablo := insertPlanningTestTablo(t, ctx, q, other, "Foreign Planning", "#b91c1c") + insertEventTestEvent(t, ctx, q, ownedTablo.ID, "Owned Event", "2026-05-20", "09:30", "", "", "") + insertEventTestEvent(t, ctx, q, foreignTablo.ID, "Foreign Event", "2026-05-20", "09:30", "", "", "") + sessionCookie := sessionCookieForUser(t, ctx, store, owner) + + req := httptest.NewRequest(http.MethodGet, "/planning?start=2026-05-16", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /planning status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "Owned Event") { + t.Fatalf("planning agenda missing owned event; body: %.800s", body) + } + if strings.Contains(body, "Foreign Event") { + t.Fatalf("planning agenda leaked foreign event; body: %.800s", body) + } +} + +func TestPlanningNavigationLinks(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + user := preInsertUser(t, ctx, q, "planningnav@example.com", "correct-horse-12") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + req := httptest.NewRequest(http.MethodGet, "/planning?start=2026-05-16", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /planning status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"Previous 14 days", "Today", "Next 14 days", "start=2026-05-02", "href=\"/planning\"", "start=2026-05-30"} { + if !strings.Contains(body, want) { + t.Fatalf("planning navigation missing %q; body: %.1000s", want, body) + } + } +} + +func TestPlanningEmptyState(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + user := preInsertUser(t, ctx, q, "planningempty@example.com", "correct-horse-12") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + req := httptest.NewRequest(http.MethodGet, "/planning?start=2026-05-16", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /planning status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"No events in this range", "Use the navigation controls to browse another 14-day window.", "Previous 14 days", "Today", "Next 14 days"} { + if !strings.Contains(body, want) { + t.Fatalf("planning empty state missing %q; body: %.1000s", want, body) + } + } +} + +func TestPlanningInvalidStartFallsBackToToday(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newPlanningTestRouter(q, store, fixedPlanningNow) + + user := preInsertUser(t, ctx, q, "planninginvalid@example.com", "correct-horse-12") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + req := httptest.NewRequest(http.MethodGet, "/planning?start=not-a-date", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /planning invalid start status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "May 16 - May 29, 2026") { + t.Fatalf("invalid start did not fall back to today range; body: %.1000s", rec.Body.String()) + } +} + +func assertBodyOrder(t *testing.T, body string, values ...string) { + t.Helper() + last := -1 + for _, value := range values { + idx := strings.Index(body, value) + if idx == -1 { + t.Fatalf("body missing %q; body: %.1200s", value, body) + } + if idx <= last { + t.Fatalf("body order mismatch: %q appeared at %d after prior index %d; body: %.1200s", value, idx, last, body) + } + last = idx + } +}