feat: chat reactions
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user