test(12-03): add discussion SSE coverage

This commit is contained in:
Arthur Belleville 2026-05-16 10:17:23 +02:00
parent 0fa34cebf0
commit c6dcb680bd
No known key found for this signature in database

View file

@ -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()