Files

716 lines
27 KiB
Go
Raw Permalink Normal View History

2026-08-01 22:06:14 +02:00
package service
import (
"context"
"errors"
2026-08-01 22:06:14 +02:00
"fmt"
"strings"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
2026-08-01 22:06:14 +02:00
)
// newMentionFixture builds a channel 10 with three members (alice=1 author,
// bob=2 online, carol=3 offline) plus a moderator (mod=4) holding
// MENTION_EVERYONE. Every role can read channel 10.
func newMentionFixture(t *testing.T) (*MessageService, *ChannelService, *db.DB) {
t.Helper()
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages,
Position: 1,
})
seedRole(t, database, &db.Role{
ID: permissions.ModeratorRoleID,
Name: "moderator",
Permissions: permissions.SendMessages | permissions.ReadMessages |
permissions.MentionEveryone,
Position: 60,
})
seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"})
seedUser(t, database, &db.User{ID: 2, Username: "Bob", Status: "online"})
seedUser(t, database, &db.User{ID: 3, Username: "carol", Status: "offline"})
seedUser(t, database, &db.User{ID: 4, Username: "mod", Status: "online"})
seedUserRole(t, database, 1, permissions.MemberRoleID)
seedUserRole(t, database, 2, permissions.MemberRoleID)
seedUserRole(t, database, 3, permissions.MemberRoleID)
seedUserRole(t, database, 4, permissions.ModeratorRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
checker := permissions.NewChecker(database)
permSvc := NewPermissionService(database, checker)
msgSvc := NewMessageService(database, permSvc, nil)
// Mention counts are written on a background goroutine in production; run
// them inline here so the tests can read the counts right after a send.
msgSvc.RunBackgroundInlineForTest()
return msgSvc, NewChannelService(database, permSvc), database
}
func sendAs(t *testing.T, svc *MessageService, userID int64, content string) *SendMessageResult {
t.Helper()
res, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
UserID: userID,
Username: "user",
RoleName: "member",
Content: content,
})
if err != nil {
t.Fatalf("SendMessage(%q): %v", content, err)
}
return res
}
func mentionCount(t *testing.T, database *db.DB, userID int64) int {
t.Helper()
n, err := database.GetMentionCount(context.Background(), userID, 10)
if err != nil {
t.Fatalf("GetMentionCount(%d): %v", userID, err)
}
return n
}
// ─── parsing ─────────────────────────────────────────────────────────────────
func TestParseMentionTokens(t *testing.T) {
tests := []struct {
name string
content string
wantTokens []string
wantEveryone bool
wantHere bool
}{
{name: "plain token", content: "hi @bob", wantTokens: []string{"bob"}},
{name: "lowercased", content: "hi @BoB", wantTokens: []string{"bob"}},
{name: "deduplicated", content: "@bob @bob @carol", wantTokens: []string{"bob", "carol"}},
{name: "no token", content: "no mentions here", wantTokens: nil},
{name: "email is not a mention", content: "write to bob@example.com", wantTokens: nil},
{name: "double at is not a mention", content: "@@bob", wantTokens: nil},
{name: "punctuation delimits", content: "(@bob), @carol!", wantTokens: []string{"bob", "carol"}},
{name: "everyone reserved", content: "@everyone hi", wantEveryone: true},
{name: "here reserved", content: "@here hi", wantHere: true},
{name: "case-insensitive reserved", content: "@EVERYONE", wantEveryone: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens, everyone, here := parseMentionTokens(tt.content)
var got []string
for _, tok := range tokens {
got = append(got, tok.spellings[0])
}
if strings.Join(got, ",") != strings.Join(tt.wantTokens, ",") {
t.Errorf("tokens = %v, want %v", got, tt.wantTokens)
}
if everyone != tt.wantEveryone {
t.Errorf("everyone = %v, want %v", everyone, tt.wantEveryone)
}
if here != tt.wantHere {
t.Errorf("here = %v, want %v", here, tt.wantHere)
}
})
}
}
// TestParseMentionTokens_TrailingPunctuationSpelling locks the fallback that
// makes "@bob." resolve to bob when no user is literally named "bob.".
func TestParseMentionTokens_TrailingPunctuationSpelling(t *testing.T) {
tokens, _, _ := parseMentionTokens("thanks @bob.")
if len(tokens) != 1 {
t.Fatalf("tokens = %d, want 1", len(tokens))
}
if got := tokens[0].spellings; len(got) != 2 || got[0] != "bob." || got[1] != "bob" {
t.Errorf("spellings = %v, want [bob. bob]", got)
}
}
func TestParseMentionTokens_CandidateCap(t *testing.T) {
var sb strings.Builder
for i := range maxMentionCandidates + 20 {
fmt.Fprintf(&sb, "@user%d ", i)
}
tokens, _, _ := parseMentionTokens(sb.String())
if len(tokens) != maxMentionCandidates {
t.Errorf("tokens = %d, want %d", len(tokens), maxMentionCandidates)
}
}
// ─── resolution on the send path ─────────────────────────────────────────────
func TestSendMessage_ResolvesKnownUsername(t *testing.T) {
svc, _, database := newMentionFixture(t)
res := sendAs(t, svc, 1, "hey @Bob, look at this")
if len(res.Mentions) != 1 || res.Mentions[0] != 2 {
t.Fatalf("mentions = %v, want [2]", res.Mentions)
}
if res.MentionsEveryone {
t.Error("mentions_everyone should be false")
}
stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID})
if err != nil {
t.Fatalf("GetMentionsByMessageIDs: %v", err)
}
if len(stored[res.MessageID]) != 1 || stored[res.MessageID][0] != 2 {
t.Errorf("stored mentions = %v, want [2]", stored[res.MessageID])
}
}
func TestSendMessage_CaseInsensitiveUsername(t *testing.T) {
svc, _, _ := newMentionFixture(t)
res := sendAs(t, svc, 1, "hi @bOB")
if len(res.Mentions) != 1 || res.Mentions[0] != 2 {
t.Fatalf("mentions = %v, want [2]", res.Mentions)
}
}
// TestSendMessage_NonASCIIUppercaseUsernameResolves locks OC-0131: a username
// holding an uppercase non-ASCII letter is legal (auth.ValidateUsername only
// rejects control/format runes) and must still be @mentionable. Go's
// Unicode-aware strings.ToLower would fold "Émile" to "émile" before the
// lookup ever reaches SQL, but users.username is only COLLATE NOCASE, which
// folds ASCII A-Z only -- so a Unicode-lowered token can never match the
// stored non-ASCII-uppercase row, and the mention silently degrades to plain
// text.
func TestSendMessage_NonASCIIUppercaseUsernameResolves(t *testing.T) {
svc, _, database := newMentionFixture(t)
seedUser(t, database, &db.User{ID: 5, Username: "Émile", Status: "online"})
seedUserRole(t, database, 5, permissions.MemberRoleID)
res := sendAs(t, svc, 1, "hey @Émile")
if len(res.Mentions) != 1 || res.Mentions[0] != 5 {
t.Fatalf("mentions = %v, want [5] (Émile must resolve)", res.Mentions)
}
}
2026-08-01 22:06:14 +02:00
func TestSendMessage_UnknownWordStaysText(t *testing.T) {
svc, _, database := newMentionFixture(t)
res := sendAs(t, svc, 1, "@nobody @bob@example.com hello")
if len(res.Mentions) != 0 {
t.Fatalf("mentions = %v, want none", res.Mentions)
}
if res.Content != "@nobody @bob@example.com hello" {
t.Errorf("content was rewritten: %q", res.Content)
}
stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID})
if err != nil {
t.Fatalf("GetMentionsByMessageIDs: %v", err)
}
if len(stored) != 0 {
t.Errorf("stored mentions = %v, want none", stored)
}
}
func TestSendMessage_MentionCapped(t *testing.T) {
svc, _, database := newMentionFixture(t)
// 25 distinct mentionable users, all readers of channel 10.
var sb strings.Builder
for i := range 25 {
id := int64(100 + i)
name := fmt.Sprintf("capuser%d", i)
seedUser(t, database, &db.User{ID: id, Username: name, Status: "online"})
seedUserRole(t, database, id, permissions.MemberRoleID)
sb.WriteString("@" + name + " ")
}
res := sendAs(t, svc, 1, sb.String())
if len(res.Mentions) != maxMentionsPerMessage {
t.Fatalf("mentions = %d, want %d", len(res.Mentions), maxMentionsPerMessage)
}
stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID})
if err != nil {
t.Fatalf("GetMentionsByMessageIDs: %v", err)
}
if len(stored[res.MessageID]) != maxMentionsPerMessage {
t.Errorf("stored = %d, want %d", len(stored[res.MessageID]), maxMentionsPerMessage)
}
}
// ─── @everyone / @here permission gate ───────────────────────────────────────
func TestSendMessage_EveryoneRequiresPermission(t *testing.T) {
svc, _, database := newMentionFixture(t)
res := sendAs(t, svc, 1, "@everyone stand up") // alice is a plain member
if res.MentionsEveryone {
t.Fatal("@everyone without MENTION_EVERYONE must not gain mention semantics")
}
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("bob mention_count = %d, want 0", got)
}
res = sendAs(t, svc, 4, "@everyone stand up") // mod holds the bit
if !res.MentionsEveryone {
t.Fatal("@everyone with MENTION_EVERYONE must be honored")
}
}
func TestSendMessage_EveryoneCountsEveryReaderButAuthor(t *testing.T) {
svc, _, database := newMentionFixture(t)
sendAs(t, svc, 4, "@everyone meeting now")
for _, uid := range []int64{1, 2, 3} {
if got := mentionCount(t, database, uid); got != 1 {
t.Errorf("user %d mention_count = %d, want 1", uid, got)
}
}
if got := mentionCount(t, database, 4); got != 0 {
t.Errorf("author mention_count = %d, want 0", got)
}
}
func TestSendMessage_HereSkipsOfflineUsers(t *testing.T) {
svc, _, database := newMentionFixture(t)
sendAs(t, svc, 4, "@here quick question")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("online bob mention_count = %d, want 1", got)
}
if got := mentionCount(t, database, 3); got != 0 {
t.Errorf("offline carol mention_count = %d, want 0", got)
}
}
// TestSendMessage_MentionsHereDistinguishesHereFromEveryone locks OC-0271:
// MentionsEveryone alone cannot tell a client which fan-out rule applied — a
// plain @everyone reaches every reader, but @here skips one with no live
// connection at send time (TestSendMessage_HereSkipsOfflineUsers above). A
// reconnecting client uses MentionsHere to avoid raising a mention badge the
// server never counted for a here-only mention delivered in its replay burst.
func TestSendMessage_MentionsHereDistinguishesHereFromEveryone(t *testing.T) {
svc, _, _ := newMentionFixture(t)
here := sendAs(t, svc, 4, "@here quick question")
if !here.MentionsEveryone {
t.Fatal("@here must set MentionsEveryone")
}
if !here.MentionsHere {
t.Error("MentionsHere = false, want true for a here-only mention")
}
everyone := sendAs(t, svc, 4, "@everyone meeting now")
if !everyone.MentionsEveryone {
t.Fatal("@everyone must set MentionsEveryone")
}
if everyone.MentionsHere {
t.Error("MentionsHere = true, want false for a plain @everyone")
}
}
2026-08-01 22:06:14 +02:00
// TestSendMessage_HereSkipsInvisibleUsers locks the phase-6 half of the @here
// rule. users.status stores the status the user *chose*, so an invisible reader
// holds the literal "invisible" here — a bare == "offline" test would ping them,
// which is the one thing "appear offline" exists to prevent. The fan-out has to
// collapse through db.BroadcastStatus first, so @here agrees with what everyone
// else can see of that reader.
func TestSendMessage_HereSkipsInvisibleUsers(t *testing.T) {
svc, _, database := newMentionFixture(t)
if err := database.UpdateUserStatus(context.Background(), 2, db.StatusInvisible); err != nil {
t.Fatalf("UpdateUserStatus(invisible): %v", err)
}
sendAs(t, svc, 4, "@here quick question")
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("invisible bob mention_count = %d, want 0", got)
}
// A plain @everyone still reaches them: only @here narrows on presence.
sendAs(t, svc, 4, "@everyone meeting now")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("invisible bob @everyone mention_count = %d, want 1", got)
}
}
// TestSendMessage_HereSkipsDisconnectedIdleDndUsers locks OC-0223: @here must
// treat a reader with no live connection as offline even when their stored
// status is idle/dnd, matching the read path's "no live connection is
// offline, whatever the row says" rule (ws/serve_ready.go presentableMembers).
// MarkUserDisconnected only ever rewrites "online" -> "offline" — an idle/dnd
// choice survives the disconnect by design, so a bare
// db.BroadcastStatus(r.Status) == db.StatusOffline test can never catch a
// disconnected idle/dnd reader without also consulting live connection state.
func TestSendMessage_HereSkipsDisconnectedIdleDndUsers(t *testing.T) {
svc, _, database := newMentionFixture(t)
// bob's last chosen status was "dnd" before disconnecting (mirrors what
// MarkUserDisconnected leaves behind for a non-"online" status).
if err := database.UpdateUserStatus(context.Background(), 2, db.StatusDND); err != nil {
t.Fatalf("UpdateUserStatus(dnd): %v", err)
}
// bob has no live connection.
svc.SetOnlineChecker(func(userID int64) bool { return userID != 2 })
sendAs(t, svc, 4, "@here quick question")
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("disconnected dnd bob mention_count = %d, want 0", got)
}
// A plain @everyone still reaches them: only @here narrows on presence.
sendAs(t, svc, 4, "@everyone meeting now")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("disconnected dnd bob @everyone mention_count = %d, want 1", got)
}
}
2026-08-01 22:06:14 +02:00
// TestSendMessage_EveryoneSkipsUsersWithoutRead locks that the @everyone
// fan-out honors per-channel denies, not just the base role mask.
func TestSendMessage_EveryoneSkipsUsersWithoutRead(t *testing.T) {
svc, _, database := newMentionFixture(t)
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages)
sendAs(t, svc, 4, "@everyone private notice")
for _, uid := range []int64{1, 2, 3} {
if got := mentionCount(t, database, uid); got != 0 {
t.Errorf("denied user %d mention_count = %d, want 0", uid, got)
}
}
}
// TestSendMessage_EveryoneHonorsUserOverrides locks the per-user layer in the
// @everyone fan-out: it is the last layer of the resolution order, so it must
// both DROP a reader the role admitted and ADD one the role excluded.
func TestSendMessage_EveryoneHonorsUserOverrides(t *testing.T) {
svc, _, database := newMentionFixture(t)
// The role cannot read the channel at all...
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages)
// ...but bob is individually granted READ back.
if err := database.UpsertChannelUserOverride(context.Background(), 10, 2, permissions.ReadMessages, 0); err != nil {
t.Fatalf("UpsertChannelUserOverride bob: %v", err)
}
sendAs(t, svc, 4, "@everyone notice")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("bob (user allow) mention_count = %d, want 1", got)
}
if got := mentionCount(t, database, 3); got != 0 {
t.Errorf("carol (no override) mention_count = %d, want 0", got)
}
}
func TestSendMessage_EveryoneSkipsUserDenied(t *testing.T) {
svc, _, database := newMentionFixture(t)
// carol alone is denied READ on a channel her role can read.
if err := database.UpsertChannelUserOverride(context.Background(), 10, 3, 0, permissions.ReadMessages); err != nil {
t.Fatalf("UpsertChannelUserOverride carol: %v", err)
}
sendAs(t, svc, 4, "@everyone notice")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("bob mention_count = %d, want 1", got)
}
if got := mentionCount(t, database, 3); got != 0 {
t.Errorf("carol (user deny) mention_count = %d, want 0", got)
}
}
// A direct @mention of a user the channel's per-user deny excludes must not
// raise their badge either — mentionReaders is the single gate behind both.
func TestSendMessage_DirectMentionSkipsUserDenied(t *testing.T) {
svc, _, database := newMentionFixture(t)
if err := database.UpsertChannelUserOverride(context.Background(), 10, 2, 0, permissions.ReadMessages); err != nil {
t.Fatalf("UpsertChannelUserOverride bob: %v", err)
}
sendAs(t, svc, 1, "@bob ping")
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("user-denied bob mention_count = %d, want 0", got)
}
}
// A single message mentioning several users at once must resolve each one
// independently against the reader set: readers get counted, a non-reader
// (denied READ_MESSAGES via a per-user override) does not — the case the
// mentioned-uid x reader lookup has to get right regardless of how it is
// implemented internally (loop or map).
func TestSendMessage_MultipleDirectMentionsResolveIndependently(t *testing.T) {
svc, _, database := newMentionFixture(t)
if err := database.UpsertChannelUserOverride(context.Background(), 10, 3, 0, permissions.ReadMessages); err != nil {
t.Fatalf("UpsertChannelUserOverride carol: %v", err)
}
sendAs(t, svc, 1, "@bob @carol @mod hi")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("bob (reader) mention_count = %d, want 1", got)
}
if got := mentionCount(t, database, 3); got != 0 {
t.Errorf("carol (denied read) mention_count = %d, want 0", got)
}
if got := mentionCount(t, database, 4); got != 1 {
t.Errorf("mod (reader) mention_count = %d, want 1", got)
}
}
// ─── mention counts ──────────────────────────────────────────────────────────
func TestSendMessage_DirectMentionIncrementsCount(t *testing.T) {
svc, _, database := newMentionFixture(t)
sendAs(t, svc, 1, "@bob ping")
sendAs(t, svc, 1, "@bob again")
if got := mentionCount(t, database, 2); got != 2 {
t.Errorf("bob mention_count = %d, want 2", got)
}
if got := mentionCount(t, database, 3); got != 0 {
t.Errorf("uninvolved carol mention_count = %d, want 0", got)
}
}
// TestSendMessage_MentionCountsWrittenInBackground exercises the default async
// path: the fixture normally forces the inline seam, so here we restore the
// real `go fn()` dispatcher and confirm the count still lands shortly after
// SendMessage returns.
func TestSendMessage_MentionCountsWrittenInBackground(t *testing.T) {
svc, _, database := newMentionFixture(t)
// Undo the fixture's inline seam so bg is the production `go fn()` again.
svc.bg = func(fn func()) { go fn() }
sendAs(t, svc, 1, "@Bob ping")
// The write is on a goroutine; poll briefly rather than assuming timing.
const deadline = 2 * time.Second
var got int
for waited := time.Duration(0); waited < deadline; waited += 10 * time.Millisecond {
if got = mentionCount(t, database, 2); got == 1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("bob mention_count = %d after %s, want 1 (background write never landed)", got, deadline)
}
func TestSendMessage_SelfMentionDoesNotCount(t *testing.T) {
svc, _, database := newMentionFixture(t)
sendAs(t, svc, 2, "note to self @bob")
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("self-mention mention_count = %d, want 0", got)
}
}
func TestSendMessage_BlockedAuthorDoesNotRaiseBadge(t *testing.T) {
svc, _, database := newMentionFixture(t)
seedBlock(t, database, 2, 1) // bob blocked alice
sendAs(t, svc, 1, "@bob @carol hello")
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("blocker mention_count = %d, want 0", got)
}
if got := mentionCount(t, database, 3); got != 1 {
t.Errorf("carol mention_count = %d, want 1", got)
}
}
func TestChannelFocus_ClearsMentionCount(t *testing.T) {
msgSvc, chanSvc, database := newMentionFixture(t)
sendAs(t, msgSvc, 1, "@bob look")
if got := mentionCount(t, database, 2); got != 1 {
t.Fatalf("setup: bob mention_count = %d, want 1", got)
}
if _, err := chanSvc.HandleChannelFocus(context.Background(), 2, 10); err != nil {
t.Fatalf("HandleChannelFocus: %v", err)
}
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("after focus mention_count = %d, want 0", got)
}
}
// TestDeleteMessage_ClearsMentionCount is OC-0275: deleting the only
// mentioning message in a channel must not leave a red mention badge behind.
// mention_count is a stored counter with exactly one incrementer
// (IncrementMentionCounts) and, before this fix, exactly one clearer
// (UpdateReadState via channel focus / mark-read) — DeleteMessage never
// touched it, so the badge survived the message that caused it.
func TestDeleteMessage_ClearsMentionCount(t *testing.T) {
svc, _, database := newMentionFixture(t)
res := sendAs(t, svc, 1, "@bob look")
if got := mentionCount(t, database, 2); got != 1 {
t.Fatalf("setup: bob mention_count = %d, want 1", got)
}
if _, err := svc.DeleteMessage(context.Background(), 1, res.MessageID); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("after deleting the only mentioning message, bob mention_count = %d, want 0", got)
}
}
// TestDeleteMessage_RepeatedDeleteDoesNotDecrementMentionCountTwice is
// OC-0284: DeleteMessage has no `msg.Deleted` guard and every layer beneath
// it is silently idempotent (SoftDeleteMessage is a bare UPDATE with no
// `deleted = 0` filter), so a second chat_delete for the same message id
// succeeds and runs DecrementMentionCounts a second time. That statement has
// no per-message idempotence — it just decrements every recipient row whose
// last_message_id < msgID and mention_count > 0 — so the second run eats a
// mention raised by a *different, still-live* message instead of being
// rejected as a no-op.
func TestDeleteMessage_RepeatedDeleteDoesNotDecrementMentionCountTwice(t *testing.T) {
svc, _, database := newMentionFixture(t)
m1 := sendAs(t, svc, 1, "@bob first")
m2 := sendAs(t, svc, 1, "@bob second")
if got := mentionCount(t, database, 2); got != 2 {
t.Fatalf("setup: bob mention_count = %d, want 2", got)
}
if _, err := svc.DeleteMessage(context.Background(), 1, m1.MessageID); err != nil {
t.Fatalf("first DeleteMessage: %v", err)
}
if got := mentionCount(t, database, 2); got != 1 {
t.Fatalf("after first delete, bob mention_count = %d, want 1", got)
}
// Repeating the delete for the same (already-deleted) message must be
// rejected rather than silently re-running the mention reversal — m2 is
// still live and unread, so its mention must survive.
if _, err := svc.DeleteMessage(context.Background(), 1, m1.MessageID); !errors.Is(err, ErrDeletedMessage) {
t.Fatalf("repeated DeleteMessage: err = %v, want ErrDeletedMessage", err)
}
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("after repeated delete of m1, bob mention_count = %d, want 1 (m2's mention must survive)", got)
}
// m2's mention must still be reversible by its own (first) delete.
if _, err := svc.DeleteMessage(context.Background(), 1, m2.MessageID); err != nil {
t.Fatalf("DeleteMessage(m2): %v", err)
}
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("after deleting m2, bob mention_count = %d, want 0", got)
}
}
// TestPurgeMessages_ClearsMentionCounts is the bulk-delete sibling of
// TestDeleteMessage_ClearsMentionCount (OC-0275): PurgeMessages must reverse
// the mention_count increments of every message it purges, the same way a
// single moderator delete does.
func TestPurgeMessages_ClearsMentionCounts(t *testing.T) {
svc, _, database := newMentionFixture(t)
// newMentionFixture's moderator role only carries MENTION_EVERYONE by
// default; grant it MANAGE_MESSAGES on channel 10 so mod (user 4) can purge.
seedChannelOverride(t, database, permissions.ModeratorRoleID, 10, permissions.ManageMessages, 0)
res := sendAs(t, svc, 1, "@bob look")
if got := mentionCount(t, database, 2); got != 1 {
t.Fatalf("setup: bob mention_count = %d, want 1", got)
}
purgeResult, err := svc.PurgeMessages(context.Background(), 4, 10, 10, 0)
if err != nil {
t.Fatalf("PurgeMessages: %v", err)
}
if len(purgeResult.MessageIDs) != 1 || purgeResult.MessageIDs[0] != res.MessageID {
t.Fatalf("purged ids = %v, want [%d]", purgeResult.MessageIDs, res.MessageID)
}
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("after purging the only mentioning message, bob mention_count = %d, want 0", got)
}
}
2026-08-01 22:06:14 +02:00
// ─── edits ───────────────────────────────────────────────────────────────────
func TestEditMessage_ReplacesMentionsWithoutRecounting(t *testing.T) {
svc, _, database := newMentionFixture(t)
res := sendAs(t, svc, 1, "@bob first")
if got := mentionCount(t, database, 2); got != 1 {
t.Fatalf("setup: bob mention_count = %d, want 1", got)
}
edited, err := svc.EditMessage(context.Background(), 1, res.MessageID, "@carol instead")
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
if len(edited.Mentions) != 1 || edited.Mentions[0] != 3 {
t.Fatalf("edited mentions = %v, want [3]", edited.Mentions)
}
stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID})
if err != nil {
t.Fatalf("GetMentionsByMessageIDs: %v", err)
}
if len(stored[res.MessageID]) != 1 || stored[res.MessageID][0] != 3 {
t.Errorf("stored mentions = %v, want [3]", stored[res.MessageID])
}
// Edits never advance badges — bob keeps his one, carol gains none.
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("bob mention_count = %d, want 1", got)
}
if got := mentionCount(t, database, 3); got != 0 {
t.Errorf("carol mention_count = %d, want 0 (edits never increment)", got)
}
}
func TestEditMessage_EveryoneGateApplies(t *testing.T) {
svc, _, database := newMentionFixture(t)
res := sendAs(t, svc, 1, "plain text")
if _, err := svc.EditMessage(context.Background(), 1, res.MessageID, "@everyone actually"); err != nil {
t.Fatalf("EditMessage: %v", err)
}
msg, err := database.GetMessage(context.Background(), res.MessageID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if msg.MentionsEveryone {
t.Error("edit by a member must not set mentions_everyone")
}
}
// ─── read-state fan-out ──────────────────────────────────────────────────────
func TestGetChannelUnreadCounts_CarriesMentionCount(t *testing.T) {
svc, _, database := newMentionFixture(t)
sendAs(t, svc, 1, "@bob check the ready payload")
counts, err := database.GetChannelUnreadCounts(context.Background(), 2)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
got, ok := counts[10]
if !ok {
t.Fatal("channel 10 missing from unread counts")
}
if got.MentionCount != 1 {
t.Errorf("mention_count = %d, want 1", got.MentionCount)
}
if got.UnreadCount != 1 {
t.Errorf("unread_count = %d, want 1", got.UnreadCount)
}
}
// TestSendMessage_DMMentionsResolve locks that DMs resolve usernames but never
// gain @everyone semantics — there is no permission surface behind a DM.
func TestSendMessage_DMMentionsResolve(t *testing.T) {
svc, _, database := newMentionFixture(t)
seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"})
seedDMParticipant(t, database, 50, 1)
seedDMParticipant(t, database, 50, 2)
res, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 50, UserID: 1, Username: "alice", Content: "@bob @everyone hi",
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
if len(res.Mentions) != 1 || res.Mentions[0] != 2 {
t.Errorf("mentions = %v, want [2]", res.Mentions)
}
if res.MentionsEveryone {
t.Error("DM must not honor @everyone")
}
}