2026-08-01 22:06:14 +02:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-08-28 06:54:32 +02:00
|
|
|
"errors"
|
2026-08-01 22:06:14 +02:00
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 18:48:10 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:45:30 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|