//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": `

Hi Bob!

`, }, aCSRF, http.StatusCreated) var sent struct { Message store.Message `json:"message"` } bodyJSON(t, resp, &sent) if strings.Contains(sent.Message.Body, "Bob") { 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": "

see attached

", "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": "

hey @mention, look at this

"}, 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") } }