package db_test import ( "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(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(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(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(chID, userID, "parent", nil) replyID, err := database.CreateMessage(chID, userID, "reply", &parentID) if err != nil { t.Fatalf("CreateMessage with reply: %v", err) } msg, _ := database.GetMessage(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(chID, userID, "test content", nil) msg, _ := database.GetMessage(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(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(chID, userID, "hello world", nil) msg, err := database.GetMessage(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(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(chID, userID, "msg", nil) if err != nil { t.Fatalf("CreateMessage %d: %v", i, err) } } msgs, err := database.GetMessages(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(chID, userID, "msg", nil) } msgs, _ := database.GetMessages(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(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(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(chID, userID, "hi", nil) msgs, _ := database.GetMessages(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(chID, userID, "original", nil) if err := database.EditMessage(id, userID, "updated"); err != nil { t.Fatalf("EditMessage: %v", err) } msg, _ := database.GetMessage(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(chID, ownerID, "original", nil) err := database.EditMessage(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(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(chID, userID, "bye", nil) if err := database.DeleteMessage(id, userID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } msg, _ := database.GetMessage(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(chID, userID, "sensitive", nil) _ = database.DeleteMessage(id, userID, false) msg, _ := database.GetMessage(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(chID, ownerID, "msg", nil) err := database.DeleteMessage(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(chID, ownerID, "msg", nil) if err := database.DeleteMessage(id, modID, true); err != nil { t.Fatalf("DeleteMessage by mod: %v", err) } msg, _ := database.GetMessage(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(9999, userID, true) if err == nil { t.Error("DeleteMessage non-existent should return error") } } // ─── Reactions ──────────────────────────────────────────────────────────────── func TestAddReaction_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "sam") chID := seedChannel(t, database, "ch") msgID, _ := database.CreateMessage(chID, userID, "hi", nil) if err := database.AddReaction(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(chID, userID, "hi", nil) _ = database.AddReaction(msgID, userID, "❤️") err := database.AddReaction(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(chID, userID, "hi", nil) _ = database.AddReaction(msgID, userID, "😂") if err := database.RemoveReaction(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(chID, userID, "hi", nil) err := database.RemoveReaction(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(chID, userID, "hi", nil) counts, err := database.GetReactions(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(chID, u1, "hi", nil) _ = database.AddReaction(msgID, u1, "👍") _ = database.AddReaction(msgID, u2, "👍") _ = database.AddReaction(msgID, u1, "❤️") counts, _ := database.GetReactions(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(chID, userID, "hello world fts test", nil) _, _ = database.CreateMessage(chID, userID, "unrelated content here", nil) results, err := database.SearchMessages("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(ch1, userID, "needle in channel 1", nil) _, _ = database.CreateMessage(ch2, userID, "needle in channel 2", nil) results, _ := database.SearchMessages("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(chID, userID, "hello there", nil) results, _ := database.SearchMessages("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(chID, userID, "searchable keyword content", nil) } results, _ := database.SearchMessages("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(chID, userID, "vanishing keyword message", nil) _ = database.DeleteMessage(id, userID, false) results, _ := database.SearchMessages("vanishing", nil, 10) if len(results) != 0 { t.Errorf("expected 0 results (deleted excluded), 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(chID, userID, "msg", nil) if err := database.UpdateReadState(userID, chID, msgID); err != nil { t.Fatalf("UpdateReadState: %v", err) } // Update again with higher message ID — should not error. msgID2, _ := database.CreateMessage(chID, userID, "msg2", nil) if err := database.UpdateReadState(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(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(chID, userID, "hello api", nil) msgs, err := database.GetMessagesForAPI(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(chID, userID, "msg", nil) ids = append(ids, id) } msgs, err := database.GetMessagesForAPI(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(chID, u1, "react me", nil) _ = database.AddReaction(msgID, u1, "👍") _ = database.AddReaction(msgID, u2, "👍") msgs, err := database.GetMessagesForAPI(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(chID, userID, "deleted msg", nil) _ = database.DeleteMessage(id, userID, false) _, _ = database.CreateMessage(chID, userID, "visible msg", nil) msgs, err := database.GetMessagesForAPI(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)) } } // ─── GetChannelUnreadCounts ───────────────────────────────────────────────── func TestGetChannelUnreadCounts_NoMessages(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "unreaduser") _ = seedChannel(t, database, "unreadchan") counts, err := database.GetChannelUnreadCounts(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(chID, userID, "msg1", nil) _, _ = database.CreateMessage(chID, userID, "msg2", nil) _, _ = database.CreateMessage(chID, userID, "msg3", nil) _ = database.UpdateReadState(userID, chID, msg1) counts, err := database.GetChannelUnreadCounts(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) } } // ─── GetLatestMessageID ───────────────────────────────────────────────────── func TestGetLatestMessageID_Empty(t *testing.T) { database := openMigratedMemory(t) chID := seedChannel(t, database, "latestchan") id, err := database.GetLatestMessageID(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(chID, userID, "first", nil) _, _ = database.CreateMessage(chID, userID, "second", nil) lastID, _ := database.CreateMessage(chID, userID, "third", nil) id, err := database.GetLatestMessageID(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(chID, userID, "keep", nil) id2, _ := database.CreateMessage(chID, userID, "delete me", nil) _ = database.DeleteMessage(id2, userID, false) latestID, err := database.GetLatestMessageID(chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } if latestID != id1 { t.Errorf("GetLatestMessageID = %d, want %d (deleted excluded)", latestID, id1) } }