Files
OwnCord/Server/db/message_queries_test.go
T
J3vbandClaude Fable 5 8787b9066d fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116)

Route the tray Status submenu through saveUserStatus() (mapping the legacy
"offline" to "invisible") so notifications, autoIdle, and reconnect presence
restore all agree with the tray's choice; build the connected overlay from
the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the
TOTP overlay open across a rejected verify (totpPending latch) and retain
the partial token for the retry instead of clearing it in finally.

Hand-applied combined cluster preserved from the previous fix run's
overlap-guard block (both clusters edit main.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0010, OC-0011)

* fix(ws): 1 defect(s) (OC-0050)

* fix(db): 1 defect(s) (OC-0052)

* fix(client): 1 defect(s) (OC-0054)

* fix(client): 1 defect(s) (OC-0059)

* fix(auth): 1 defect(s) (OC-0061)

* fix(ws): 1 defect(s) (OC-0062)

* fix(client): 1 defect(s) (OC-0064)

* fix(service): 1 defect(s) (OC-0070)

* fix(ws): 1 defect(s) (OC-0073)

* fix(service): 2 defect(s) (OC-0075, OC-0120)

* fix(admin): 1 defect(s) (OC-0076)

* fix(voice): 1 defect(s) (OC-0084)

* fix(client): 2 defect(s) (OC-0085, OC-0094)

Scope collapsed-category persistence to the connected host instead of the
server display name, and stop the DM back button from jumping to the first
text channel when DM mode was entered without recording channelBeforeDm.

* fix(service): 1 defect(s) (OC-0087)

* fix(client): 1 defect(s) (OC-0089)

* fix(ws): 1 defect(s) (OC-0091)

* fix(api): 1 defect(s) (OC-0093)

* fix(identity): 1 defect(s) (OC-0118)

* fix(dm): 1 defect(s) (OC-0119)

* fix(voice): 1 defect(s) (OC-0135)

* fix(api): 1 defect(s) (OC-0137)

* fix(client): 1 defect(s) (OC-0142)

* fix(client): 1 defect(s) (OC-0144)

* fix(admin): 1 defect(s) (OC-0145)

* fix(updater): 1 defect(s) (OC-0146)

* fix(client): 1 defect(s) (OC-0150)

* fix(mentions): 1 defect(s) (OC-0131)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:48:10 +02:00

1357 lines
47 KiB
Go

package db_test
import (
"context"
"slices"
"strconv"
"testing"
"github.com/owncord/server/db"
)
// seedUser inserts a minimal test user and returns its ID.
func seedUser(t *testing.T, database *db.DB, username string) int64 {
t.Helper()
id, err := database.CreateUser(context.Background(), username, "hash", 4)
if err != nil {
t.Fatalf("seedUser(%q): %v", username, err)
}
return id
}
// seedChannel inserts a minimal test channel and returns its ID.
func seedChannel(t *testing.T, database *db.DB, name string) int64 {
t.Helper()
id, err := database.CreateChannel(context.Background(), name, "text", "", "", 0)
if err != nil {
t.Fatalf("seedChannel(%q): %v", name, err)
}
return id
}
// ─── CreateMessage ────────────────────────────────────────────────────────────
func TestCreateMessage_ReturnsID(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "alice")
chID := seedChannel(t, database, "general")
id, err := database.CreateMessage(context.Background(), chID, userID, "hello", nil)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
if id <= 0 {
t.Errorf("expected positive ID, got %d", id)
}
}
func TestCreateMessage_WithReplyTo(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "alice")
chID := seedChannel(t, database, "general")
parentID, _ := database.CreateMessage(context.Background(), chID, userID, "parent", nil)
replyID, err := database.CreateMessage(context.Background(), chID, userID, "reply", &parentID)
if err != nil {
t.Fatalf("CreateMessage with reply: %v", err)
}
msg, _ := database.GetMessage(context.Background(), replyID)
if msg.ReplyTo == nil || *msg.ReplyTo != parentID {
t.Errorf("ReplyTo = %v, want %d", msg.ReplyTo, parentID)
}
}
func TestCreateMessage_ContentPreserved(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "bob")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "test content", nil)
msg, _ := database.GetMessage(context.Background(), id)
if msg.Content != "test content" {
t.Errorf("Content = %q, want 'test content'", msg.Content)
}
}
// ─── GetMessage ───────────────────────────────────────────────────────────────
func TestGetMessage_NotFound(t *testing.T) {
database := openMigratedMemory(t)
msg, err := database.GetMessage(context.Background(), 9999)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg != nil {
t.Error("expected nil for non-existent message")
}
}
func TestGetMessage_Fields(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "carol")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "hello world", nil)
msg, err := database.GetMessage(context.Background(), id)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg == nil {
t.Fatal("expected message, got nil")
}
if msg.ChannelID != chID {
t.Errorf("ChannelID = %d, want %d", msg.ChannelID, chID)
}
if msg.UserID != userID {
t.Errorf("UserID = %d, want %d", msg.UserID, userID)
}
if msg.Deleted {
t.Error("expected Deleted=false for new message")
}
if msg.Pinned {
t.Error("expected Pinned=false for new message")
}
if msg.EditedAt != nil {
t.Error("expected EditedAt=nil for new message")
}
}
// ─── GetMessages ──────────────────────────────────────────────────────────────
func TestGetMessages_EmptyChannel(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "empty")
msgs, err := database.GetMessages(context.Background(), chID, 0, 50)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestGetMessages_ReturnsMessages(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "dave")
chID := seedChannel(t, database, "ch")
for i := range 3 {
_, err := database.CreateMessage(context.Background(), chID, userID, "msg", nil)
if err != nil {
t.Fatalf("CreateMessage %d: %v", i, err)
}
}
msgs, err := database.GetMessages(context.Background(), chID, 0, 50)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(msgs) != 3 {
t.Errorf("expected 3 messages, got %d", len(msgs))
}
}
func TestGetMessages_LimitRespected(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "eve")
chID := seedChannel(t, database, "ch")
for range 10 {
_, _ = database.CreateMessage(context.Background(), chID, userID, "msg", nil)
}
msgs, _ := database.GetMessages(context.Background(), chID, 0, 5)
if len(msgs) != 5 {
t.Errorf("expected 5 messages (limit), got %d", len(msgs))
}
}
func TestGetMessages_BeforePagination(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "frank")
chID := seedChannel(t, database, "ch")
ids := make([]int64, 0, 5)
for range 5 {
id, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil)
ids = append(ids, id)
}
// Get messages before the 4th message (should get 3 messages: ids 0,1,2).
msgs, _ := database.GetMessages(context.Background(), chID, ids[3], 50)
if len(msgs) != 3 {
t.Errorf("expected 3 messages before id %d, got %d", ids[3], len(msgs))
}
}
func TestGetMessages_IncludesUsername(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "grace")
chID := seedChannel(t, database, "ch")
_, _ = database.CreateMessage(context.Background(), chID, userID, "hi", nil)
msgs, _ := database.GetMessages(context.Background(), chID, 0, 50)
if len(msgs) == 0 {
t.Fatal("expected messages")
}
if msgs[0].Username != "grace" {
t.Errorf("Username = %q, want 'grace'", msgs[0].Username)
}
}
// ─── EditMessage ──────────────────────────────────────────────────────────────
func TestEditMessage_OwnerCanEdit(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "henry")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "original", nil)
updated, err := database.EditMessage(context.Background(), id, userID, "updated")
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
// The RETURNING row must reflect the write without a re-read.
if updated == nil || updated.Content != "updated" {
t.Errorf("returned row Content = %+v, want 'updated'", updated)
}
if updated.EditedAt == nil {
t.Error("returned row EditedAt should be set after edit")
}
msg, _ := database.GetMessage(context.Background(), id)
if msg.Content != "updated" {
t.Errorf("Content = %q, want 'updated'", msg.Content)
}
if msg.EditedAt == nil {
t.Error("EditedAt should be set after edit")
}
}
func TestEditMessage_NonOwnerCannotEdit(t *testing.T) {
database := openMigratedMemory(t)
ownerID := seedUser(t, database, "ivan")
otherID := seedUser(t, database, "julia")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, ownerID, "original", nil)
_, err := database.EditMessage(context.Background(), id, otherID, "hacked")
if err == nil {
t.Error("EditMessage by non-owner should return error")
}
}
func TestEditMessage_NotFound(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "kim")
_, err := database.EditMessage(context.Background(), 9999, userID, "x")
if err == nil {
t.Error("EditMessage non-existent should return error")
}
}
// ─── DeleteMessage ────────────────────────────────────────────────────────────
func TestDeleteMessage_OwnerCanDelete(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "larry")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "bye", nil)
if err := database.DeleteMessage(context.Background(), id, userID, false); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
msg, _ := database.GetMessage(context.Background(), id)
if msg == nil {
t.Fatal("soft-deleted message should still exist in DB")
}
if !msg.Deleted {
t.Error("expected Deleted=true after soft delete")
}
}
func TestDeleteMessage_ContentPreservedAfterSoftDelete(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "mia")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "sensitive", nil)
_ = database.DeleteMessage(context.Background(), id, userID, false)
msg, _ := database.GetMessage(context.Background(), id)
// Content preserved for broadcast (soft delete only flags deleted=1).
if msg.Content == "" {
t.Error("content should be preserved on soft delete for broadcast purposes")
}
}
func TestDeleteMessage_NonOwnerBlockedWithoutMod(t *testing.T) {
database := openMigratedMemory(t)
ownerID := seedUser(t, database, "nate")
otherID := seedUser(t, database, "olivia")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, ownerID, "msg", nil)
err := database.DeleteMessage(context.Background(), id, otherID, false)
if err == nil {
t.Error("DeleteMessage by non-owner non-mod should return error")
}
}
func TestDeleteMessage_ModCanDeleteAny(t *testing.T) {
database := openMigratedMemory(t)
ownerID := seedUser(t, database, "pete")
modID := seedUser(t, database, "quinn")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, ownerID, "msg", nil)
if err := database.DeleteMessage(context.Background(), id, modID, true); err != nil {
t.Fatalf("DeleteMessage by mod: %v", err)
}
msg, _ := database.GetMessage(context.Background(), id)
if !msg.Deleted {
t.Error("expected Deleted=true after mod delete")
}
}
func TestDeleteMessage_NotFound(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "rachel")
err := database.DeleteMessage(context.Background(), 9999, userID, true)
if err == nil {
t.Error("DeleteMessage non-existent should return error")
}
}
// ─── PurgeChannelMessages ─────────────────────────────────────────────────────
// purgeSeed inserts n messages into a fresh channel and returns the database,
// the channel id, and the message ids in insertion (oldest-first) order.
func purgeSeed(t *testing.T, n int) (*db.DB, int64, []int64) {
t.Helper()
database := openMigratedMemory(t)
userID := seedUser(t, database, "purger")
chID := seedChannel(t, database, "purge-ch")
ids := make([]int64, 0, n)
for i := range n {
id, err := database.CreateMessage(context.Background(), chID, userID,
"msg"+strconv.Itoa(i), nil)
if err != nil {
t.Fatalf("CreateMessage %d: %v", i, err)
}
ids = append(ids, id)
}
return database, chID, ids
}
func TestPurgeChannelMessages_DeletesNewestFirst(t *testing.T) {
database, chID, ids := purgeSeed(t, 5)
got, err := database.PurgeChannelMessages(context.Background(), chID, 0, 2)
if err != nil {
t.Fatalf("PurgeChannelMessages: %v", err)
}
want := []int64{ids[4], ids[3]}
if !slices.Equal(got, want) {
t.Fatalf("purged ids = %v, want %v", got, want)
}
for _, id := range want {
msg, _ := database.GetMessage(context.Background(), id)
if msg == nil || !msg.Deleted {
t.Errorf("message %d should be soft-deleted", id)
}
}
for _, id := range ids[:3] {
msg, _ := database.GetMessage(context.Background(), id)
if msg == nil || msg.Deleted {
t.Errorf("message %d should be untouched", id)
}
}
}
func TestPurgeChannelMessages_PreservesTombstones(t *testing.T) {
database, chID, ids := purgeSeed(t, 3)
if _, err := database.PurgeChannelMessages(context.Background(), chID, 0, 3); err != nil {
t.Fatalf("PurgeChannelMessages: %v", err)
}
// The rows must survive with their content, so tombstones render and
// reply_to targets still resolve — exactly as a single soft delete.
for _, id := range ids {
msg, err := database.GetMessage(context.Background(), id)
if err != nil {
t.Fatalf("GetMessage(%d): %v", id, err)
}
if msg == nil {
t.Fatalf("message %d was hard-deleted", id)
}
if !msg.Deleted {
t.Errorf("message %d not marked deleted", id)
}
if msg.Content == "" {
t.Errorf("message %d lost its content", id)
}
}
}
func TestPurgeChannelMessages_SkipsAlreadyDeleted(t *testing.T) {
database, chID, ids := purgeSeed(t, 4)
if _, err := database.PurgeChannelMessages(context.Background(), chID, 0, 1); err != nil {
t.Fatalf("first purge: %v", err)
}
got, err := database.PurgeChannelMessages(context.Background(), chID, 0, 10)
if err != nil {
t.Fatalf("second purge: %v", err)
}
want := []int64{ids[2], ids[1], ids[0]}
if !slices.Equal(got, want) {
t.Fatalf("second purge ids = %v, want %v", got, want)
}
}
func TestPurgeChannelMessages_BeforeCursor(t *testing.T) {
database, chID, ids := purgeSeed(t, 5)
got, err := database.PurgeChannelMessages(context.Background(), chID, ids[2], 10)
if err != nil {
t.Fatalf("PurgeChannelMessages: %v", err)
}
want := []int64{ids[1], ids[0]}
if !slices.Equal(got, want) {
t.Fatalf("purged ids = %v, want %v", got, want)
}
for _, id := range ids[2:] {
msg, _ := database.GetMessage(context.Background(), id)
if msg.Deleted {
t.Errorf("message %d at/after the cursor should be untouched", id)
}
}
}
func TestPurgeChannelMessages_OtherChannelsUntouched(t *testing.T) {
database, chID, ids := purgeSeed(t, 2)
otherCh := seedChannel(t, database, "other")
otherID, _ := database.CreateMessage(context.Background(), otherCh, ids[0], "keep", nil)
if _, err := database.PurgeChannelMessages(context.Background(), chID, 0, 100); err != nil {
t.Fatalf("PurgeChannelMessages: %v", err)
}
msg, _ := database.GetMessage(context.Background(), otherID)
if msg == nil || msg.Deleted {
t.Error("a message in another channel was purged")
}
}
func TestPurgeChannelMessages_EmptyChannelAndZeroLimit(t *testing.T) {
database, chID, _ := purgeSeed(t, 1)
got, err := database.PurgeChannelMessages(context.Background(), chID, 0, 0)
if err != nil {
t.Fatalf("zero limit: %v", err)
}
if len(got) != 0 {
t.Fatalf("zero limit purged %v, want none", got)
}
emptyCh := seedChannel(t, database, "empty")
got, err = database.PurgeChannelMessages(context.Background(), emptyCh, 0, 50)
if err != nil {
t.Fatalf("empty channel: %v", err)
}
if got == nil || len(got) != 0 {
t.Fatalf("empty channel returned %v, want empty non-nil slice", got)
}
}
// ─── Reactions ────────────────────────────────────────────────────────────────
func TestAddReaction_Success(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "sam")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil)
if err := database.AddReaction(context.Background(), msgID, userID, "👍"); err != nil {
t.Fatalf("AddReaction: %v", err)
}
}
func TestAddReaction_UniqueConstraint(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "tina")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil)
_ = database.AddReaction(context.Background(), msgID, userID, "❤️")
err := database.AddReaction(context.Background(), msgID, userID, "❤️")
if err == nil {
t.Error("adding duplicate reaction should return error")
}
}
func TestRemoveReaction_Success(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "uma")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil)
_ = database.AddReaction(context.Background(), msgID, userID, "😂")
if err := database.RemoveReaction(context.Background(), msgID, userID, "😂"); err != nil {
t.Fatalf("RemoveReaction: %v", err)
}
}
func TestRemoveReaction_NotFound(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "victor")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil)
err := database.RemoveReaction(context.Background(), msgID, userID, "🔥")
if err == nil {
t.Error("removing non-existent reaction should return error")
}
}
func TestGetReactions_Empty(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "wendy")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil)
counts, err := database.GetReactions(context.Background(), msgID)
if err != nil {
t.Fatalf("GetReactions: %v", err)
}
if len(counts) != 0 {
t.Errorf("expected 0 reactions, got %d", len(counts))
}
}
func TestGetReactions_Counts(t *testing.T) {
database := openMigratedMemory(t)
u1 := seedUser(t, database, "xavier")
u2 := seedUser(t, database, "yvonne")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, u1, "hi", nil)
_ = database.AddReaction(context.Background(), msgID, u1, "👍")
_ = database.AddReaction(context.Background(), msgID, u2, "👍")
_ = database.AddReaction(context.Background(), msgID, u1, "❤️")
counts, _ := database.GetReactions(context.Background(), msgID)
if len(counts) != 2 {
t.Fatalf("expected 2 emoji types, got %d", len(counts))
}
for _, rc := range counts {
switch rc.Emoji {
case "👍":
if rc.Count != 2 {
t.Errorf("👍 count = %d, want 2", rc.Count)
}
case "❤️":
if rc.Count != 1 {
t.Errorf("❤️ count = %d, want 1", rc.Count)
}
default:
t.Errorf("unexpected emoji %q", rc.Emoji)
}
}
}
// ─── SearchMessages ───────────────────────────────────────────────────────────
func TestSearchMessages_FindsMatch(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "zara")
chID := seedChannel(t, database, "searchch")
_, _ = database.CreateMessage(context.Background(), chID, userID, "hello world fts test", nil)
_, _ = database.CreateMessage(context.Background(), chID, userID, "unrelated content here", nil)
results, err := database.SearchMessages(context.Background(), "hello", nil, 10)
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if len(results) != 1 {
t.Errorf("expected 1 result, got %d", len(results))
}
if results[0].Content != "hello world fts test" {
t.Errorf("Content = %q, want 'hello world fts test'", results[0].Content)
}
}
func TestSearchMessages_FilterByChannel(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "adam")
ch1 := seedChannel(t, database, "ch1")
ch2 := seedChannel(t, database, "ch2")
_, _ = database.CreateMessage(context.Background(), ch1, userID, "needle in channel 1", nil)
_, _ = database.CreateMessage(context.Background(), ch2, userID, "needle in channel 2", nil)
results, _ := database.SearchMessages(context.Background(), "needle", &ch1, 10)
if len(results) != 1 {
t.Errorf("expected 1 result in ch1, got %d", len(results))
}
if results[0].ChannelID != ch1 {
t.Errorf("ChannelID = %d, want %d", results[0].ChannelID, ch1)
}
}
func TestSearchMessages_NoResults(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "beth")
chID := seedChannel(t, database, "ch")
_, _ = database.CreateMessage(context.Background(), chID, userID, "hello there", nil)
results, _ := database.SearchMessages(context.Background(), "xyzzy", nil, 10)
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestSearchMessages_LimitRespected(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "carl")
chID := seedChannel(t, database, "ch")
for range 5 {
_, _ = database.CreateMessage(context.Background(), chID, userID, "searchable keyword content", nil)
}
results, _ := database.SearchMessages(context.Background(), "keyword", nil, 3)
if len(results) != 3 {
t.Errorf("expected 3 results (limit), got %d", len(results))
}
}
func TestSearchMessages_DeletedNotReturned(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "diana")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "vanishing keyword message", nil)
_ = database.DeleteMessage(context.Background(), id, userID, false)
results, _ := database.SearchMessages(context.Background(), "vanishing", nil, 10)
if len(results) != 0 {
t.Errorf("expected 0 results (deleted excluded), got %d", len(results))
}
}
// Migration 019 narrows the messages_au trigger to AFTER UPDATE OF content;
// an edit must still reindex the FTS table (old term gone, new term found).
func TestSearchMessages_EditReindexes(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "edith")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "obsolete wording here", nil)
if _, err := database.EditMessage(context.Background(), id, userID, "fresh wording here"); err != nil {
t.Fatalf("EditMessage: %v", err)
}
stale, _ := database.SearchMessages(context.Background(), "obsolete", nil, 10)
if len(stale) != 0 {
t.Errorf("expected 0 results for pre-edit content, got %d", len(stale))
}
updated, _ := database.SearchMessages(context.Background(), "fresh", nil, 10)
if len(updated) != 1 {
t.Errorf("expected 1 result for post-edit content, got %d", len(updated))
}
}
// Pinning updates a non-content column, which the narrowed trigger must
// ignore — the message stays searchable and the FTS index stays consistent.
func TestSearchMessages_PinnedMessageStaysSearchable(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "pinny")
chID := seedChannel(t, database, "ch")
id, _ := database.CreateMessage(context.Background(), chID, userID, "pinworthy announcement", nil)
if err := database.SetMessagePinned(context.Background(), id, true); err != nil {
t.Fatalf("SetMessagePinned: %v", err)
}
results, err := database.SearchMessages(context.Background(), "pinworthy", nil, 10)
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if len(results) != 1 {
t.Errorf("expected pinned message to remain searchable, got %d results", len(results))
}
}
// OC-0096: sanitizeFTSQuery kept '-' as an allowed bareword character, but in
// FTS5's MATCH grammar '-' introduces a column filter ("-col: expr"), so
// "well-known" parses as "well" followed by a filter on a nonexistent column
// "known" and SQLite raises "no such column: known" instead of matching.
func TestSearchMessages_HyphenatedQuery(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "hyphenuser")
chID := seedChannel(t, database, "hyphench")
_, _ = database.CreateMessage(context.Background(), chID, userID, "a well-known fact", nil)
results, err := database.SearchMessages(context.Background(), "well-known", nil, 10)
if err != nil {
t.Fatalf("SearchMessages(%q): %v", "well-known", err)
}
if len(results) != 1 {
t.Errorf("expected 1 result, got %d", len(results))
}
}
// ─── UpdateReadState ──────────────────────────────────────────────────────────
func TestUpdateReadState_Upsert(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "ella")
chID := seedChannel(t, database, "ch")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil)
if err := database.UpdateReadState(context.Background(), userID, chID, msgID); err != nil {
t.Fatalf("UpdateReadState: %v", err)
}
// Update again with higher message ID — should not error.
msgID2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil)
if err := database.UpdateReadState(context.Background(), userID, chID, msgID2); err != nil {
t.Fatalf("UpdateReadState second call: %v", err)
}
}
// ─── GetMessagesForAPI ──────────────────────────────────────────────────────
func TestGetMessagesForAPI_Empty(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "apichan")
userID := seedUser(t, database, "apiuser")
msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID)
if err != nil {
t.Fatalf("GetMessagesForAPI: %v", err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestGetMessagesForAPI_ReturnsUserObject(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "apiuser2")
chID := seedChannel(t, database, "apichan2")
_, _ = database.CreateMessage(context.Background(), chID, userID, "hello api", nil)
msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID)
if err != nil {
t.Fatalf("GetMessagesForAPI: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].User.Username != "apiuser2" {
t.Errorf("User.Username = %q, want 'apiuser2'", msgs[0].User.Username)
}
if msgs[0].User.ID != userID {
t.Errorf("User.ID = %d, want %d", msgs[0].User.ID, userID)
}
if msgs[0].Content != "hello api" {
t.Errorf("Content = %q, want 'hello api'", msgs[0].Content)
}
}
func TestGetMessagesForAPI_BeforePagination(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "apipage")
chID := seedChannel(t, database, "apich")
ids := make([]int64, 0, 5)
for range 5 {
id, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil)
ids = append(ids, id)
}
msgs, err := database.GetMessagesForAPI(context.Background(), chID, ids[3], 50, userID)
if err != nil {
t.Fatalf("GetMessagesForAPI with before: %v", err)
}
if len(msgs) != 3 {
t.Errorf("expected 3 messages before id %d, got %d", ids[3], len(msgs))
}
}
func TestGetMessagesForAPI_WithReactions(t *testing.T) {
database := openMigratedMemory(t)
u1 := seedUser(t, database, "reactuser1")
u2 := seedUser(t, database, "reactuser2")
chID := seedChannel(t, database, "reactchan")
msgID, _ := database.CreateMessage(context.Background(), chID, u1, "react me", nil)
_ = database.AddReaction(context.Background(), msgID, u1, "👍")
_ = database.AddReaction(context.Background(), msgID, u2, "👍")
msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, u1)
if err != nil {
t.Fatalf("GetMessagesForAPI: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if len(msgs[0].Reactions) != 1 {
t.Fatalf("expected 1 reaction type, got %d", len(msgs[0].Reactions))
}
if msgs[0].Reactions[0].Count != 2 {
t.Errorf("reaction count = %d, want 2", msgs[0].Reactions[0].Count)
}
if !msgs[0].Reactions[0].Me {
t.Error("Me should be true for requesting user who reacted")
}
}
func TestGetMessagesForAPI_ExcludesDeleted(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "apidel")
chID := seedChannel(t, database, "apidelchan")
id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted msg", nil)
_ = database.DeleteMessage(context.Background(), id, userID, false)
_, _ = database.CreateMessage(context.Background(), chID, userID, "visible msg", nil)
msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID)
if err != nil {
t.Fatalf("GetMessagesForAPI: %v", err)
}
if len(msgs) != 1 {
t.Errorf("expected 1 message (deleted excluded), got %d", len(msgs))
}
}
// ─── GetMessagesAroundForAPI ────────────────────────────────────────────────
// seedAroundMessages fills a channel with n messages and returns their ids in
// ascending order.
func seedAroundMessages(t *testing.T, database *db.DB, chID, userID int64, n int) []int64 {
t.Helper()
ids := make([]int64, 0, n)
for i := range n {
id, err := database.CreateMessage(context.Background(), chID, userID, "m"+strconv.Itoa(i), nil)
if err != nil {
t.Fatalf("CreateMessage %d: %v", i, err)
}
ids = append(ids, id)
}
return ids
}
func TestGetMessagesAroundForAPI_CentersAndOrdersAscending(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "aroundu1")
chID := seedChannel(t, database, "aroundc1")
ids := seedAroundMessages(t, database, chID, userID, 20)
msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[10], 3, 2, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI: %v", err)
}
// 3 older + centre + 2 newer.
want := []int64{ids[7], ids[8], ids[9], ids[10], ids[11], ids[12]}
got := make([]int64, 0, len(msgs))
for _, m := range msgs {
got = append(got, m.ID)
}
if !slices.Equal(got, want) {
t.Errorf("window = %v, want %v", got, want)
}
}
func TestGetMessagesAroundForAPI_ClampsAtChannelEdges(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "aroundu2")
chID := seedChannel(t, database, "aroundc2")
ids := seedAroundMessages(t, database, chID, userID, 4)
first, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[0], 10, 10, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI(first): %v", err)
}
if len(first) != 4 || first[0].ID != ids[0] {
t.Errorf("window at the first message = %d entries starting at %d, want 4 starting at %d",
len(first), first[0].ID, ids[0])
}
last, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[3], 10, 10, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI(last): %v", err)
}
if len(last) != 4 || last[len(last)-1].ID != ids[3] {
t.Errorf("window at the last message = %d entries ending at %d, want 4 ending at %d",
len(last), last[len(last)-1].ID, ids[3])
}
}
func TestGetMessagesAroundForAPI_ExcludesDeleted(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "aroundu3")
chID := seedChannel(t, database, "aroundc3")
ids := seedAroundMessages(t, database, chID, userID, 5)
if err := database.DeleteMessage(context.Background(), ids[1], userID, false); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[2], 5, 5, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI: %v", err)
}
for _, m := range msgs {
if m.ID == ids[1] {
t.Fatalf("deleted message %d present in the window", ids[1])
}
}
if len(msgs) != 4 {
t.Errorf("window size = %d, want 4", len(msgs))
}
}
func TestGetMessagesAroundForAPI_ScopedToChannel(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "aroundu4")
chA := seedChannel(t, database, "aroundc4a")
chB := seedChannel(t, database, "aroundc4b")
idsA := seedAroundMessages(t, database, chA, userID, 3)
seedAroundMessages(t, database, chB, userID, 3)
msgs, err := database.GetMessagesAroundForAPI(context.Background(), chA, idsA[1], 10, 10, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("window size = %d, want the 3 messages of channel A only", len(msgs))
}
for _, m := range msgs {
if m.ChannelID != chA {
t.Errorf("message %d belongs to channel %d, not %d", m.ID, m.ChannelID, chA)
}
}
}
func TestGetMessagesAroundForAPI_NegativeCountsClampToZero(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "aroundu5")
chID := seedChannel(t, database, "aroundc5")
ids := seedAroundMessages(t, database, chID, userID, 5)
// A negative count must not become a negative SQL LIMIT (which SQLite
// reads as "no limit" and would silently return the whole channel).
msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[2], -4, -4, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI: %v", err)
}
if len(msgs) != 1 || msgs[0].ID != ids[2] {
t.Errorf("window = %d entries, want just the centre %d", len(msgs), ids[2])
}
}
func TestGetMessagesAroundForAPI_UnknownCentreIsEmpty(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "aroundu6")
chID := seedChannel(t, database, "aroundc6")
seedAroundMessages(t, database, chID, userID, 3)
msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, 999999, 5, 5, userID)
if err != nil {
t.Fatalf("GetMessagesAroundForAPI: %v", err)
}
// Nothing is <= the centre in this channel below it, and nothing above it.
if len(msgs) != 3 {
t.Errorf("window size = %d; an out-of-range centre should still be bounded by the channel", len(msgs))
}
}
// ─── GetChannelUnreadCounts ─────────────────────────────────────────────────
func TestGetChannelUnreadCounts_NoMessages(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "unreaduser")
_ = seedChannel(t, database, "unreadchan")
counts, err := database.GetChannelUnreadCounts(context.Background(), userID)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
// Should return entries for text channels even with 0 messages.
if counts == nil {
t.Fatal("GetChannelUnreadCounts returned nil")
}
}
func TestGetChannelUnreadCounts_WithUnreadMessages(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "unreaduser2")
chID := seedChannel(t, database, "unreadchan2")
// Create 3 messages, mark first as read.
msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil)
_, _ = database.CreateMessage(context.Background(), chID, userID, "msg2", nil)
_, _ = database.CreateMessage(context.Background(), chID, userID, "msg3", nil)
_ = database.UpdateReadState(context.Background(), userID, chID, msg1)
counts, err := database.GetChannelUnreadCounts(context.Background(), userID)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
cu, ok := counts[chID]
if !ok {
t.Fatalf("channel %d not in unread counts", chID)
}
if cu.UnreadCount != 2 {
t.Errorf("UnreadCount = %d, want 2", cu.UnreadCount)
}
}
// A channel with no messages must still yield a 0,0 entry — the correlated
// subquery rewrite must not silently drop empty channels from the ready
// payload's unread map.
func TestGetChannelUnreadCounts_ZeroMessageChannelYieldsZeros(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "unreadzero")
chID := seedChannel(t, database, "emptychan")
counts, err := database.GetChannelUnreadCounts(context.Background(), userID)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
cu, ok := counts[chID]
if !ok {
t.Fatalf("empty channel %d missing from unread counts", chID)
}
if cu.LastMessageID != 0 || cu.UnreadCount != 0 {
t.Errorf("empty channel = {last:%d unread:%d}, want {0 0}", cu.LastMessageID, cu.UnreadCount)
}
}
func TestGetChannelUnreadCounts_IncludesAnnouncementChannels(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "unreadann")
annID, err := database.CreateChannel(context.Background(), "announcements", "announcement", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel(announcement): %v", err)
}
voiceID, err := database.CreateChannel(context.Background(), "voicechan", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel(voice): %v", err)
}
msgID, _ := database.CreateMessage(context.Background(), annID, userID, "server news", nil)
counts, err := database.GetChannelUnreadCounts(context.Background(), userID)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
cu, ok := counts[annID]
if !ok {
t.Fatalf("announcement channel %d missing from unread counts", annID)
}
if cu.LastMessageID != msgID {
t.Errorf("LastMessageID = %d, want %d", cu.LastMessageID, msgID)
}
if cu.UnreadCount != 1 {
t.Errorf("UnreadCount = %d, want 1", cu.UnreadCount)
}
if _, ok := counts[voiceID]; ok {
t.Errorf("voice channel %d must not appear in unread counts", voiceID)
}
}
// Deleted messages count neither toward unread nor toward last_msg_id.
func TestGetChannelUnreadCounts_ExcludesDeleted(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "unreaddel")
chID := seedChannel(t, database, "unreaddelchan")
keepID, _ := database.CreateMessage(context.Background(), chID, userID, "keep", nil)
delID, _ := database.CreateMessage(context.Background(), chID, userID, "gone", nil)
if err := database.DeleteMessage(context.Background(), delID, userID, false); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
counts, err := database.GetChannelUnreadCounts(context.Background(), userID)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
cu := counts[chID]
if cu.LastMessageID != keepID {
t.Errorf("LastMessageID = %d, want %d (deleted excluded)", cu.LastMessageID, keepID)
}
if cu.UnreadCount != 1 {
t.Errorf("UnreadCount = %d, want 1 (deleted excluded)", cu.UnreadCount)
}
}
// ─── GetLatestMessageID ─────────────────────────────────────────────────────
func TestGetLatestMessageID_Empty(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "latestchan")
id, err := database.GetLatestMessageID(context.Background(), chID)
if err != nil {
t.Fatalf("GetLatestMessageID: %v", err)
}
if id != 0 {
t.Errorf("expected 0 for empty channel, got %d", id)
}
}
func TestGetLatestMessageID_ReturnsHighest(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "latestuser")
chID := seedChannel(t, database, "latestchan2")
_, _ = database.CreateMessage(context.Background(), chID, userID, "first", nil)
_, _ = database.CreateMessage(context.Background(), chID, userID, "second", nil)
lastID, _ := database.CreateMessage(context.Background(), chID, userID, "third", nil)
id, err := database.GetLatestMessageID(context.Background(), chID)
if err != nil {
t.Fatalf("GetLatestMessageID: %v", err)
}
if id != lastID {
t.Errorf("GetLatestMessageID = %d, want %d", id, lastID)
}
}
func TestGetLatestMessageID_ExcludesDeleted(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "latestdel")
chID := seedChannel(t, database, "latestdelchan")
id1, _ := database.CreateMessage(context.Background(), chID, userID, "keep", nil)
id2, _ := database.CreateMessage(context.Background(), chID, userID, "delete me", nil)
_ = database.DeleteMessage(context.Background(), id2, userID, false)
latestID, err := database.GetLatestMessageID(context.Background(), chID)
if err != nil {
t.Fatalf("GetLatestMessageID: %v", err)
}
if latestID != id1 {
t.Errorf("GetLatestMessageID = %d, want %d (deleted excluded)", latestID, id1)
}
}
// ─── GetReactionUsers ───────────────────────────────────────────────────────
func TestGetReactionUsers_ReturnsReactorsInReactionOrder(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "reactusers")
author := seedUser(t, database, "reactauthor")
first := seedUser(t, database, "reactfirst")
second := seedUser(t, database, "reactsecond")
msgID, _ := database.CreateMessage(context.Background(), chID, author, "react to me", nil)
// second reacts before first, so reaction order (not user id) decides.
if err := database.AddReaction(context.Background(), msgID, second, "👍"); err != nil {
t.Fatalf("AddReaction(second): %v", err)
}
if err := database.AddReaction(context.Background(), msgID, first, "👍"); err != nil {
t.Fatalf("AddReaction(first): %v", err)
}
// A different emoji on the same message must not leak in.
if err := database.AddReaction(context.Background(), msgID, author, "🎉"); err != nil {
t.Fatalf("AddReaction(other emoji): %v", err)
}
users, err := database.GetReactionUsers(context.Background(), msgID, "👍", 100)
if err != nil {
t.Fatalf("GetReactionUsers: %v", err)
}
if len(users) != 2 {
t.Fatalf("len(users) = %d, want 2 (%+v)", len(users), users)
}
if users[0].Username != "reactsecond" || users[1].Username != "reactfirst" {
t.Errorf("order = [%s %s], want [reactsecond reactfirst]", users[0].Username, users[1].Username)
}
if users[0].ID != second {
t.Errorf("users[0].ID = %d, want %d", users[0].ID, second)
}
}
func TestGetReactionUsers_UnknownEmojiIsEmptyNotError(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "reactempty")
userID := seedUser(t, database, "reactnone")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil)
users, err := database.GetReactionUsers(context.Background(), msgID, "🐉", 100)
if err != nil {
t.Fatalf("GetReactionUsers: %v", err)
}
if len(users) != 0 {
t.Errorf("len(users) = %d, want 0", len(users))
}
}
func TestGetReactionUsers_ClampsLimitToMax(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "reactclamp")
author := seedUser(t, database, "clampauthor")
msgID, _ := database.CreateMessage(context.Background(), chID, author, "many", nil)
const reactors = db.MaxReactionUsers + 5
for i := range reactors {
uid := seedUser(t, database, "clamper"+strconv.Itoa(i))
if err := database.AddReaction(context.Background(), msgID, uid, "👍"); err != nil {
t.Fatalf("AddReaction(%d): %v", i, err)
}
}
// A caller asking for more than the cap still gets at most the cap.
users, err := database.GetReactionUsers(context.Background(), msgID, "👍", 10_000)
if err != nil {
t.Fatalf("GetReactionUsers: %v", err)
}
if len(users) != db.MaxReactionUsers {
t.Errorf("len(users) = %d, want %d", len(users), db.MaxReactionUsers)
}
// A non-positive limit means "the cap", not "nothing".
users, err = database.GetReactionUsers(context.Background(), msgID, "👍", 0)
if err != nil {
t.Fatalf("GetReactionUsers(0): %v", err)
}
if len(users) != db.MaxReactionUsers {
t.Errorf("len(users) with limit 0 = %d, want %d", len(users), db.MaxReactionUsers)
}
}
func TestGetReactionUsers_RespectsSmallerLimit(t *testing.T) {
database := openMigratedMemory(t)
chID := seedChannel(t, database, "reactsmall")
author := seedUser(t, database, "smallauthor")
msgID, _ := database.CreateMessage(context.Background(), chID, author, "some", nil)
for i := range 5 {
uid := seedUser(t, database, "smaller"+strconv.Itoa(i))
_ = database.AddReaction(context.Background(), msgID, uid, "👍")
}
users, err := database.GetReactionUsers(context.Background(), msgID, "👍", 2)
if err != nil {
t.Fatalf("GetReactionUsers: %v", err)
}
if len(users) != 2 {
t.Errorf("len(users) = %d, want 2", len(users))
}
}
// ─── GetChannelUnreadCounts: DM rows ────────────────────────────────────────
// DM channels the user participates in must appear, so the ready payload can
// ship a real mention_count for them instead of resetting the badge to 0 on
// every reconnect.
func TestGetChannelUnreadCounts_IncludesParticipatingDMs(t *testing.T) {
database := openMigratedMemory(t)
alice := seedUser(t, database, "dmunreadalice")
bob := seedUser(t, database, "dmunreadbob")
ch, _, err := database.GetOrCreateDMChannel(context.Background(), alice, bob)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
msgID, _ := database.CreateMessage(context.Background(), ch.ID, bob, "hey", nil)
if err := database.IncrementMentionCounts(context.Background(), ch.ID, msgID, []int64{alice}); err != nil {
t.Fatalf("IncrementMentionCounts: %v", err)
}
counts, err := database.GetChannelUnreadCounts(context.Background(), alice)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
cu, ok := counts[ch.ID]
if !ok {
t.Fatalf("DM channel %d missing from unread counts for a participant", ch.ID)
}
if cu.LastMessageID != msgID {
t.Errorf("LastMessageID = %d, want %d", cu.LastMessageID, msgID)
}
if cu.UnreadCount != 1 {
t.Errorf("UnreadCount = %d, want 1", cu.UnreadCount)
}
if cu.MentionCount != 1 {
t.Errorf("MentionCount = %d, want 1", cu.MentionCount)
}
}
// ─── GetPinnedMessages ────────────────────────────────────────────────────────
// TestGetPinnedMessages_Capped pins more messages than db.MaxPinnedMessages and
// verifies the query stays bounded instead of returning every pinned row. An
// uncapped GetPinnedMessages feeds an unbounded message-ID slice into the
// shared IN-list batch fetches (reactions/attachments/mentions); past
// SQLite's ~32766 bound-parameter limit that fails every call permanently
// ("too many SQL variables"). This pins the cap well below that ceiling.
func TestGetPinnedMessages_Capped(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "pinner")
chID := seedChannel(t, database, "pins")
total := db.MaxPinnedMessages + 5
for i := range total {
id, err := database.CreateMessage(context.Background(), chID, userID, "msg", nil)
if err != nil {
t.Fatalf("CreateMessage[%d]: %v", i, err)
}
if err := database.SetMessagePinned(context.Background(), id, true); err != nil {
t.Fatalf("SetMessagePinned[%d]: %v", i, err)
}
}
msgs, err := database.GetPinnedMessages(context.Background(), chID, userID)
if err != nil {
t.Fatalf("GetPinnedMessages: %v", err)
}
if len(msgs) > db.MaxPinnedMessages {
t.Errorf("GetPinnedMessages returned %d pins, want <= MaxPinnedMessages (%d)", len(msgs), db.MaxPinnedMessages)
}
}
func TestGetChannelUnreadCounts_ExcludesForeignDMs(t *testing.T) {
database := openMigratedMemory(t)
alice := seedUser(t, database, "dmforeignalice")
bob := seedUser(t, database, "dmforeignbob")
outsider := seedUser(t, database, "dmforeignoutsider")
ch, _, err := database.GetOrCreateDMChannel(context.Background(), alice, bob)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
_, _ = database.CreateMessage(context.Background(), ch.ID, bob, "private", nil)
counts, err := database.GetChannelUnreadCounts(context.Background(), outsider)
if err != nil {
t.Fatalf("GetChannelUnreadCounts: %v", err)
}
if _, ok := counts[ch.ID]; ok {
t.Errorf("DM channel %d leaked into a non-participant's unread counts", ch.ID)
}
}