diff --git a/backend/internal/web/handlers_discussion_test.go b/backend/internal/web/handlers_discussion_test.go index 680b16c..494bc56 100644 --- a/backend/internal/web/handlers_discussion_test.go +++ b/backend/internal/web/handlers_discussion_test.go @@ -285,3 +285,161 @@ func TestDiscussionRequiresCSRF(t *testing.T) { t.Fatalf("POST discussion without CSRF succeeded; want rejection") } } + +func TestTablosListDiscussionUnreadBadge(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newDiscussionTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "discussion-unread@example.com", "correct-horse-12") + unreadTablo := insertDiscussionTestTablo(t, ctx, q, user, "Unread Tablo") + quietTablo := insertDiscussionTestTablo(t, ctx, q, user, "Quiet Tablo") + for i := 0; i < 3; i++ { + insertDiscussionTestMessage(t, ctx, pool, q, unreadTablo.ID, user.ID, "Unread message", time.Date(2026, 5, 16, 9, i, 0, 0, time.UTC)) + } + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + 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, `aria-label="3 unread discussion messages"`) { + t.Fatalf("unread badge accessible label missing; body: %.1200s", body) + } + unreadCard := discussionTestTabloCardHTML(t, body, unreadTablo.ID) + if !strings.Contains(unreadCard, ">3<") { + t.Fatalf("unread tablo card missing numeric badge; card: %.800s", unreadCard) + } + quietCard := discussionTestTabloCardHTML(t, body, quietTablo.ID) + if strings.Contains(quietCard, "unread discussion messages") { + t.Fatalf("quiet tablo card unexpectedly contains unread badge; card: %.800s", quietCard) + } +} + +func TestTablosListDiscussionUnreadDoesNotLeakOtherUsers(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newDiscussionTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "discussion-unread-owner@example.com", "correct-horse-12") + other := preInsertUser(t, ctx, q, "discussion-unread-other@example.com", "correct-horse-12") + _ = insertDiscussionTestTablo(t, ctx, q, user, "Owner Quiet Tablo") + otherTablo := insertDiscussionTestTablo(t, ctx, q, other, "Other Private Tablo") + insertDiscussionTestMessage(t, ctx, pool, q, otherTablo.ID, other.ID, "Other user's message", time.Date(2026, 5, 16, 9, 0, 0, 0, time.UTC)) + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + 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, "Other Private Tablo") || strings.Contains(body, "unread discussion messages") { + t.Fatalf("dashboard leaked another user's unread state; body: %.1200s", body) + } +} + +func TestDiscussionGetMarksMessagesRead(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newDiscussionTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "discussion-read@example.com", "correct-horse-12") + tablo := insertDiscussionTestTablo(t, ctx, q, user, "Read State Tablo") + insertDiscussionTestMessage(t, ctx, pool, q, tablo.ID, user.ID, "First unread", time.Date(2026, 5, 16, 9, 0, 0, 0, time.UTC)) + insertDiscussionTestMessage(t, ctx, pool, q, tablo.ID, user.ID, "Second unread", time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC)) + sessionCookie := sessionCookieForUser(t, ctx, store, user) + + beforeReq := httptest.NewRequest(http.MethodGet, "/", nil) + beforeReq.AddCookie(sessionCookie) + beforeRec := httptest.NewRecorder() + router.ServeHTTP(beforeRec, beforeReq) + if !strings.Contains(beforeRec.Body.String(), `aria-label="2 unread discussion messages"`) { + t.Fatalf("pre-read dashboard missing unread badge; body: %.1200s", beforeRec.Body.String()) + } + + readReq := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/discussion", nil) + readReq.AddCookie(sessionCookie) + readRec := httptest.NewRecorder() + router.ServeHTTP(readRec, readReq) + if readRec.Code != http.StatusOK { + t.Fatalf("GET discussion status = %d; want 200", readRec.Code) + } + + afterReq := httptest.NewRequest(http.MethodGet, "/", nil) + afterReq.AddCookie(sessionCookie) + afterRec := httptest.NewRecorder() + router.ServeHTTP(afterRec, afterReq) + if strings.Contains(afterRec.Body.String(), "unread discussion messages") { + t.Fatalf("post-read dashboard still shows unread badge; body: %.1200s", afterRec.Body.String()) + } +} + +func TestDiscussionPostMarksSenderRead(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newDiscussionTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "discussion-sender-read@example.com", "correct-horse-12") + tablo := insertDiscussionTestTablo(t, ctx, q, user, "Sender Read Tablo") + sessionCookie := sessionCookieForUser(t, ctx, store, user) + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/discussion", []*http.Cookie{sessionCookie}) + + form := url.Values{"body": {"Sender-created message"}, "_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/discussion/messages", 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 discussion message status = %d; want 200; body: %.500s", rec.Code, rec.Body.String()) + } + + dashboardReq := httptest.NewRequest(http.MethodGet, "/", nil) + dashboardReq.AddCookie(sessionCookie) + dashboardRec := httptest.NewRecorder() + router.ServeHTTP(dashboardRec, dashboardReq) + if strings.Contains(dashboardRec.Body.String(), "unread discussion messages") { + t.Fatalf("sender's own message counted unread; body: %.1200s", dashboardRec.Body.String()) + } +} + +func discussionTestTabloCardHTML(t *testing.T, body string, id uuid.UUID) string { + t.Helper() + start := strings.Index(body, `id="tablo-`+id.String()+`"`) + if start == -1 { + t.Fatalf("tablo card %s not found; body: %.1200s", id, body) + } + next := strings.Index(body[start+1:], `id="tablo-`) + if next == -1 { + return body[start:] + } + return body[start : start+1+next] +}