From c6dcb680bd6f3b1af5ff35fe862e08a60f9f6fba Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 10:17:23 +0200 Subject: [PATCH] test(12-03): add discussion SSE coverage --- .../internal/web/handlers_discussion_test.go | 188 +++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/backend/internal/web/handlers_discussion_test.go b/backend/internal/web/handlers_discussion_test.go index 494bc56..86dac65 100644 --- a/backend/internal/web/handlers_discussion_test.go +++ b/backend/internal/web/handlers_discussion_test.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "strings" + "sync" "testing" "time" @@ -19,9 +20,13 @@ import ( ) 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}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + 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()) } @@ -286,6 +291,187 @@ func TestDiscussionRequiresCSRF(t *testing.T) { } } +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 TestTablosListDiscussionUnreadBadge(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup()