xtablo-source/backend/internal/web/handlers_discussion_test.go
2026-05-16 10:32:28 +02:00

657 lines
24 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"backend/internal/auth"
"backend/internal/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
func newDiscussionTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
return newDiscussionTestRouterWithBroker(q, store, nil)
}
func newDiscussionTestRouterWithBroker(q *sqlc.Queries, store *auth.Store, broker DiscussionRealtime) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q, Realtime: broker}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newDiscussionTestRouter: " + err.Error())
}
return router
}
func insertDiscussionTestTablo(t *testing.T, ctx context.Context, q *sqlc.Queries, user sqlc.User, title 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{Valid: false},
})
if err != nil {
t.Fatalf("InsertTablo: %v", err)
}
return tablo
}
func insertDiscussionTestMessage(t *testing.T, ctx context.Context, pool *pgxpool.Pool, q *sqlc.Queries, tabloID, authorID uuid.UUID, body string, createdAt time.Time) sqlc.DiscussionMessage {
t.Helper()
msg, err := q.CreateDiscussionMessage(ctx, sqlc.CreateDiscussionMessageParams{
TabloID: tabloID,
AuthorUserID: authorID,
Body: body,
})
if err != nil {
t.Fatalf("CreateDiscussionMessage: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE discussion_messages SET created_at = $1, updated_at = $1 WHERE id = $2`, createdAt, msg.ID); err != nil {
t.Fatalf("set discussion message created_at: %v", err)
}
msg.CreatedAt = pgtype.Timestamptz{Time: createdAt, Valid: true}
msg.UpdatedAt = pgtype.Timestamptz{Time: createdAt, Valid: true}
return msg
}
func TestDiscussionTabRendersHistoryAndComposer(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-history@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Discussion History Tablo")
first := insertDiscussionTestMessage(t, ctx, pool, q, tablo.ID, user.ID, "<script>alert('x')</script> first", time.Date(2026, 5, 16, 9, 30, 0, 0, time.UTC))
second := insertDiscussionTestMessage(t, ctx, pool, q, tablo.ID, user.ID, "Second message", time.Date(2026, 5, 17, 10, 45, 0, 0, time.UTC))
sessionCookie := sessionCookieForUser(t, ctx, store, user)
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/discussion", 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 discussion tab status = %d; want 200; body: %.500s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Discussion", "1 participant", user.Email, "May 16, 2026", "May 17, 2026", "Message", "Write a message...", "Send message", "hx-on::after-request", "this.reset()"} {
if !strings.Contains(body, want) {
t.Errorf("discussion tab missing %q; body: %.1000s", want, body)
}
}
if strings.Contains(body, "<script>alert") {
t.Fatalf("message body rendered raw script tag; body: %.1000s", body)
}
if !strings.Contains(body, "&lt;script&gt;alert") {
t.Fatalf("escaped script-looking body missing; body: %.1000s", body)
}
if strings.Index(body, first.Body) > strings.Index(body, second.Body) {
t.Fatalf("messages not rendered oldest-first; body: %.1000s", body)
}
for _, forbidden := range []string{"Delete message", "Edit message", "Reply", "Typing", "Attach"} {
if strings.Contains(body, forbidden) {
t.Errorf("discussion tab contains out-of-scope control %q", forbidden)
}
}
}
func TestDiscussionTabFullPageFallback(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-fullpage@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Discussion Full Page Tablo")
sessionCookie := sessionCookieForUser(t, ctx, store, user)
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/discussion", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("direct GET discussion status = %d; want 200; body: %.500s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Xtablo", "Back to tablos", tablo.Title, "Overview", "Tasks", "Files", "Events", "Discussion"} {
if !strings.Contains(body, want) {
t.Errorf("full page discussion fallback missing %q; body: %.1000s", want, body)
}
}
}
func TestDiscussionPostCreatesMessage(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-post@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Discussion Post Tablo")
sessionCookie := sessionCookieForUser(t, ctx, store, user)
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/discussion", []*http.Cookie{sessionCookie})
form := url.Values{
"body": {"A new discussion 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())
}
if !strings.Contains(rec.Body.String(), "A new discussion message") {
t.Fatalf("created message missing from response; body: %.800s", rec.Body.String())
}
messages, err := q.ListDiscussionMessagesByTablo(ctx, tablo.ID)
if err != nil {
t.Fatalf("ListDiscussionMessagesByTablo: %v", err)
}
if len(messages) != 1 {
t.Fatalf("message count = %d; want 1", len(messages))
}
if messages[0].AuthorUserID != user.ID || messages[0].Body != "A new discussion message" {
t.Fatalf("stored message mismatch: %+v", messages[0])
}
}
func TestDiscussionPostRejectsEmptyAndTooLong(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-validation@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Discussion Validation Tablo")
sessionCookie := sessionCookieForUser(t, ctx, store, user)
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/discussion", []*http.Cookie{sessionCookie})
tests := []struct {
name string
body string
want string
}{
{name: "empty", body: " \n\t", want: "Message is required."},
{name: "too long", body: strings.Repeat("x", 10001), want: "Message is too long."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
form := url.Values{"body": {tt.body}, "_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.StatusUnprocessableEntity {
t.Fatalf("invalid POST status = %d; want 422; body: %.500s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), tt.want) {
t.Fatalf("validation copy %q missing; body: %.800s", tt.want, rec.Body.String())
}
})
}
}
func TestDiscussionOwnershipReturns404(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newDiscussionTestRouter(q, store)
owner := preInsertUser(t, ctx, q, "discussion-owner@example.com", "correct-horse-12")
nonOwner := preInsertUser(t, ctx, q, "discussion-nonowner@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, owner, "Private Discussion Tablo")
nonOwnerCookie := sessionCookieForUser(t, ctx, store, nonOwner)
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/discussion", nil)
req.AddCookie(nonOwnerCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("non-owner GET discussion status = %d; want 404", rec.Code)
}
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/new", []*http.Cookie{nonOwnerCookie})
form := url.Values{"body": {"cannot write here"}, "_csrf": {csrfToken}}
postReq := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/discussion/messages", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postReq.Header.Set("HX-Request", "true")
for _, c := range csrfCookies {
postReq.AddCookie(c)
}
postRec := httptest.NewRecorder()
router.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusNotFound {
t.Fatalf("non-owner POST discussion status = %d; want 404", postRec.Code)
}
}
func TestDiscussionRequiresCSRF(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-csrf@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Discussion CSRF Tablo")
sessionCookie := sessionCookieForUser(t, ctx, store, user)
form := url.Values{"body": {"missing token"}}
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")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatalf("POST discussion without CSRF succeeded; want rejection")
}
}
type recordingDiscussionRealtime struct {
mu sync.Mutex
events []DiscussionEvent
}
func (r *recordingDiscussionRealtime) Subscribe(ctx context.Context, tabloID uuid.UUID) (<-chan DiscussionEvent, func()) {
ch := make(chan DiscussionEvent)
return ch, func() { close(ch) }
}
func (r *recordingDiscussionRealtime) Publish(event DiscussionEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, event)
}
func (r *recordingDiscussionRealtime) lastEvent(t *testing.T) DiscussionEvent {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
if len(r.events) == 0 {
t.Fatal("no discussion event published")
}
return r.events[len(r.events)-1]
}
func TestDiscussionStreamRequiresAuth(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-stream-owner@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Stream Auth Tablo")
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/discussion/stream", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unauthenticated stream status = %d; want 303", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/login" {
t.Fatalf("unauthenticated stream Location = %q; want /login", got)
}
}
func TestDiscussionStreamOwnershipReturns404(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newDiscussionTestRouter(q, store)
owner := preInsertUser(t, ctx, q, "discussion-stream-owner-404@example.com", "correct-horse-12")
nonOwner := preInsertUser(t, ctx, q, "discussion-stream-nonowner@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, owner, "Private Stream Tablo")
nonOwnerCookie := sessionCookieForUser(t, ctx, store, nonOwner)
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/discussion/stream", nil)
req.AddCookie(nonOwnerCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("non-owner stream status = %d; want 404", rec.Code)
}
}
func TestDiscussionStreamHeaders(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-stream-headers@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Stream Headers Tablo")
sessionCookie := sessionCookieForUser(t, ctx, store, user)
server := httptest.NewServer(router)
defer server.Close()
req, err := http.NewRequest(http.MethodGet, server.URL+"/tablos/"+tablo.ID.String()+"/discussion/stream", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.AddCookie(sessionCookie)
resp, err := server.Client().Do(req)
if err != nil {
t.Fatalf("stream request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("stream status = %d; want 200", resp.StatusCode)
}
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") {
t.Fatalf("stream Content-Type = %q; want text/event-stream", got)
}
if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
t.Fatalf("stream Cache-Control = %q; want no-cache", got)
}
}
func TestDiscussionPostBroadcastsToBroker(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
broker := &recordingDiscussionRealtime{}
router := newDiscussionTestRouterWithBroker(q, store, broker)
user := preInsertUser(t, ctx, q, "discussion-broadcast@example.com", "correct-horse-12")
tablo := insertDiscussionTestTablo(t, ctx, q, user, "Broadcast Tablo")
sessionCookie := sessionCookieForUser(t, ctx, store, user)
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/discussion", []*http.Cookie{sessionCookie})
form := url.Values{"body": {"Broadcast me"}, "_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())
}
event := broker.lastEvent(t)
if event.TabloID != tablo.ID {
t.Fatalf("published TabloID = %s; want %s", event.TabloID, tablo.ID)
}
if event.MessageID == uuid.Nil {
t.Fatal("published event missing message ID")
}
if event.AuthorUserID != user.ID {
t.Fatalf("published AuthorUserID = %s; want %s", event.AuthorUserID, user.ID)
}
if !strings.Contains(event.MessageHTML, "Broadcast me") {
t.Fatalf("published message HTML missing body: %.500s", event.MessageHTML)
}
if !event.RefreshUnread {
t.Fatal("published event did not request unread refresh")
}
}
func TestDiscussionBrokerUnregistersOnCancel(t *testing.T) {
broker := NewDiscussionBroker()
tabloID := uuid.New()
ctx, cancel := context.WithCancel(context.Background())
_, unsubscribe := broker.Subscribe(ctx, tabloID)
if got := broker.SubscriberCount(tabloID); got != 1 {
t.Fatalf("subscriber count after subscribe = %d; want 1", got)
}
cancel()
deadline := time.After(500 * time.Millisecond)
for broker.SubscriberCount(tabloID) != 0 {
select {
case <-deadline:
unsubscribe()
t.Fatalf("subscriber count after cancel = %d; want 0", broker.SubscriberCount(tabloID))
default:
time.Sleep(10 * time.Millisecond)
}
}
}
func TestDiscussionStaticScriptSuppressesDuplicateMessageSwap(t *testing.T) {
js, err := os.ReadFile("../../static/discussion-sse.js")
if err != nil {
t.Fatalf("read discussion-sse.js: %v", err)
}
script := string(js)
for _, want := range []string{"htmx:beforeSwap", "requestConfig.elt", "messageIdFromHTML", "preventDefault()"} {
if !strings.Contains(script, want) {
t.Fatalf("discussion script missing %q for duplicate swap suppression; script: %.1200s", want, script)
}
}
}
func TestDiscussionStaticScriptClearsComposerAfterSuccessfulPost(t *testing.T) {
js, err := os.ReadFile("../../static/discussion-sse.js")
if err != nil {
t.Fatalf("read discussion-sse.js: %v", err)
}
script := string(js)
for _, want := range []string{"htmx:afterRequest", "requestConfig.elt", "isSuccessfulDiscussionPost", "xhr.status", "discussion-message-body", ".reset()"} {
if !strings.Contains(script, want) {
t.Fatalf("discussion script missing %q for composer reset; script: %.1200s", want, script)
}
}
}
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]
}