feat: chat reactions

This commit is contained in:
Krispire Admin
2026-08-30 01:51:32 +05:30
parent 9c458ba19f
commit cee2d3c874
8 changed files with 758 additions and 43 deletions
+255
View File
@@ -2204,3 +2204,258 @@ func TestReceipts_TickProgression(t *testing.T) {
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")
}
}