test(12-01): add discussion handler coverage

This commit is contained in:
Arthur Belleville 2026-05-16 10:08:34 +02:00
parent 68884daf11
commit 39e21be126
No known key found for this signature in database

View file

@ -0,0 +1,287 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"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 {
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}, 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, "&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")
}
}