657 lines
24 KiB
Go
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"} {
|
|
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, "<script>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", "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]
|
|
}
|