1722 lines
57 KiB
Go
1722 lines
57 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"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), 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)
|
|
}
|
|
}
|