feat: messaging with conversations, rich text, attachments, typing, unread (phase 9)
- conversations: dm (unique pair, find-or-create), group (titled), project (customer-bound); participant-only access - messages: server-sanitized rich text, attachments referencing uploads from the generic POST /api/v1/files endpoint (rate limited, MIME sniffed) - unread counts via aggregation; mark-read pushes readBy receipts - chat files served only to conversation participants (or uploader) - @mentions in chat notify the mentioned participant - typing indicator: client WS event relayed to other participants - WS targeted delivery (SendTo) + 15s polling fallback - two-pane messages UI: conversation list with unread badges, composer (contenteditable + formatting buttons), drag & drop upload, inline image previews with lightbox, new-conversation dialog with user search Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
//go:build integration
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
func TestMessagingFlow(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
|
||||
a := newClient(t)
|
||||
userA := registerUser(t, ts.URL, a, "alice@example.com", "Alice A")
|
||||
aCSRF := csrfFrom(t, a, ts.URL)
|
||||
b := newClient(t)
|
||||
userB := registerUser(t, ts.URL, b, "bob@example.com", "Bob B")
|
||||
bCSRF := csrfFrom(t, b, ts.URL)
|
||||
c := newClient(t)
|
||||
registerUser(t, ts.URL, c, "carol@example.com", "Carol C")
|
||||
|
||||
// Alice opens a DM with Bob
|
||||
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "dm", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusOK)
|
||||
var created struct {
|
||||
Conversation store.Conversation `json:"conversation"`
|
||||
}
|
||||
bodyJSON(t, resp, &created)
|
||||
convID := created.Conversation.ID
|
||||
|
||||
// dm is unique: creating again returns the same conversation
|
||||
resp = mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "dm", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusOK)
|
||||
var again struct {
|
||||
Conversation store.Conversation `json:"conversation"`
|
||||
}
|
||||
bodyJSON(t, resp, &again)
|
||||
if again.Conversation.ID != convID {
|
||||
t.Fatalf("dm not deduplicated: %s vs %s", again.Conversation.ID, convID)
|
||||
}
|
||||
|
||||
// Alice sends a rich-text message; script is sanitized away
|
||||
resp = mustPost(t, a, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
|
||||
"body": `<p>Hi <b>Bob</b>!</p><script>alert(1)</script>`,
|
||||
}, aCSRF, http.StatusCreated)
|
||||
var sent struct {
|
||||
Message store.Message `json:"message"`
|
||||
}
|
||||
bodyJSON(t, resp, &sent)
|
||||
if strings.Contains(sent.Message.Body, "<script") || !strings.Contains(sent.Message.Body, "<b>Bob</b>") {
|
||||
t.Fatalf("sanitization wrong: %q", sent.Message.Body)
|
||||
}
|
||||
|
||||
// Bob sees 1 unread; Carol sees nothing
|
||||
resp, err := b.Get(ts.URL + "/api/v1/conversations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bobConvs struct {
|
||||
Conversations []map[string]any `json:"conversations"`
|
||||
}
|
||||
bodyJSON(t, resp, &bobConvs)
|
||||
if len(bobConvs.Conversations) != 1 {
|
||||
t.Fatalf("bob conversations: %d", len(bobConvs.Conversations))
|
||||
}
|
||||
if got := bobConvs.Conversations[0]["unread"].(float64); got != 1 {
|
||||
t.Fatalf("bob unread = %v, want 1", got)
|
||||
}
|
||||
if title := bobConvs.Conversations[0]["title"]; title != "Alice A" {
|
||||
t.Fatalf("dm title for bob = %v", title)
|
||||
}
|
||||
|
||||
// Carol can't read or post
|
||||
cResp, err := c.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cResp.Body.Close()
|
||||
if cResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("carol read: %d", cResp.StatusCode)
|
||||
}
|
||||
|
||||
// Bob reads → unread 0
|
||||
mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/read", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
|
||||
resp, _ = b.Get(ts.URL + "/api/v1/conversations")
|
||||
bodyJSON(t, resp, &bobConvs)
|
||||
if got := bobConvs.Conversations[0]["unread"].(float64); got != 0 {
|
||||
t.Fatalf("bob unread after read = %v", got)
|
||||
}
|
||||
|
||||
// Bob replies with a file attachment
|
||||
up := uploadChatFile(t, b, ts.URL, bCSRF, "shot.png", pngBytes)
|
||||
resp = mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
|
||||
"body": "<p>see attached</p>", "attachments": []string{up},
|
||||
}, bCSRF, http.StatusCreated)
|
||||
bodyJSON(t, resp, &sent)
|
||||
if len(sent.Message.Attachments) != 1 || !sent.Message.Attachments[0].IsImage {
|
||||
t.Fatalf("attachment: %+v", sent.Message.Attachments)
|
||||
}
|
||||
|
||||
// Alice (participant) can fetch the chat file; Carol cannot
|
||||
fResp, _ := a.Get(ts.URL + "/files/" + up)
|
||||
fResp.Body.Close()
|
||||
if fResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("alice chat file: %d", fResp.StatusCode)
|
||||
}
|
||||
fResp, _ = c.Get(ts.URL + "/files/" + up)
|
||||
fResp.Body.Close()
|
||||
if fResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("carol chat file: %d, want 403", fResp.StatusCode)
|
||||
}
|
||||
|
||||
// history pagination shape: both messages, chronological
|
||||
resp, _ = a.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
|
||||
var hist struct {
|
||||
Messages []store.Message `json:"messages"`
|
||||
}
|
||||
bodyJSON(t, resp, &hist)
|
||||
if len(hist.Messages) != 2 || hist.Messages[0].SenderID != userA.ID {
|
||||
t.Fatalf("history: %d messages, first by %s", len(hist.Messages), hist.Messages[0].SenderID)
|
||||
}
|
||||
|
||||
// group conversation requires a title
|
||||
resp = postJSON(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "group", "participantIds": []string{userB.ID},
|
||||
}, aCSRF)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("untitled group: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "group", "title": "Standup", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusCreated).Body.Close()
|
||||
}
|
||||
|
||||
func uploadChatFile(t *testing.T, c *http.Client, ts, csrf, name string, content []byte) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write(content)
|
||||
mw.Close()
|
||||
req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/files", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set(csrfHeader, csrf)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload: %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
FileID string `json:"fileId"`
|
||||
}
|
||||
bodyJSON(t, resp, &out)
|
||||
return out.FileID
|
||||
}
|
||||
|
||||
func TestMentionInChatNotifies(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
a := newClient(t)
|
||||
registerUser(t, ts.URL, a, "ma@example.com", "Mention Alice")
|
||||
aCSRF := csrfFrom(t, a, ts.URL)
|
||||
b := newClient(t)
|
||||
userB := registerUser(t, ts.URL, b, "mb@example.com", "Mention Bob")
|
||||
|
||||
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "dm", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusOK)
|
||||
var created struct {
|
||||
Conversation store.Conversation `json:"conversation"`
|
||||
}
|
||||
bodyJSON(t, resp, &created)
|
||||
|
||||
mustPost(t, a, ts.URL+"/api/v1/conversations/"+created.Conversation.ID+"/messages",
|
||||
map[string]any{"body": "<p>hey @mention, look at this</p>"}, aCSRF, http.StatusCreated).Body.Close()
|
||||
|
||||
notifs, err := st.ListNotifications(t.Context(), userB.ID, false, "", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mentioned bool
|
||||
for _, n := range notifs {
|
||||
if n.Kind == "mention" {
|
||||
mentioned = true
|
||||
}
|
||||
}
|
||||
if !mentioned {
|
||||
t.Fatal("bob was not notified about the mention")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user