test(12-01): add discussion handler coverage
This commit is contained in:
parent
68884daf11
commit
39e21be126
1 changed files with 287 additions and 0 deletions
287
backend/internal/web/handlers_discussion_test.go
Normal file
287
backend/internal/web/handlers_discussion_test.go
Normal 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, "<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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue