Files
spsays-bff/internal/service/chat_service_test.go
T
2026-09-02 23:00:55 +05:30

2705 lines
86 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// The service's rules — blocks, Kautilya being read-only, entities being parsed
// server-side, a retry not re-broadcasting — are all statements about real rows,
// so they're exercised against a real database. Skipped without POSTGRES_URI.
func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *repository.Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set — skipping chat service integration tests")
}
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
chat := repository.NewChatRepo(pg)
// Nil realtime/R2/cache clients make publishing, uploads and caching
// no-ops — these tests are about the rules, and none of those services are
// reachable from a test run.
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg),
nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop()), chat, pg
}
// chatUserWithHandle returns both identifiers: ids for authoring, handles for
// the member-management API.
func chatUserWithHandle(t *testing.T, pg *repository.Postgres, label string) (id, handle string) {
t.Helper()
id = uuid.NewString()
suffix := id[:8]
handle = "chatsvc_" + suffix
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, "chatsvc-"+suffix+"@example.invalid", "Chat Svc "+label, handle); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id, handle
}
func chatUser(t *testing.T, pg *repository.Postgres, label string) string {
t.Helper()
id, _ := chatUserWithHandle(t, pg, label)
return id
}
// handleOf looks up a test user's handle for the member-management calls.
func handleOf(t *testing.T, pg *repository.Postgres, userID string) string {
t.Helper()
var h string
if err := pg.Pool.QueryRow(context.Background(),
`SELECT handle FROM users WHERE id = $1`, userID).Scan(&h); err != nil {
t.Fatalf("handle for %s: %v", userID, err)
}
return h
}
func cleanupConv(t *testing.T, pg *repository.Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id)
})
}
// Blocking must close a DM in BOTH directions. A one-way channel — where the
// person who blocked can still be written to — would be worse than refusing.
func TestOpenDM_BlockedBothDirections(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
blocks := repository.NewBlockRepo(pg)
t.Run("blocked by peer", func(t *testing.T) {
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
if err := blocks.Block(ctx, b, a); err != nil {
t.Fatal(err)
}
if _, err := svc.OpenDM(ctx, a, b); !errors.Is(err, ErrChatBlocked) {
t.Errorf("err = %v, want ErrChatBlocked", err)
}
})
t.Run("caller blocked the peer", func(t *testing.T) {
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
if err := blocks.Block(ctx, a, b); err != nil {
t.Fatal(err)
}
if _, err := svc.OpenDM(ctx, a, b); !errors.Is(err, ErrChatBlocked) {
t.Errorf("err = %v, want ErrChatBlocked", err)
}
})
}
// A block placed after the thread exists must stop new messages too — checking
// only at thread creation would leave an open channel behind.
func TestSend_BlockedAfterThreadExists(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "before"}); err != nil {
t.Fatalf("send before block: %v", err)
}
if err := repository.NewBlockRepo(pg).Block(ctx, b, a); err != nil {
t.Fatal(err)
}
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "after"}); !errors.Is(err, ErrChatBlocked) {
t.Errorf("send after block: err = %v, want ErrChatBlocked", err)
}
}
func TestSend_RejectsOutsidersAndEmptyBodies(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.Send(ctx, outsider, conv.ID, SendRequest{Body: "hi"}); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider send: err = %v, want ErrNotParticipant", err)
}
if _, err := svc.Messages(ctx, outsider, conv.ID, 0, 10); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider read: err = %v, want ErrNotParticipant", err)
}
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: " "}); !errors.Is(err, ErrChatEmptyBody) {
t.Errorf("whitespace-only send: err = %v, want ErrChatEmptyBody", err)
}
}
// Kautilya's tab and plumbing exist, but nothing consumes messages yet, so the
// thread must refuse rather than silently swallow them.
func TestSend_KautilyaIsReadOnly(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a := chatUser(t, pg, "A")
conv, err := svc.OpenAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "who is Kṛṣṇa?"}); !errors.Is(err, ErrChatReadOnly) {
t.Errorf("err = %v, want ErrChatReadOnly", err)
}
}
// Entities are derived server-side. A client that sends markup gets it parsed;
// it cannot dictate how its text is decorated in someone else's thread.
func TestSend_ParsesEntitiesServerSide(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "read *this* at https://prabhupadasays.org"})
if err != nil {
t.Fatal(err)
}
if msg.Body != "read this at https://prabhupadasays.org" {
t.Errorf("stored body = %q — markers should be stripped", msg.Body)
}
var spans []Entity
if err := json.Unmarshal(msg.Entities, &spans); err != nil {
t.Fatalf("entities: %v (%s)", err, msg.Entities)
}
kinds := map[string]bool{}
for _, e := range spans {
kinds[e.Type] = true
}
if !kinds[EntityBold] || !kinds[EntityURL] {
t.Errorf("entities = %+v, want a bold span and a url span", spans)
}
}
// A retried send must return the stored message, not a second bubble.
func TestSend_RetryIsIdempotent(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
id := uuid.NewString()
req := SendRequest{Body: "only once", ClientMsgID: &id}
first, err := svc.Send(ctx, a, conv.ID, req)
if err != nil {
t.Fatal(err)
}
again, err := svc.Send(ctx, a, conv.ID, req)
if err != nil {
t.Fatal(err)
}
if again.ID != first.ID {
t.Errorf("retry created a second message: %s vs %s", again.ID, first.ID)
}
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
if len(msgs) != 1 {
t.Errorf("thread has %d messages after a retry, want 1", len(msgs))
}
}
func TestIsParticipant_DistinguishesDenialFromFailure(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
ok, err := svc.IsParticipant(ctx, a, conv.ID)
if err != nil || !ok {
t.Errorf("member: (%v, %v), want (true, nil)", ok, err)
}
// Not a member is a plain denial, not an error — otherwise the channel
// rule would return 500 instead of 403 for every outsider.
ok, err = svc.IsParticipant(ctx, outsider, conv.ID)
if err != nil || ok {
t.Errorf("outsider: (%v, %v), want (false, nil)", ok, err)
}
}
func TestKindsForTab(t *testing.T) {
if got := KindsForTab("kautilya"); len(got) != 1 || got[0] != model.ConversationAI {
t.Errorf("kautilya tab = %v", got)
}
for _, tab := range []string{"chats", ""} {
got := KindsForTab(tab)
if len(got) != 2 || got[0] != model.ConversationDM || got[1] != model.ConversationGroup {
t.Errorf("tab %q = %v, want dm+group", tab, got)
}
}
}
// Muting silences alerts for one participant without touching delivery: the
// other member is unaffected, and unread keeps climbing for the muter.
func TestSetMute(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if muted, _ := svc.IsMuted(ctx, b, conv.ID); muted {
t.Error("thread starts muted")
}
until, err := svc.SetMute(ctx, b, conv.ID, MuteEightHours)
if err != nil {
t.Fatal(err)
}
if until == nil || !until.After(time.Now()) {
t.Fatalf("muted_until = %v, want a future time", until)
}
if muted, _ := svc.IsMuted(ctx, b, conv.ID); !muted {
t.Error("not muted after an 8h mute")
}
// Mute is per participant: A must be unaffected by B muting.
if muted, _ := svc.IsMuted(ctx, a, conv.ID); muted {
t.Error("muting for B also muted A")
}
// Delivery continues while muted — unread still accrues.
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "still arrives"}); err != nil {
t.Fatal(err)
}
inbox, err := svc.Inbox(ctx, b, "chats", 10)
if err != nil {
t.Fatal(err)
}
var row *model.InboxItem
for i := range inbox {
if inbox[i].ID == conv.ID {
row = &inbox[i]
}
}
if row == nil {
t.Fatal("muted thread vanished from the inbox")
}
if row.Unread == 0 {
t.Error("muted thread stopped counting unread — mute silences alerts, not delivery")
}
if row.MutedUntil == nil {
t.Error("inbox row does not report muted_until, so the client can't show the muted icon")
}
if _, err := svc.SetMute(ctx, b, conv.ID, MuteOff); err != nil {
t.Fatal(err)
}
if muted, _ := svc.IsMuted(ctx, b, conv.ID); muted {
t.Error("still muted after unmuting")
}
p, _ := chat.Participant(ctx, conv.ID, b)
if p.MutedUntil != nil {
t.Errorf("muted_until = %v after unmute, want NULL", p.MutedUntil)
}
}
// A mute whose deadline has passed reads as unmuted, so no sweep job is needed.
func TestIsMuted_ExpiredMuteReadsAsUnmuted(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
past := time.Now().Add(-time.Hour)
if err := chat.SetMute(ctx, conv.ID, b, &past); err != nil {
t.Fatal(err)
}
if muted, err := svc.IsMuted(ctx, b, conv.ID); err != nil || muted {
t.Errorf("expired mute: muted = %v, err = %v — want false", muted, err)
}
}
func TestSetMute_RejectsUnknownDurationAndOutsiders(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.SetMute(ctx, a, conv.ID, "forever-and-ever"); !errors.Is(err, ErrChatBadMuteDuration) {
t.Errorf("bad duration: err = %v, want ErrChatBadMuteDuration", err)
}
if _, err := svc.SetMute(ctx, outsider, conv.ID, MuteAlways); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider mute: err = %v, want ErrNotParticipant", err)
}
}
// "Always" must not silently expire — it is stored far enough out that no
// realistic clock reaches it.
func TestSetMute_AlwaysDoesNotExpire(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
until, err := svc.SetMute(ctx, a, conv.ID, MuteAlways)
if err != nil {
t.Fatal(err)
}
if until.Year() < 9000 {
t.Errorf("always-mute expires in %d", until.Year())
}
if muted, _ := svc.IsMuted(ctx, a, conv.ID); !muted {
t.Error("always-mute does not read as muted")
}
}
// Replying must be confined to the thread you are in. The foreign key alone
// only proves the target exists somewhere, so without the write-side check a
// client could quote a conversation it was never part of — and the reply
// preview would then render that text to everyone here.
func TestSend_ReplyTargetMustBeInSameConversation(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
here, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, here.ID)
elsewhere, err := svc.OpenDM(ctx, a, c)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, elsewhere.ID)
secret, err := svc.Send(ctx, a, elsewhere.ID, SendRequest{Body: "private to C"})
if err != nil {
t.Fatal(err)
}
_, err = svc.Send(ctx, a, here.ID, SendRequest{Body: "quoting", ReplyToID: &secret.ID})
if !errors.Is(err, repository.ErrReplyTargetNotHere) {
t.Fatalf("cross-thread reply: err = %v, want ErrReplyTargetNotHere", err)
}
// And nothing was persisted by the rejected attempt.
msgs, _ := svc.Messages(ctx, a, here.ID, 0, 10)
if len(msgs) != 0 {
t.Errorf("rejected reply still wrote %d message(s)", len(msgs))
}
}
// A reply within the thread is hydrated for rendering: the bubble needs the
// quoted sender and a line of text.
func TestSend_ReplyIsHydratedOnRead(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"})
if err != nil {
t.Fatal(err)
}
if _, err := svc.Send(ctx, b, conv.ID, SendRequest{Body: "answering", ReplyToID: &original.ID}); err != nil {
t.Fatal(err)
}
msgs, err := svc.Messages(ctx, a, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
// Newest first.
reply := msgs[0]
if reply.ReplyTo == nil {
t.Fatal("reply has no hydrated preview")
}
if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" {
t.Errorf("preview = %+v, want the original message", reply.ReplyTo)
}
if reply.ReplyTo.SenderName == "" {
t.Error("preview has no sender name — the quote line needs it")
}
// A plain message carries no preview.
if msgs[1].ReplyTo != nil {
t.Errorf("non-reply carries a preview: %+v", msgs[1].ReplyTo)
}
}
// The thread screen needs participants by name in one request — previously it
// scanned both inbox tabs to find a single conversation.
func TestDetail(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hello"}); err != nil {
t.Fatal(err)
}
d, err := svc.Detail(ctx, b, conv.ID)
if err != nil {
t.Fatal(err)
}
if len(d.Participants) != 2 {
t.Errorf("participants = %d, want 2", len(d.Participants))
}
for _, p := range d.Participants {
if p.Name == "" || p.Handle == "" {
t.Errorf("participant %+v is missing a name/handle — the reply banner needs them", p)
}
}
if d.Peer == nil || d.Peer.ID != a {
t.Errorf("peer = %+v, want user A", d.Peer)
}
if d.Unread != 1 {
t.Errorf("unread = %d, want 1", d.Unread)
}
// Same 404-shaped denial as every other chat read.
if _, err := svc.Detail(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider: err = %v, want ErrNotParticipant", err)
}
}
// A group's detail carries every member and no peer.
func TestDetail_GroupHasNoPeer(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.CreateGroup(ctx, a, "Kīrtana", []string{handleOf(t, pg, b), handleOf(t, pg, c)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
d, err := svc.Detail(ctx, a, conv.ID)
if err != nil {
t.Fatal(err)
}
if d.Peer != nil {
t.Errorf("group has a peer: %+v", d.Peer)
}
if len(d.Participants) != 3 {
t.Errorf("participants = %d, want 3", len(d.Participants))
}
}
// A link card may only be attached for a URL the parser actually found in the
// body. Without this a message could carry a card for a link it never mentions
// — the text says one thing, the card says another.
func TestSend_PreviewURLMustAppearInBody(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
elsewhere := "https://example.org/not-mentioned"
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "just some text with no link",
PreviewURL: &elsewhere,
})
if err != nil {
t.Fatal(err)
}
for _, att := range msg.Attachments {
if att.Kind == model.AttachmentLink {
t.Errorf("attached a card for a URL not in the body: %+v", att)
}
}
}
// Media keys are namespaced by conversation and uploader. Attaching a key from
// outside the caller's own prefix must be refused, or a guessed key could pull
// a file from a thread the caller is not in.
func TestSend_RejectsForeignAttachmentKeys(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
foreign := []MediaAttachment{
{Key: "chat/" + conv.ID + "/" + b + "/deadbeef", Mime: "image/png"}, // another user's prefix
{Key: "chat/some-other-conversation/" + a + "/deadbeef", Mime: "image/png"},
{Key: "avatars/" + a + "/photo.png", Mime: "image/png"}, // outside chat entirely
{Key: "../../etc/passwd", Mime: "image/png"},
}
for _, m := range foreign {
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "look at this", Attachments: []MediaAttachment{m},
})
if err != nil {
t.Fatalf("send: %v", err)
}
if len(msg.Attachments) != 0 {
t.Errorf("key %q was attached; want it refused", m.Key)
}
}
}
// Uploads require the private bucket. Falling back to the public one would put
// chat media at a URL anyone could fetch.
func TestPresignAttachment_RequiresPrivateBucket(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
// This service was built with a nil R2 (see testChatService).
if _, _, err := svc.PresignAttachment(ctx, a, conv.ID, "image/png"); !errors.Is(err, ErrChatMediaOff) {
t.Errorf("err = %v, want ErrChatMediaOff", err)
}
// Membership is still checked before anything else.
outsider := chatUser(t, pg, "C")
if _, _, err := svc.PresignAttachment(ctx, outsider, conv.ID, "image/png"); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider: err = %v, want ErrNotParticipant", err)
}
}
func TestAttachmentKindFor(t *testing.T) {
cases := map[string]string{
"image/png": model.AttachmentImage,
"image/webp": model.AttachmentImage,
"video/mp4": model.AttachmentVideo,
"audio/mpeg": model.AttachmentAudio,
"application/pdf": model.AttachmentFile,
}
for mime, want := range cases {
if got, ok := attachmentKindFor(mime); !ok || got != want {
t.Errorf("attachmentKindFor(%q) = (%q, %v), want %q", mime, got, ok, want)
}
}
// Anything executable or unknown is refused rather than stored as a file.
for _, mime := range []string{"text/html", "application/x-sh", "application/octet-stream", ""} {
if _, ok := attachmentKindFor(mime); ok {
t.Errorf("attachmentKindFor(%q) accepted it", mime)
}
}
}
// Attachments always serialize as an array so clients need no nil check.
func TestMessages_AttachmentsAlwaysPresent(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "plain"}); err != nil {
t.Fatal(err)
}
msgs, err := svc.Messages(ctx, a, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if msgs[0].Attachments == nil {
t.Error("attachments is nil, want an empty slice")
}
encoded, _ := json.Marshal(msgs[0].Attachments)
if string(encoded) != "[]" {
t.Errorf("serialized as %s, want []", encoded)
}
}
// A photo with no caption is an ordinary message — only a message with neither
// text nor attachments is empty.
func TestSend_AttachmentWithoutCaptionIsAllowed(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
key := "chat/" + conv.ID + "/" + a + "/abc123"
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "",
Attachments: []MediaAttachment{{Key: key, Mime: "image/jpeg"}},
})
if err != nil {
t.Fatalf("captionless attachment rejected: %v", err)
}
if len(msg.Attachments) != 1 || msg.Attachments[0].Kind != model.AttachmentImage {
t.Errorf("attachments = %+v, want one image", msg.Attachments)
}
if msg.Body != "" {
t.Errorf("body = %q, want empty", msg.Body)
}
// Neither text nor attachments is still empty.
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: " "}); !errors.Is(err, ErrChatEmptyBody) {
t.Errorf("empty send: err = %v, want ErrChatEmptyBody", err)
}
}
// Starring is scoped to the thread: a message id from another conversation
// must be refused, or a caller could star something they cannot see.
func TestSetStar_ScopedToConversation(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
here, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, here.ID)
elsewhere, err := svc.OpenDM(ctx, a, c)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, elsewhere.ID)
mine, err := svc.Send(ctx, a, here.ID, SendRequest{Body: "star me"})
if err != nil {
t.Fatal(err)
}
other, err := svc.Send(ctx, a, elsewhere.ID, SendRequest{Body: "different thread"})
if err != nil {
t.Fatal(err)
}
if err := svc.SetStar(ctx, a, here.ID, mine.ID, true); err != nil {
t.Fatalf("star own thread's message: %v", err)
}
if err := svc.SetStar(ctx, a, here.ID, other.ID, true); !errors.Is(err, repository.ErrMessageNotHere) {
t.Errorf("cross-thread star: err = %v, want ErrMessageNotHere", err)
}
starred, err := svc.Starred(ctx, a, here.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(starred) != 1 || starred[0].ID != mine.ID {
t.Errorf("starred = %d message(s), want just the one in this thread", len(starred))
}
// Stars are per user — B sees none of A's.
theirs, _ := svc.Starred(ctx, b, here.ID, 10)
if len(theirs) != 0 {
t.Errorf("other participant sees %d starred, want 0", len(theirs))
}
// Unstar is idempotent.
for i := 0; i < 2; i++ {
if err := svc.SetStar(ctx, a, here.ID, mine.ID, false); err != nil {
t.Fatalf("unstar: %v", err)
}
}
if after, _ := svc.Starred(ctx, a, here.ID, 10); len(after) != 0 {
t.Errorf("still starred after unstar: %d", len(after))
}
}
// The chat profile's tabs are a kind filter over the same gallery index.
func TestGallery_SplitsMediaFromLinks(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "with a photo",
Attachments: []MediaAttachment{{Key: "chat/" + conv.ID + "/" + a + "/pic", Mime: "image/jpeg"}},
})
if err != nil {
t.Fatal(err)
}
url := "https://example.org/x"
if _, err := chat.AddAttachment(ctx, repository.AddAttachmentParams{
MessageID: msg.ID, ConversationID: conv.ID, Ord: 1,
Kind: model.AttachmentLink, URL: &url,
}); err != nil {
t.Fatal(err)
}
media, err := svc.Gallery(ctx, a, conv.ID, "media", nil, 50)
if err != nil {
t.Fatal(err)
}
if len(media) != 1 || media[0].Kind != model.AttachmentImage {
t.Errorf("media tab = %+v, want one image", media)
}
links, err := svc.Gallery(ctx, a, conv.ID, "links", nil, 50)
if err != nil {
t.Fatal(err)
}
if len(links) != 1 || links[0].Kind != model.AttachmentLink {
t.Errorf("links tab = %+v, want one link", links)
}
outsider := chatUser(t, pg, "C")
if _, err := svc.Gallery(ctx, outsider, conv.ID, "media", nil, 50); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider gallery: err = %v, want ErrNotParticipant", err)
}
}
// The profile line reads "friends since" off the accepted follow, in whichever
// direction it happened, and reports one-way follows honestly.
func TestDetail_Relationship(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
follows := repository.NewFollowRepo(pg)
// No edges yet.
d, err := svc.Detail(ctx, a, conv.ID)
if err != nil {
t.Fatal(err)
}
if d.Relationship == nil {
t.Fatal("DM detail has no relationship")
}
if d.Relationship.Following || d.Relationship.FollowedBy || d.Relationship.FriendsSince != nil {
t.Errorf("relationship = %+v, want all-false with no date", d.Relationship)
}
// A pending request is not a friendship.
if _, err := follows.Request(ctx, a, b); err != nil {
t.Fatal(err)
}
d, _ = svc.Detail(ctx, a, conv.ID)
if d.Relationship.Following || d.Relationship.FriendsSince != nil {
t.Errorf("pending request counted as a follow: %+v", d.Relationship)
}
// Accepted: one-way.
if _, err := follows.Accept(ctx, a, b); err != nil {
t.Fatal(err)
}
d, _ = svc.Detail(ctx, a, conv.ID)
if !d.Relationship.Following || d.Relationship.FollowedBy {
t.Errorf("one-way follow reported as %+v", d.Relationship)
}
if d.Relationship.FriendsSince == nil {
t.Error("friends_since is nil after an accepted follow")
}
// From B's side the same edge is a follower, not a following.
fromB, _ := svc.Detail(ctx, b, conv.ID)
if fromB.Relationship.Following || !fromB.Relationship.FollowedBy {
t.Errorf("peer's view = %+v, want followed_by only", fromB.Relationship)
}
}
// A group has no single peer, so no relationship block.
func TestDetail_GroupHasNoRelationship(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.CreateGroup(ctx, a, "Group", []string{handleOf(t, pg, b)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
d, err := svc.Detail(ctx, a, conv.ID)
if err != nil {
t.Fatal(err)
}
if d.Relationship != nil {
t.Errorf("group carries a relationship: %+v", d.Relationship)
}
}
// "Delete for everyone" is the sender's alone — letting a recipient erase
// someone else's words for all participants would be a moderation power, not a
// chat feature. Anyone may hide a message for themselves.
func TestDeleteMessage_Scopes(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
fromA, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "A wrote this"})
if err != nil {
t.Fatal(err)
}
// B cannot delete A's message for everyone.
if err := svc.DeleteMessage(ctx, b, fromA.ID, DeleteForEveryone); !errors.Is(err, ErrChatNotSender) {
t.Errorf("recipient delete-for-everyone: err = %v, want ErrChatNotSender", err)
}
// But B can hide it from their own view, leaving A's intact.
if err := svc.DeleteMessage(ctx, b, fromA.ID, DeleteForMe); err != nil {
t.Fatalf("delete for me: %v", err)
}
bSees, _ := svc.Messages(ctx, b, conv.ID, 0, 10)
if len(bSees) != 0 {
t.Errorf("hider still sees %d message(s)", len(bSees))
}
aSees, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
if len(aSees) != 1 {
t.Errorf("sender sees %d, want their message still there", len(aSees))
}
// A deletes for everyone: the row survives as a tombstone with its text
// gone, so seq stays gapless and the bubble can be replaced in place.
if err := svc.DeleteMessage(ctx, a, fromA.ID, DeleteForEveryone); err != nil {
t.Fatalf("delete for everyone: %v", err)
}
after, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
if len(after) != 1 {
t.Fatalf("tombstone missing: %d messages", len(after))
}
if after[0].DeletedAt == nil {
t.Error("message not marked deleted")
}
if after[0].Body != "" {
t.Errorf("body = %q, want the text cleared", after[0].Body)
}
}
// Forwarding checks BOTH memberships. Checking only the destination would turn
// a message id into a way to copy text out of a thread you were never in.
func TestForwardMessage_RequiresAccessToSourceAndDestination(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
private, err := svc.OpenDM(ctx, b, c) // A is NOT in this one
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, private.ID)
mine, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, mine.ID)
secret, err := svc.Send(ctx, b, private.ID, SendRequest{Body: "not for A"})
if err != nil {
t.Fatal(err)
}
if _, err := svc.ForwardMessage(ctx, a, secret.ID, mine.ID); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("forwarding from an unseen thread: err = %v, want ErrNotParticipant", err)
}
// A legitimate forward carries the text and records its origin.
ours, err := svc.Send(ctx, a, mine.ID, SendRequest{Body: "*worth* sharing"})
if err != nil {
t.Fatal(err)
}
elsewhere, err := svc.OpenDM(ctx, a, c)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, elsewhere.ID)
fwd, err := svc.ForwardMessage(ctx, a, ours.ID, elsewhere.ID)
if err != nil {
t.Fatalf("forward: %v", err)
}
if fwd.Body != ours.Body {
t.Errorf("forwarded body = %q, want %q", fwd.Body, ours.Body)
}
if fwd.ForwardedFromID == nil || *fwd.ForwardedFromID != ours.ID {
t.Errorf("forwarded_from_id = %v, want %s", fwd.ForwardedFromID, ours.ID)
}
if fwd.ConversationID != elsewhere.ID {
t.Errorf("landed in %s, want %s", fwd.ConversationID, elsewhere.ID)
}
}
func TestForwardMessage_RefusesDeleted(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
other, err := svc.OpenDM(ctx, a, chatUser(t, pg, "C"))
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, other.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "gone soon"})
if err != nil {
t.Fatal(err)
}
if err := svc.DeleteMessage(ctx, a, msg.ID, DeleteForEveryone); err != nil {
t.Fatal(err)
}
if _, err := svc.ForwardMessage(ctx, a, msg.ID, other.ID); !errors.Is(err, ErrMessageDeleted) {
t.Errorf("err = %v, want ErrMessageDeleted", err)
}
}
// A report copies its evidence in, so moderators read the report rather than
// the live thread — and the evidence survives the sender deleting it.
func TestReportMessage_SnapshotsEvidence(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
for i := 0; i < 3; i++ {
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "context"}); err != nil {
t.Fatal(err)
}
}
bad, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the offending message"})
if err != nil {
t.Fatal(err)
}
if err := svc.ReportMessage(ctx, outsider, bad.ID, "abuse", "n/a"); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider report: err = %v, want ErrNotParticipant", err)
}
if err := svc.ReportMessage(ctx, b, bad.ID, " ", "no category"); !errors.Is(err, ErrChatBadReport) {
t.Errorf("blank category: err = %v, want ErrChatBadReport", err)
}
if err := svc.ReportMessage(ctx, b, bad.ID, "abuse", "please review"); err != nil {
t.Fatalf("report: %v", err)
}
var snapshot []byte
var reported *string
if err := pg.Pool.QueryRow(ctx, `
SELECT snapshot, reported_user_id FROM message_reports WHERE message_id = $1`,
bad.ID).Scan(&snapshot, &reported); err != nil {
t.Fatal(err)
}
if reported == nil || *reported != a {
t.Errorf("reported_user_id = %v, want the sender", reported)
}
var payload struct {
Messages []struct {
ID string `json:"id"`
Body string `json:"body"`
} `json:"messages"`
}
if err := json.Unmarshal(snapshot, &payload); err != nil {
t.Fatalf("snapshot is not valid JSON: %v", err)
}
if len(payload.Messages) < 2 {
t.Errorf("snapshot captured %d messages, want the reported one plus context", len(payload.Messages))
}
found := false
for _, m := range payload.Messages {
if m.ID == bad.ID && m.Body == "the offending message" {
found = true
}
}
if !found {
t.Error("snapshot does not contain the reported message's text")
}
// The evidence must outlive the sender deleting the message.
if err := svc.DeleteMessage(ctx, a, bad.ID, DeleteForEveryone); err != nil {
t.Fatal(err)
}
var stillThere []byte
if err := pg.Pool.QueryRow(ctx,
`SELECT snapshot FROM message_reports WHERE reporter_id = $1`, b).Scan(&stillThere); err != nil {
t.Fatalf("report vanished with the message: %v", err)
}
if !bytes.Contains(stillThere, []byte("the offending message")) {
t.Error("snapshot lost its evidence when the message was deleted")
}
}
func groupOf(t *testing.T, svc *ChatService, pg *repository.Postgres, owner string, members ...string) string {
t.Helper()
handles := make([]string, 0, len(members))
for _, m := range members {
handles = append(handles, handleOf(t, pg, m))
}
conv, err := svc.CreateGroup(context.Background(), owner, "Kīrtana", handles)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
return conv.ID
}
// An admins-only group is an announcement channel: members read, admins post.
func TestSendPolicy_AdminsOnly(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member")
id := groupOf(t, svc, pg, owner, member)
// Default lets everyone post — the migration must not mute existing groups.
if _, err := svc.Send(ctx, member, id, SendRequest{Body: "hello"}); err != nil {
t.Fatalf("member send under the default policy: %v", err)
}
admins := model.SendPolicyAdmins
if err := svc.UpdateGroup(ctx, owner, id, nil, &admins, nil); err != nil {
t.Fatal(err)
}
if _, err := svc.Send(ctx, member, id, SendRequest{Body: "still talking"}); !errors.Is(err, ErrChatAdminsOnly) {
t.Errorf("member send under admins-only: err = %v, want ErrChatAdminsOnly", err)
}
if _, err := svc.Send(ctx, owner, id, SendRequest{Body: "announcement"}); err != nil {
t.Errorf("owner blocked by their own policy: %v", err)
}
// Promoting the member restores their ability to post.
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), model.RoleAdmin); err != nil {
t.Fatal(err)
}
if _, err := svc.Send(ctx, member, id, SendRequest{Body: "now an admin"}); err != nil {
t.Errorf("promoted member still blocked: %v", err)
}
// Reverting the policy lets everyone back in.
everyone := model.SendPolicyEveryone
if err := svc.UpdateGroup(ctx, owner, id, nil, &everyone, nil); err != nil {
t.Fatal(err)
}
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), model.RoleMember); err != nil {
t.Fatal(err)
}
if _, err := svc.Send(ctx, member, id, SendRequest{Body: "talking again"}); err != nil {
t.Errorf("member blocked after reverting the policy: %v", err)
}
}
// Only admins administer, and only the owner changes roles — an admin
// promoting other admins would let one account escalate the whole group.
func TestGroupAdministration_Permissions(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner := chatUser(t, pg, "owner")
admin := chatUser(t, pg, "admin")
member := chatUser(t, pg, "member")
outsider := chatUser(t, pg, "outsider")
id := groupOf(t, svc, pg, owner, admin, member)
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, admin), model.RoleAdmin); err != nil {
t.Fatal(err)
}
t.Run("a plain member cannot administer", func(t *testing.T) {
if err := svc.AddMembers(ctx, member, id, []string{handleOf(t, pg, outsider)}); !errors.Is(err, ErrChatNotAdmin) {
t.Errorf("add: err = %v, want ErrChatNotAdmin", err)
}
title := "Renamed"
if err := svc.UpdateGroup(ctx, member, id, &title, nil, nil); !errors.Is(err, ErrChatNotAdmin) {
t.Errorf("rename: err = %v, want ErrChatNotAdmin", err)
}
})
t.Run("an admin can add but cannot change roles", func(t *testing.T) {
if err := svc.AddMembers(ctx, admin, id, []string{handleOf(t, pg, outsider)}); err != nil {
t.Errorf("admin add: %v", err)
}
if err := svc.SetMemberRole(ctx, admin, id, handleOf(t, pg, member), model.RoleAdmin); !errors.Is(err, ErrChatNotAdmin) {
t.Errorf("admin promoting: err = %v, want ErrChatNotAdmin", err)
}
})
t.Run("the owner is immutable", func(t *testing.T) {
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, owner), model.RoleMember); !errors.Is(err, ErrChatOwnerImmutable) {
t.Errorf("self-demote: err = %v, want ErrChatOwnerImmutable", err)
}
if err := svc.RemoveMember(ctx, admin, id, handleOf(t, pg, owner)); !errors.Is(err, ErrChatOwnerImmutable) {
t.Errorf("admin removing owner: err = %v, want ErrChatOwnerImmutable", err)
}
})
t.Run("an admin cannot remove another admin", func(t *testing.T) {
second := chatUser(t, pg, "admin2")
if err := svc.AddMembers(ctx, owner, id, []string{handleOf(t, pg, second)}); err != nil {
t.Fatal(err)
}
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, second), model.RoleAdmin); err != nil {
t.Fatal(err)
}
if err := svc.RemoveMember(ctx, admin, id, handleOf(t, pg, second)); !errors.Is(err, ErrChatNotAdmin) {
t.Errorf("admin removing admin: err = %v, want ErrChatNotAdmin", err)
}
// The owner can.
if err := svc.RemoveMember(ctx, owner, id, handleOf(t, pg, second)); err != nil {
t.Errorf("owner removing admin: %v", err)
}
})
t.Run("bad roles and policies are rejected", func(t *testing.T) {
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), "superuser"); !errors.Is(err, ErrChatBadRole) {
t.Errorf("err = %v, want ErrChatBadRole", err)
}
bad := "nobody"
if err := svc.UpdateGroup(ctx, owner, id, nil, &bad, nil); !errors.Is(err, ErrChatBadPolicy) {
t.Errorf("err = %v, want ErrChatBadPolicy", err)
}
})
t.Run("group-only operations refuse DMs", func(t *testing.T) {
dm, err := svc.OpenDM(ctx, owner, member)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, dm.ID)
if err := svc.AddMembers(ctx, owner, dm.ID, []string{handleOf(t, pg, outsider)}); !errors.Is(err, ErrChatNotGroup) {
t.Errorf("err = %v, want ErrChatNotGroup", err)
}
})
}
// Anyone may leave, and a departing owner hands the group on — otherwise it
// would be left with nobody able to administer it.
func TestRemoveMember_LeavingAndSuccession(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
owner := chatUser(t, pg, "owner")
admin := chatUser(t, pg, "admin")
member := chatUser(t, pg, "member")
id := groupOf(t, svc, pg, owner, admin, member)
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, admin), model.RoleAdmin); err != nil {
t.Fatal(err)
}
// A member removes themselves without any admin rights.
if err := svc.RemoveMember(ctx, member, id, handleOf(t, pg, member)); err != nil {
t.Fatalf("self-removal: %v", err)
}
if _, err := chat.Participant(ctx, id, member); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("after leaving: err = %v, want ErrNotParticipant", err)
}
// The owner leaves; the admin inherits.
if err := svc.RemoveMember(ctx, owner, id, handleOf(t, pg, owner)); err != nil {
t.Fatalf("owner leaving: %v", err)
}
successor, err := chat.Participant(ctx, id, admin)
if err != nil {
t.Fatal(err)
}
if successor.Role != model.RoleOwner {
t.Errorf("successor role = %q, want owner", successor.Role)
}
}
// Re-adding someone who left restarts them as a plain member; it must not
// resurrect the admin rights they had before.
func TestAddMembers_RejoinDoesNotRestoreAdmin(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
owner, other := chatUser(t, pg, "owner"), chatUser(t, pg, "other")
id := groupOf(t, svc, pg, owner, other)
if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, other), model.RoleAdmin); err != nil {
t.Fatal(err)
}
if err := svc.RemoveMember(ctx, other, id, handleOf(t, pg, other)); err != nil {
t.Fatal(err)
}
if err := svc.AddMembers(ctx, owner, id, []string{handleOf(t, pg, other)}); err != nil {
t.Fatal(err)
}
back, err := chat.Participant(ctx, id, other)
if err != nil {
t.Fatalf("rejoined member is not a participant: %v", err)
}
if back.Role != model.RoleMember {
t.Errorf("role after rejoining = %q, want member", back.Role)
}
}
// The caller's own role rides on Detail so the UI can show admin controls
// without deriving it from the member list.
func TestDetail_ReportsMyRoleAndMemberRoles(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member")
id := groupOf(t, svc, pg, owner, member)
asOwner, err := svc.Detail(ctx, owner, id)
if err != nil {
t.Fatal(err)
}
if asOwner.MyRole != model.RoleOwner {
t.Errorf("my_role = %q, want owner", asOwner.MyRole)
}
if asOwner.SendPolicy != model.SendPolicyEveryone {
t.Errorf("send_policy = %q, want everyone", asOwner.SendPolicy)
}
// The owner sorts first in the member list.
if len(asOwner.Participants) != 2 || asOwner.Participants[0].Role != model.RoleOwner {
t.Errorf("participants = %+v, want the owner first", asOwner.Participants)
}
asMember, _ := svc.Detail(ctx, member, id)
if asMember.MyRole != model.RoleMember {
t.Errorf("member's my_role = %q, want member", asMember.MyRole)
}
}
// A group's photo must come from that group's own upload prefix. Without the
// check an admin could point avatar_key at another thread's media, and every
// member here would be served a file from a conversation they are not in.
func TestUpdateGroup_AvatarKeyMustBelongToThisGroup(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member")
id := groupOf(t, svc, pg, owner, member)
other := groupOf(t, svc, pg, owner)
foreign := []string{
"chat/" + other + "/avatar/deadbeef", // another group's avatar
"chat/" + id + "/" + owner + "/photo", // a message attachment here
"avatars/" + owner + "/me.png", // outside chat entirely
"../../etc/passwd",
}
for _, key := range foreign {
k := key
if err := svc.UpdateGroup(ctx, owner, id, nil, nil, &k); !errors.Is(err, ErrChatBadAvatarKey) {
t.Errorf("key %q: err = %v, want ErrChatBadAvatarKey", key, err)
}
}
// A key from this group's own prefix is accepted and stored.
valid := "chat/" + id + "/avatar/abc123"
if err := svc.UpdateGroup(ctx, owner, id, nil, nil, &valid); err != nil {
t.Fatalf("valid avatar key rejected: %v", err)
}
conv, err := chat.Conversation(ctx, id)
if err != nil {
t.Fatal(err)
}
if conv.AvatarKey == nil || *conv.AvatarKey != valid {
t.Errorf("avatar_key = %v, want %q", conv.AvatarKey, valid)
}
// And a plain member cannot set it at all.
another := "chat/" + id + "/avatar/xyz"
if err := svc.UpdateGroup(ctx, member, id, nil, nil, &another); !errors.Is(err, ErrChatNotAdmin) {
t.Errorf("member setting the photo: err = %v, want ErrChatNotAdmin", err)
}
}
// Reading a group photo is members-only, and says so clearly when unset.
func TestGroupAvatarURL_Access(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, outsider := chatUser(t, pg, "owner"), chatUser(t, pg, "outsider")
id := groupOf(t, svc, pg, owner)
if _, err := svc.GroupAvatarURL(ctx, outsider, id); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider: err = %v, want ErrNotParticipant", err)
}
// A member of a group with no photo gets a clear "unset", not a 500.
if _, err := svc.GroupAvatarURL(ctx, owner, id); !errors.Is(err, ErrAvatarUnset) {
t.Errorf("no photo: err = %v, want ErrAvatarUnset", err)
}
}
// Only image uploads are offered, and only to admins.
func TestPresignGroupAvatar_Restrictions(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member")
id := groupOf(t, svc, pg, owner, member)
if _, _, err := svc.PresignGroupAvatar(ctx, member, id, "image/png"); !errors.Is(err, ErrChatNotAdmin) {
t.Errorf("member: err = %v, want ErrChatNotAdmin", err)
}
// A PDF is a valid attachment but not a valid photo.
if _, _, err := svc.PresignGroupAvatar(ctx, owner, id, "application/pdf"); !errors.Is(err, ErrChatBadMedia) {
t.Errorf("pdf: err = %v, want ErrChatBadMedia", err)
}
// With R2 unconfigured in tests, an admin with a valid image gets as far as
// the storage check — proving the admin and MIME gates both passed.
if _, _, err := svc.PresignGroupAvatar(ctx, owner, id, "image/jpeg"); !errors.Is(err, ErrChatMediaOff) {
t.Errorf("admin with an image: err = %v, want ErrChatMediaOff", err)
}
}
// Absent settings must read as the product defaults, or a user who never opened
// the settings page would get whatever zero values happen to be.
func TestSettings_DefaultsWhenNeverSaved(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a := chatUser(t, pg, "A")
got, err := svc.Settings(ctx, a)
if err != nil {
t.Fatal(err)
}
want := model.DefaultChatSettings()
if got.ReadReceipts != want.ReadReceipts || got.WhoCanMessage != want.WhoCanMessage ||
got.MinAccountAgeMonths != want.MinAccountAgeMonths || got.TrainKautilya != want.TrainKautilya {
t.Errorf("defaults = %+v, want %+v", got, want)
}
if !got.ReadReceipts || got.WhoCanMessage != model.WhoAnyone || !got.TrainKautilya {
t.Error("defaults must be: receipts on, anyone can message, training on")
}
}
func TestSaveSettings_Validation(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a := chatUser(t, pg, "A") // test users are created with no gender
base := model.DefaultChatSettings()
bad := base
bad.WhoCanMessage = "nobody-at-all"
if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatBadWhoCanMessage) {
t.Errorf("unknown policy: err = %v, want ErrChatBadWhoCanMessage", err)
}
for _, months := range []int{0, -1, 121} {
bad = base
bad.WhoCanMessage = model.WhoAccountAge
bad.MinAccountAgeMonths = months
if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatBadAccountAge) {
t.Errorf("%d months: err = %v, want ErrChatBadAccountAge", months, err)
}
}
// same_gender without a gender on the profile would silently do nothing,
// so it is refused with an error the UI can explain.
bad = base
bad.WhoCanMessage = model.WhoSameGender
if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatGenderUnset) {
t.Errorf("same_gender with no gender: err = %v, want ErrChatGenderUnset", err)
}
// Once a gender is set it becomes selectable.
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET gender='female' WHERE id=$1`, a); err != nil {
t.Fatal(err)
}
if saved, err := svc.SaveSettings(ctx, a, bad); err != nil {
t.Errorf("same_gender with a gender: %v", err)
} else if saved.WhoCanMessage != model.WhoSameGender || !saved.GenderSet {
t.Errorf("saved = %+v", saved)
}
}
// "Who can message you" gates STARTING a thread.
func TestWhoCanMessage_GatesNewConversations(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
follows := repository.NewFollowRepo(pg)
t.Run("friends only", func(t *testing.T) {
recipient, stranger := chatUser(t, pg, "R"), chatUser(t, pg, "S")
s := model.DefaultChatSettings()
s.WhoCanMessage = model.WhoFriends
if _, err := svc.SaveSettings(ctx, recipient, s); err != nil {
t.Fatal(err)
}
if _, err := svc.OpenDM(ctx, stranger, recipient); !errors.Is(err, ErrChatNotAccepting) {
t.Errorf("stranger: err = %v, want ErrChatNotAccepting", err)
}
// An accepted follow in either direction counts as a connection.
if _, err := follows.Request(ctx, stranger, recipient); err != nil {
t.Fatal(err)
}
if _, err := follows.Accept(ctx, stranger, recipient); err != nil {
t.Fatal(err)
}
conv, err := svc.OpenDM(ctx, stranger, recipient)
if err != nil {
t.Fatalf("friend: %v", err)
}
cleanupConv(t, pg, conv.ID)
})
t.Run("account age", func(t *testing.T) {
recipient, fresh := chatUser(t, pg, "R"), chatUser(t, pg, "F")
s := model.DefaultChatSettings()
s.WhoCanMessage = model.WhoAccountAge
s.MinAccountAgeMonths = 6
if _, err := svc.SaveSettings(ctx, recipient, s); err != nil {
t.Fatal(err)
}
if _, err := svc.OpenDM(ctx, fresh, recipient); !errors.Is(err, ErrChatNotAccepting) {
t.Errorf("new account: err = %v, want ErrChatNotAccepting", err)
}
// Age the account past the threshold.
if _, err := pg.Pool.Exec(ctx,
`UPDATE users SET created_at = now() - interval '1 year' WHERE id=$1`, fresh); err != nil {
t.Fatal(err)
}
conv, err := svc.OpenDM(ctx, fresh, recipient)
if err != nil {
t.Fatalf("old enough: %v", err)
}
cleanupConv(t, pg, conv.ID)
})
t.Run("same gender", func(t *testing.T) {
recipient, same, other := chatUser(t, pg, "R"), chatUser(t, pg, "S"), chatUser(t, pg, "O")
if _, err := pg.Pool.Exec(ctx,
`UPDATE users SET gender = CASE id WHEN $1 THEN 'female' WHEN $2 THEN 'female' ELSE 'male' END
WHERE id = ANY($3)`, recipient, same, []string{recipient, same, other}); err != nil {
t.Fatal(err)
}
s := model.DefaultChatSettings()
s.WhoCanMessage = model.WhoSameGender
if _, err := svc.SaveSettings(ctx, recipient, s); err != nil {
t.Fatal(err)
}
if _, err := svc.OpenDM(ctx, other, recipient); !errors.Is(err, ErrChatNotAccepting) {
t.Errorf("different gender: err = %v, want ErrChatNotAccepting", err)
}
conv, err := svc.OpenDM(ctx, same, recipient)
if err != nil {
t.Fatalf("same gender: %v", err)
}
cleanupConv(t, pg, conv.ID)
})
// Tightening the setting must not strand an existing conversation.
t.Run("existing threads keep working", func(t *testing.T) {
recipient, stranger := chatUser(t, pg, "R"), chatUser(t, pg, "S")
conv, err := svc.OpenDM(ctx, stranger, recipient)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
s := model.DefaultChatSettings()
s.WhoCanMessage = model.WhoFriends
if _, err := svc.SaveSettings(ctx, recipient, s); err != nil {
t.Fatal(err)
}
if _, err := svc.Send(ctx, stranger, conv.ID, SendRequest{Body: "still here"}); err != nil {
t.Errorf("existing thread was closed by the setting: %v", err)
}
})
}
// Read receipts are reciprocal: switching them off stops you broadcasting your
// reads AND stops you seeing everyone else's. A one-way version would let a
// user watch others while staying invisible.
func TestReadReceipts_AreReciprocal(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "read me"})
if err != nil {
t.Fatal(err)
}
if err := svc.MarkRead(ctx, b, conv.ID, msg.Seq); err != nil {
t.Fatal(err)
}
// Both on: A sees B's read.
if r, err := svc.Receipts(ctx, a, conv.ID); err != nil || r.ReadSeq != msg.Seq {
t.Fatalf("receipts = %+v (err %v), want read_seq %d", r, err, msg.Seq)
}
// B switches them off: A no longer sees B's read...
off := model.DefaultChatSettings()
off.ReadReceipts = false
if _, err := svc.SaveSettings(ctx, b, off); err != nil {
t.Fatal(err)
}
if r, _ := svc.Receipts(ctx, a, conv.ID); r.ReadSeq != 0 {
t.Errorf("A still sees a read cursor for someone who switched receipts off: %+v", r)
}
// ...and B doesn't get to see A's either.
if err := svc.MarkRead(ctx, a, conv.ID, msg.Seq); err != nil {
t.Fatal(err)
}
if r, _ := svc.Receipts(ctx, b, conv.ID); r.ReadSeq != 0 {
t.Errorf("B switched receipts off but still sees others': %+v", r)
}
// Delivery is unaffected — it reports that a device received the message,
// not that a person read it.
if err := svc.MarkDelivered(ctx, b, conv.ID, msg.Seq); err != nil {
t.Fatal(err)
}
if r, _ := svc.Receipts(ctx, a, conv.ID); r.DeliveredSeq != msg.Seq {
t.Errorf("delivery cursor = %+v, want %d", r, msg.Seq)
}
// The muter's own unread still clears — their cursor advanced locally.
inbox, _ := svc.Inbox(ctx, b, "chats", 10)
for _, row := range inbox {
if row.ID == conv.ID && row.Unread != 0 {
t.Errorf("unread = %d for someone who read the thread with receipts off", row.Unread)
}
}
}
// The Message button is offered from RelationshipState.CanMessage, so it must
// agree with what OpenDM actually does — a button that fails on tap is worse
// than no button.
func TestCanStartConversation_MatchesOpenDM(t *testing.T) {
svc, chat, pg := testChatService(t)
ctx := context.Background()
blocks := repository.NewBlockRepo(pg)
follows := repository.NewFollowRepo(pg)
cases := []struct {
name string
setup func(t *testing.T, viewer, target string)
want bool
}{
{"no restrictions", func(*testing.T, string, string) {}, true},
{"friends only, not a friend", func(t *testing.T, _, target string) {
s := model.DefaultChatSettings()
s.WhoCanMessage = model.WhoFriends
if _, err := svc.SaveSettings(ctx, target, s); err != nil {
t.Fatal(err)
}
}, false},
{"friends only, is a friend", func(t *testing.T, viewer, target string) {
s := model.DefaultChatSettings()
s.WhoCanMessage = model.WhoFriends
if _, err := svc.SaveSettings(ctx, target, s); err != nil {
t.Fatal(err)
}
if _, err := follows.Request(ctx, viewer, target); err != nil {
t.Fatal(err)
}
if _, err := follows.Accept(ctx, viewer, target); err != nil {
t.Fatal(err)
}
}, true},
{"target blocked the viewer", func(t *testing.T, viewer, target string) {
if err := blocks.Block(ctx, target, viewer); err != nil {
t.Fatal(err)
}
}, true}, // CanStartConversation doesn't consider blocks; OpenDM checks them first
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
viewer, target := chatUser(t, pg, "V"), chatUser(t, pg, "T")
tc.setup(t, viewer, target)
got, err := chat.CanStartConversation(ctx, viewer, target)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("CanStartConversation = %v, want %v", got, tc.want)
}
// Whatever the predicate says, OpenDM must behave the same way for
// the settings-based cases.
conv, err := svc.OpenDM(ctx, viewer, target)
if conv != nil {
cleanupConv(t, pg, conv.ID)
}
refusedBySetting := errors.Is(err, ErrChatNotAccepting)
if got && refusedBySetting {
t.Error("predicate allowed it but OpenDM refused on the setting")
}
if !got && !refusedBySetting {
t.Errorf("predicate refused it but OpenDM gave err = %v", err)
}
})
}
}
// Opening by handle is what the profile's Message button uses.
func TestOpenDMByHandle(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
viewer := chatUser(t, pg, "V")
target, targetHandle := chatUserWithHandle(t, pg, "T")
conv, err := svc.OpenDMByHandle(ctx, viewer, targetHandle)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
// Same thread as opening by id — the handle path is a resolver, not a
// second way to create conversations.
byID, err := svc.OpenDM(ctx, viewer, target)
if err != nil {
t.Fatal(err)
}
if byID.ID != conv.ID {
t.Errorf("handle path made a different thread: %s vs %s", conv.ID, byID.ID)
}
if _, err := svc.OpenDMByHandle(ctx, viewer, "nobody_by_this_handle"); !errors.Is(err, repository.ErrUserGone) {
t.Errorf("unknown handle: err = %v, want ErrUserGone", err)
}
}
// A quote renders as trusted in-app content, so its link must be unable to
// leave the app. Anything that could navigate off-site has to be refused.
func TestQuoteAttachment_RejectsOffSitePaths(t *testing.T) {
good := []string{
"/books/bg/2/13",
"/books/bg/2/13?hl=abc%20def",
"/u/someone/posts#post-1",
"/",
}
for _, p := range good {
q := QuoteAttachment{Path: p, Title: "Bg 2.13", Text: "the passage"}
if !q.valid() {
t.Errorf("valid() = false for in-app path %q", p)
}
}
bad := map[string]string{
"absolute http": "http://evil.example/x",
"absolute https": "https://evil.example/x",
"protocol relative": "//evil.example/x",
"backslash relative": "/\\evil.example/x",
"scheme only": "javascript:alert(1)",
"data uri": "data:text/html,<script>",
"no leading slash": "books/bg/2/13",
"empty": "",
"newline injection": "/books\n/evil",
"tab injection": "/books\t/evil",
}
for name, p := range bad {
q := QuoteAttachment{Path: p, Title: "Bg 2.13", Text: "the passage"}
if q.valid() {
t.Errorf("%s: valid() = true for %q — must be refused", name, p)
}
}
// A quote with no passage is not a quote.
if (&QuoteAttachment{Path: "/books/bg/2/13", Text: " "}).valid() {
t.Error("valid() = true for a blank passage")
}
// Oversized fields are refused rather than truncated.
long := strings.Repeat("x", maxQuoteTextRunes+1)
if (&QuoteAttachment{Path: "/books/bg/2/13", Text: long}).valid() {
t.Error("valid() = true for an oversized passage")
}
}
// makePost inserts a post owned by userID and returns its id.
func makePost(t *testing.T, pg *repository.Postgres, userID, caption string) string {
t.Helper()
var id string
if err := pg.Pool.QueryRow(context.Background(), `
INSERT INTO posts (user_id, caption, source_url, source_title)
VALUES ($1, $2, '/books/bg/2/13', 'Bhagavad-gītā 2.13') RETURNING id`,
userID, caption).Scan(&id); err != nil {
t.Fatalf("create post: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM posts WHERE id = $1`, id)
})
return id
}
func entityAttachment(t *testing.T, msgs []model.Message) *model.Attachment {
t.Helper()
for i := range msgs {
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentEntity {
return &msgs[i].Attachments[j]
}
}
}
return nil
}
// Sharing is a reference, not a copy: access is decided when the message is
// READ, per person. A post that stops being visible must stop rendering, even
// though the snapshot taken at send time still sits in the row.
func TestSharedPost_ResolvedPerViewerAtReadTime(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
postID := makePost(t, pg, a, "a passage worth sharing")
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
Entity: &EntityAttachment{Type: model.EntityPost, ID: postID},
}); err != nil {
t.Fatalf("share post: %v", err)
}
// Recipient sees the card.
msgs, err := svc.Messages(ctx, b, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
att := entityAttachment(t, msgs)
if att == nil {
t.Fatal("no entity attachment on the shared message")
}
if att.EntityUnavailable || len(att.Entity) == 0 {
t.Fatalf("recipient should see the card: unavailable=%v entity=%s",
att.EntityUnavailable, att.Entity)
}
// Author hides the post → it must stop resolving for the recipient...
if _, err := pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = true WHERE id = $1`, postID); err != nil {
t.Fatal(err)
}
msgs, err = svc.Messages(ctx, b, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
att = entityAttachment(t, msgs)
if att == nil || !att.EntityUnavailable {
t.Error("a hidden post must render as unavailable to the recipient")
}
if att != nil && len(att.Entity) != 0 {
t.Errorf("stale snapshot leaked after the post was hidden: %s", att.Entity)
}
// ...while its owner still sees it, matching the feed's rules.
msgs, err = svc.Messages(ctx, a, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
att = entityAttachment(t, msgs)
if att == nil || att.EntityUnavailable {
t.Error("the owner should still see their own hidden post")
}
}
// You cannot share what you were never allowed to read.
func TestSharedPost_SenderMustSeeItFirst(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, stranger := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
hidden := makePost(t, pg, stranger, "not yours to share")
if _, err := pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = true WHERE id = $1`, hidden); err != nil {
t.Fatal(err)
}
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "look at this",
Entity: &EntityAttachment{Type: model.EntityPost, ID: hidden},
})
if err != nil {
t.Fatalf("send: %v", err)
}
full, err := svc.Messages(ctx, a, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if att := entityAttachment(t, full); att != nil {
t.Errorf("a post the sender cannot see must not be attached (msg %s)", msg.ID)
}
}
// queryCounter counts every statement the pool issues.
type queryCounter struct{ n atomic.Int64 }
func (q *queryCounter) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context {
q.n.Add(1)
return ctx
}
func (q *queryCounter) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
// Resolving shared posts must cost the same whether a page carries one share
// or many — otherwise a thread full of shared posts turns one screen into
// dozens of round-trips.
func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) {
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
ctx := context.Background()
cfg, err := pgxpool.ParseConfig(uri)
if err != nil {
t.Fatal(err)
}
counter := &queryCounter{}
cfg.ConnConfig.Tracer = counter
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pool.Close)
pg := &repository.Postgres{Pool: pool}
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
repository.NewPostRepo(pg), nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop())
// measure returns the statement count for reading a thread carrying
// `shares` distinct shared posts.
measure := func(shares int) int {
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
for i := 0; i < shares; i++ {
id := makePost(t, pg, a, "shared")
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
Entity: &EntityAttachment{Type: model.EntityPost, ID: id},
}); err != nil {
t.Fatal(err)
}
}
// Warm the pool so connection setup isn't counted.
if _, err := svc.Messages(ctx, b, conv.ID, 0, 50); err != nil {
t.Fatal(err)
}
before := counter.n.Load()
msgs, err := svc.Messages(ctx, b, conv.ID, 0, 50)
if err != nil {
t.Fatal(err)
}
after := counter.n.Load()
// Guard the measurement: the cards must actually have resolved, or a
// low count would just mean nothing was looked up.
resolved := 0
for i := range msgs {
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentEntity &&
!msgs[i].Attachments[j].EntityUnavailable {
resolved++
}
}
}
if resolved != shares {
t.Fatalf("resolved %d of %d shared posts", resolved, shares)
}
return int(after - before)
}
one, many := measure(1), measure(8)
t.Logf("queries: 1 share = %d, 8 shares = %d", one, many)
if many != one {
t.Errorf("reading 8 shares cost %d queries vs %d for 1 — resolution is still per-share",
many, one)
}
}
// The nav badge counts threads waiting on you — and mute is meant to stop a
// thread demanding attention, so a muted one must not light it.
func TestUnreadCount_CountsThreadsAndSkipsMuted(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
me, a, b := chatUser(t, pg, "Me"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
if n, err := svc.UnreadCount(ctx, me); err != nil || n != 0 {
t.Fatalf("fresh account: count = %d, err = %v; want 0", n, err)
}
withA, err := svc.OpenDM(ctx, a, me)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, withA.ID)
withB, err := svc.OpenDM(ctx, b, me)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, withB.ID)
// Two messages in one thread still count as one waiting thread.
for _, body := range []string{"hare", "krsna"} {
if _, err := svc.Send(ctx, a, withA.ID, SendRequest{Body: body}); err != nil {
t.Fatal(err)
}
}
if _, err := svc.Send(ctx, b, withB.ID, SendRequest{Body: "hello"}); err != nil {
t.Fatal(err)
}
if n, _ := svc.UnreadCount(ctx, me); n != 2 {
t.Errorf("two threads with unread: count = %d, want 2", n)
}
// Muting one drops it from the badge...
if _, err := svc.SetMute(ctx, me, withA.ID, MuteAlways); err != nil {
t.Fatal(err)
}
if n, _ := svc.UnreadCount(ctx, me); n != 1 {
t.Errorf("after muting one: count = %d, want 1", n)
}
// ...without marking it read: the inbox still shows its unread.
inbox, err := svc.Inbox(ctx, me, "chats", 10)
if err != nil {
t.Fatal(err)
}
var mutedUnread int64
for _, it := range inbox {
if it.ID == withA.ID {
mutedUnread = it.Unread
}
}
if mutedUnread != 2 {
t.Errorf("muted thread's inbox unread = %d, want 2 — mute silences, it does not read", mutedUnread)
}
// Reading the other clears the badge entirely.
if err := svc.MarkRead(ctx, me, withB.ID, 1); err != nil {
t.Fatal(err)
}
if n, _ := svc.UnreadCount(ctx, me); n != 0 {
t.Errorf("after reading: count = %d, want 0", n)
}
}
// A chat push must not fire when the thread is muted or the user opted out.
// Both are the difference between a useful alert and an uninstall.
func TestChatPush_SuppressedByMuteAndPreference(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
prefs := repository.NewNotificationPrefsRepo(pg)
t.Run("muted thread does not push", func(t *testing.T) {
me, them := chatUser(t, pg, "Me"), chatUser(t, pg, "Them")
conv, err := svc.OpenDM(ctx, them, me)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if _, err := svc.SetMute(ctx, me, conv.ID, MuteAlways); err != nil {
t.Fatal(err)
}
cands, err := svc.chat.PushCandidates(ctx, conv.ID, them)
if err != nil {
t.Fatal(err)
}
for _, c := range cands {
if c.UserID == me && (c.MutedUntil == nil || !c.MutedUntil.After(time.Now())) {
t.Error("muted participant should carry a future muted_until")
}
}
})
t.Run("push opt-out is recorded for the chat kind", func(t *testing.T) {
me := chatUser(t, pg, "Me")
if err := prefs.Disable(ctx, me,
[]string{NotifChatMessage}, []string{repository.ChannelPush}); err != nil {
t.Fatal(err)
}
off, err := prefs.DisabledSet(ctx, me)
if err != nil {
t.Fatal(err)
}
if !prefOff(off, NotifChatMessage, repository.ChannelPush) {
t.Error("disabling Messages→Push should be visible to the chat push path")
}
// Turning off chat push must not silence anything else.
if prefOff(off, NotifPostLike, repository.ChannelPush) {
t.Error("disabling chat push also disabled post likes")
}
})
t.Run("sender is never a push candidate", func(t *testing.T) {
me, them := chatUser(t, pg, "Me"), chatUser(t, pg, "Them")
conv, err := svc.OpenDM(ctx, me, them)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
cands, err := svc.chat.PushCandidates(ctx, conv.ID, me)
if err != nil {
t.Fatal(err)
}
for _, c := range cands {
if c.UserID == me {
t.Error("the sender must not be pushed their own message")
}
}
if len(cands) != 1 {
t.Errorf("candidates = %d, want 1 (the recipient)", len(cands))
}
})
t.Run("a departed member stops receiving push", func(t *testing.T) {
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
if err := svc.RemoveMember(ctx, b, conv.ID, handleOf(t, pg, b)); err != nil {
t.Fatal(err)
}
cands, err := svc.chat.PushCandidates(ctx, conv.ID, owner)
if err != nil {
t.Fatal(err)
}
for _, c := range cands {
if c.UserID == b {
t.Error("someone who left the group must not be pushed")
}
}
})
}
// The three tick states have to be reachable in order: one tick on send, two
// once it reaches the other device, blue once they read it. The middle state
// only exists if delivery is acked separately from reading.
func TestReceipts_TickProgression(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hare kṛṣṇa"})
if err != nil {
t.Fatal(err)
}
// Sent: nothing acked yet — one tick.
rc, err := svc.Receipts(ctx, a, conv.ID)
if err != nil {
t.Fatal(err)
}
if rc.DeliveredSeq >= msg.Seq || rc.ReadSeq >= msg.Seq {
t.Fatalf("just sent: %+v — expected neither delivered nor read", rc)
}
// Delivered: reached their device, still unread — two grey ticks.
if err := svc.MarkDelivered(ctx, b, conv.ID, msg.Seq); err != nil {
t.Fatal(err)
}
rc, _ = svc.Receipts(ctx, a, conv.ID)
if rc.DeliveredSeq < msg.Seq {
t.Errorf("after delivery ack: delivered_seq = %d, want >= %d", rc.DeliveredSeq, msg.Seq)
}
if rc.ReadSeq >= msg.Seq {
t.Errorf("delivery must not imply read: %+v", rc)
}
// Read: blue.
if err := svc.MarkRead(ctx, b, conv.ID, msg.Seq); err != nil {
t.Fatal(err)
}
rc, _ = svc.Receipts(ctx, a, conv.ID)
if rc.ReadSeq < msg.Seq {
t.Errorf("after read: read_seq = %d, want >= %d", rc.ReadSeq, msg.Seq)
}
// Reading implies delivery, so someone who was offline and opens the thread
// can't end up read-but-undelivered.
if rc.DeliveredSeq < rc.ReadSeq {
t.Errorf("delivered_seq (%d) fell behind read_seq (%d)", rc.DeliveredSeq, rc.ReadSeq)
}
// Acks must never move a cursor backwards.
if err := svc.MarkDelivered(ctx, b, conv.ID, 1); err != nil {
t.Fatal(err)
}
back, _ := svc.Receipts(ctx, a, conv.ID)
if back.DeliveredSeq < rc.DeliveredSeq || back.ReadSeq < rc.ReadSeq {
t.Errorf("a stale ack rewound the cursors: %+v then %+v", rc, back)
}
}
func reactionsOf(t *testing.T, msgs []model.Message, id string) []model.Reaction {
t.Helper()
for i := range msgs {
if msgs[i].ID == id {
return msgs[i].Reactions
}
}
return nil
}
// Reacting again replaces rather than stacks, and the chip knows whose it is.
func TestReactions_ReplaceAndToggle(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hare kṛṣṇa"})
if err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, b, msg.ID, HeartEmoji); err != nil {
t.Fatal(err)
}
msgs, _ := svc.Messages(ctx, b, conv.ID, 0, 10)
got := reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Emoji != HeartEmoji || got[0].Count != 1 || !got[0].Mine {
t.Fatalf("after reacting: %+v", got)
}
// The author sees the same reaction, but it is not theirs.
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
got = reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Mine {
t.Errorf("author's view: %+v — Mine should be false", got)
}
// Changing your mind replaces; it must not accumulate.
if err := svc.React(ctx, b, msg.ID, "😂"); err != nil {
t.Fatal(err)
}
msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10)
got = reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Emoji != "😂" {
t.Errorf("after changing: %+v — want exactly one, the new emoji", got)
}
// Removing leaves nothing, and removing twice is harmless.
if err := svc.Unreact(ctx, b, msg.ID); err != nil {
t.Fatal(err)
}
if err := svc.Unreact(ctx, b, msg.ID); err != nil {
t.Errorf("second unreact should be a no-op, got %v", err)
}
msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10)
if got = reactionsOf(t, msgs, msg.ID); len(got) != 0 {
t.Errorf("after removing: %+v — want none", got)
}
}
// Counts group across people, and each viewer sees only their own as "mine".
func TestReactions_GroupAcrossPeople(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"})
if err != nil {
t.Fatal(err)
}
for _, u := range []string{a, b} {
if err := svc.React(ctx, u, msg.ID, HeartEmoji); err != nil {
t.Fatal(err)
}
}
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
got := reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Count != 2 || !got[0].Mine {
t.Errorf("A's view: %+v — want one chip, count 2, mine", got)
}
msgs, _ = svc.Messages(ctx, owner, conv.ID, 0, 10)
got = reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Count != 2 || got[0].Mine {
t.Errorf("owner's view: %+v — count 2 but not mine", got)
}
}
// A message id alone must not let an outsider react into a thread they were
// never part of.
func TestReactions_OutsiderRefused(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "private"})
if err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, outsider, msg.ID, HeartEmoji); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider react: err = %v, want ErrNotParticipant", err)
}
if err := svc.Typing(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider typing: err = %v, want ErrNotParticipant", err)
}
}
// An emoji column is not free text storage.
func TestReactions_RejectsJunk(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "x"})
if err != nil {
t.Fatal(err)
}
for _, bad := range []string{"", " ", strings.Repeat("a", 64)} {
if err := svc.React(ctx, b, msg.ID, bad); !errors.Is(err, ErrChatBadEmoji) {
t.Errorf("React(%q): err = %v, want ErrChatBadEmoji", bad, err)
}
}
}
// The "who reacted" list names each person and their emoji, and is readable
// only by people in the thread.
func TestReactionDetails_ListsEveryoneAndGuardsOutsiders(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
outsider := chatUser(t, pg, "X")
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"})
if err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, a, msg.ID, HeartEmoji); err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, b, msg.ID, "😂"); err != nil {
t.Fatal(err)
}
list, err := svc.MessageReactions(ctx, owner, msg.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("list = %d entries, want 2", len(list))
}
byUser := map[string]string{}
for _, d := range list {
byUser[d.User.ID] = d.Emoji
if d.User.Handle == "" || d.User.Name == "" {
t.Errorf("entry %+v is missing the details needed to render a row", d)
}
}
if byUser[a] != HeartEmoji || byUser[b] != "😂" {
t.Errorf("emoji did not follow their authors: %+v", byUser)
}
// Changing your mind replaces your entry rather than adding one.
if err := svc.React(ctx, a, msg.ID, "🙏"); err != nil {
t.Fatal(err)
}
list, _ = svc.MessageReactions(ctx, owner, msg.ID)
if len(list) != 2 {
t.Errorf("after changing: %d entries, want 2", len(list))
}
// Knowing a message id must not reveal who is in a thread you're not in.
if _, err := svc.MessageReactions(ctx, outsider, msg.ID); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider: err = %v, want ErrNotParticipant", err)
}
}
// A reply must arrive with its quoted preview already attached. Append only
// RETURNINGs reply_to_id, so without a read-back the sender — and everyone
// receiving the realtime event — would see a reply quoting nothing until the
// thread was reloaded.
func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"})
if err != nil {
t.Fatal(err)
}
reply, err := svc.Send(ctx, b, conv.ID, SendRequest{
Body: "answering", ReplyToID: &original.ID,
})
if err != nil {
t.Fatal(err)
}
if reply.ReplyTo == nil {
t.Fatal("the send response has no quoted preview — the client has nothing to render")
}
if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" {
t.Errorf("quote = %+v, want the original message", reply.ReplyTo)
}
// And a retried send must return the same shape, not a bare row.
clientID := uuid.NewString()
first, err := svc.Send(ctx, b, conv.ID, SendRequest{
Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID,
})
if err != nil {
t.Fatal(err)
}
retry, err := svc.Send(ctx, b, conv.ID, SendRequest{
Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID,
})
if err != nil {
t.Fatal(err)
}
if retry.ID != first.ID {
t.Fatalf("retry created a second message: %s vs %s", retry.ID, first.ID)
}
if retry.ReplyTo == nil {
t.Error("a retried reply lost its quote")
}
}
// grantSticker gives a user a reward the way sādhana does, returning its key.
func grantSticker(t *testing.T, pg *repository.Postgres, userID, key string) string {
t.Helper()
ctx := context.Background()
var rewardID string
if err := pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, $2, 'https://example.invalid/art.png')
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name
RETURNING id`, key, "Sticker "+key).Scan(&rewardID); err != nil {
t.Fatalf("create reward: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(ctx, `DELETE FROM sadhna_rewards WHERE id = $1`, rewardID)
})
if userID != "" {
// milestone is unique per user; a large random-ish one avoids clashing
// with any real streak rows.
if _, err := pg.Pool.Exec(ctx, `
INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
userID, rewardID, 900000+len(key)); err != nil {
t.Fatalf("grant reward: %v", err)
}
}
return key
}
func stickerAttachment(t *testing.T, msgs []model.Message) *model.Attachment {
t.Helper()
for i := range msgs {
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentSticker {
return &msgs[i].Attachments[j]
}
}
}
return nil
}
// A sticker key is not a capability: sending one you never earned must fail,
// or the whole catalog is available to anyone who can guess a key.
func TestStickers_OnlySendWhatYouOwn(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
mine := grantSticker(t, pg, a, "sticker_owned_"+a[:8])
// Exists in the catalog, granted to nobody.
unearned := grantSticker(t, pg, "", "sticker_unearned_"+a[:8])
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &mine}); err != nil {
t.Fatalf("sending an earned sticker: %v", err)
}
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
att := stickerAttachment(t, msgs)
if att == nil {
t.Fatal("an earned sticker did not attach")
}
if att.ImageURL == nil || *att.ImageURL == "" || att.EntityID == nil || *att.EntityID != mine {
t.Errorf("attachment = %+v, want the artwork and its key", att)
}
// Not earned, not saved: the message sends, but carries no sticker.
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "trying", StickerKey: &unearned,
}); err != nil {
t.Fatal(err)
}
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
for i := range msgs {
for j := range msgs[i].Attachments {
if att := msgs[i].Attachments[j]; att.Kind == model.AttachmentSticker &&
att.EntityID != nil && *att.EntityID == unearned {
t.Error("a sticker the sender never earned was attached")
}
}
}
}
// Saving is how a sticker spreads — but only from a message actually sent to
// you, in a thread you are actually in.
func TestStickers_SaveFromChat(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
key := grantSticker(t, pg, a, "sticker_shared_"+a[:8])
sent, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &key})
if err != nil {
t.Fatal(err)
}
// B has not earned it, so it isn't in their drawer yet.
before, _ := svc.Stickers(ctx, b)
for _, s := range before {
if s.Key == key {
t.Fatal("B has the sticker before saving it")
}
}
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
t.Fatalf("saving a sticker sent to you: %v", err)
}
after, _ := svc.Stickers(ctx, b)
var found *model.Sticker
for i := range after {
if after[i].Key == key {
found = &after[i]
}
}
if found == nil {
t.Fatal("saved sticker is not in B's drawer")
}
if !found.Saved {
t.Error("a sticker from a chat should be marked as saved, not earned")
}
// B can now send it themselves.
if _, err := svc.Send(ctx, b, conv.ID, SendRequest{StickerKey: &key}); err != nil {
t.Errorf("B should be able to send a saved sticker: %v", err)
}
// Saving twice is harmless.
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
t.Errorf("second save should be idempotent: %v", err)
}
// Someone outside the thread cannot save from it, even knowing both ids.
if err := svc.SaveSticker(ctx, outsider, sent.ID, key); !errors.Is(err, repository.ErrStickerNotOwned) {
t.Errorf("outsider save: err = %v, want ErrStickerNotOwned", err)
}
// The sender's own drawer still reports it as earned, not saved.
mine, _ := svc.Stickers(ctx, a)
for _, s := range mine {
if s.Key == key && s.Saved {
t.Error("an earned sticker is being reported as saved")
}
}
}
// A quote, sticker, photo or shared post lives in an attachment row, not in
// the body — so forwarding one has to carry the attachments across or the
// recipient gets an empty bubble.
func TestForward_CarriesAttachments(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
source, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, source.ID)
target, err := svc.OpenDM(ctx, a, c)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, target.ID)
// A quote with no body at all — the case that forwarded blank.
sent, err := svc.Send(ctx, a, source.ID, SendRequest{
Quote: &QuoteAttachment{
Path: "/books/bg/2/13?hl=abc",
Title: "Bhagavad-gītā 2.13",
Text: "As the embodied soul continually passes…",
},
})
if err != nil {
t.Fatal(err)
}
if sent.Body != "" {
t.Fatalf("setup: expected a body-less quote, got %q", sent.Body)
}
fwd, err := svc.ForwardMessage(ctx, a, sent.ID, target.ID)
if err != nil {
t.Fatal(err)
}
msgs, err := svc.Messages(ctx, c, target.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
var quote *model.Attachment
for i := range msgs {
if msgs[i].ID != fwd.ID {
continue
}
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentQuote {
quote = &msgs[i].Attachments[j]
}
}
}
if quote == nil {
t.Fatal("the forwarded message has no quote — it would render empty")
}
if quote.Description == nil || *quote.Description == "" {
t.Error("the forwarded quote lost its passage")
}
if quote.URL == nil || *quote.URL != "/books/bg/2/13?hl=abc" {
t.Errorf("the forwarded quote lost its deep link: %v", quote.URL)
}
// The copy belongs to the DESTINATION thread: reads authorize against the
// attachment's own conversation, so a copy still scoped to the source
// would be unreadable to the recipient.
var convID string
if err := pg.Pool.QueryRow(ctx,
`SELECT conversation_id FROM message_attachments WHERE message_id = $1`,
fwd.ID).Scan(&convID); err != nil {
t.Fatal(err)
}
if convID != target.ID {
t.Errorf("copied attachment is scoped to %s, want the destination %s", convID, target.ID)
}
// The original is untouched.
back, err := svc.Messages(ctx, b, source.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
found := false
for i := range back {
if back[i].ID == sent.ID && len(back[i].Attachments) == 1 {
found = true
}
}
if !found {
t.Error("forwarding disturbed the original message's attachment")
}
}