2026-08-01 22:06:14 +02:00
|
|
|
package db_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"testing"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
2026-08-01 22:06:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// groupDMFixture returns a migrated database with three users (ids 1..3).
|
|
|
|
|
func groupDMFixture(t *testing.T) *db.DB {
|
|
|
|
|
t.Helper()
|
|
|
|
|
database := openMigratedMemory(t)
|
|
|
|
|
for i, name := range []string{"ga", "gb", "gc"} {
|
|
|
|
|
if got := seedUser(t, database, name); got != int64(i+1) {
|
|
|
|
|
t.Fatalf("seedUser %s: expected id %d, got %d", name, i+1, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return database
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCreateGroupDMChannel_OpensForEveryone(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
ch, err := database.CreateGroupDMChannel(ctx, "Crew", []int64{1, 2, 3})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateGroupDMChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if ch.Name != "Crew" {
|
|
|
|
|
t.Errorf("expected name Crew, got %q", ch.Name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isGroup, err := database.IsGroupDM(ctx, ch.ID)
|
|
|
|
|
if err != nil || !isGroup {
|
|
|
|
|
t.Errorf("expected is_group=true, got %v (err=%v)", isGroup, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, uid := range []int64{1, 2, 3} {
|
|
|
|
|
dms, listErr := database.GetUserDMChannels(ctx, uid)
|
|
|
|
|
if listErr != nil {
|
|
|
|
|
t.Fatalf("GetUserDMChannels(%d): %v", uid, listErr)
|
|
|
|
|
}
|
|
|
|
|
if len(dms) != 1 {
|
|
|
|
|
t.Fatalf("user %d: expected 1 open DM, got %d", uid, len(dms))
|
|
|
|
|
}
|
|
|
|
|
if !dms[0].IsGroup || dms[0].Name != "Crew" {
|
|
|
|
|
t.Errorf("user %d: expected the group, got %+v", uid, dms[0])
|
|
|
|
|
}
|
|
|
|
|
if len(dms[0].Recipients) != 2 {
|
|
|
|
|
t.Errorf("user %d: expected 2 others, got %d", uid, len(dms[0].Recipients))
|
|
|
|
|
}
|
|
|
|
|
for _, r := range dms[0].Recipients {
|
|
|
|
|
if r.ID == uid {
|
|
|
|
|
t.Errorf("user %d appears in their own recipients list", uid)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCreateGroupDMChannel_RefusesUnderThree(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
if _, err := database.CreateGroupDMChannel(context.Background(), "", []int64{1, 2}); err == nil {
|
|
|
|
|
t.Fatal("expected an error creating a 2-person group DM")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The 1:1 lookup must never return a group DM, even one whose membership has
|
|
|
|
|
// shrunk to exactly the two users being looked up.
|
|
|
|
|
func TestGetOrCreateDMChannel_IgnoresShrunkGroup(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
group, err := database.CreateGroupDMChannel(ctx, "Shrinking", []int64{1, 2, 3})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateGroupDMChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err = database.LeaveGroupDM(ctx, 3, group.ID); err != nil {
|
|
|
|
|
t.Fatalf("LeaveGroupDM: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ch, created, err := database.GetOrCreateDMChannel(ctx, 1, 2)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetOrCreateDMChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !created {
|
|
|
|
|
t.Error("expected a brand-new 1:1 DM, not a reused channel")
|
|
|
|
|
}
|
|
|
|
|
if ch.ID == group.ID {
|
|
|
|
|
t.Fatal("the 1:1 lookup returned the shrunk group DM")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLeaveGroupDM_DeletesChannelOnLastLeave(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
group, err := database.CreateGroupDMChannel(ctx, "Ephemeral", []int64{1, 2, 3})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateGroupDMChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i, uid := range []int64{1, 2, 3} {
|
|
|
|
|
deleted, leaveErr := database.LeaveGroupDM(ctx, uid, group.ID)
|
|
|
|
|
if leaveErr != nil {
|
|
|
|
|
t.Fatalf("LeaveGroupDM(%d): %v", uid, leaveErr)
|
|
|
|
|
}
|
|
|
|
|
wantDeleted := i == 2
|
|
|
|
|
if deleted != wantDeleted {
|
|
|
|
|
t.Errorf("leave %d: deleted=%v, want %v", uid, deleted, wantDeleted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ch, err := database.GetChannel(ctx, group.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if ch != nil {
|
|
|
|
|
t.Error("channel survived the last participant leaving")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
// TestLeaveGroupDM_LastLeavePreservesAttachmentsForReclaim locks the
|
|
|
|
|
// attachment-unlink fix: messages.channel_id and attachments.message_id both
|
|
|
|
|
// cascade ON DELETE (migrations/001), so deleting the channel row on the last
|
|
|
|
|
// leave would otherwise destroy the attachment rows too — the only handle the
|
|
|
|
|
// orphan sweep (DeleteOrphanedAttachments) has on the uploaded files,
|
|
|
|
|
// stranding them on disk with no query left able to name them.
|
|
|
|
|
func TestLeaveGroupDM_LastLeavePreservesAttachmentsForReclaim(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
group, err := database.CreateGroupDMChannel(ctx, "Ephemeral", []int64{1, 2, 3})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateGroupDMChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgID, err := database.CreateMessage(ctx, group.ID, 1, "look at this", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateMessage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := database.ExecContext(ctx,
|
|
|
|
|
`INSERT INTO attachments (id, filename, stored_as, mime_type, size, uploader_id)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
"att-leave-1", "photo.png", "stored-photo.png", "image/png", 2048, 1,
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("seed attachment: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if n, err := database.LinkAttachmentsToMessage(ctx, msgID, 1, []string{"att-leave-1"}); err != nil || n != 1 {
|
|
|
|
|
t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, uid := range []int64{1, 2, 3} {
|
|
|
|
|
if _, err := database.LeaveGroupDM(ctx, uid, group.ID); err != nil {
|
|
|
|
|
t.Fatalf("LeaveGroupDM(%d): %v", uid, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ch, err := database.GetChannel(ctx, group.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if ch != nil {
|
|
|
|
|
t.Error("channel survived the last participant leaving")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
att, err := database.GetAttachmentByID(ctx, "att-leave-1")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetAttachmentByID: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if att == nil {
|
|
|
|
|
t.Fatal("attachment row was destroyed by the channel-delete cascade; want it unlinked and preserved for the orphan sweep")
|
|
|
|
|
}
|
|
|
|
|
if att.MessageID != nil {
|
|
|
|
|
t.Errorf("attachment MessageID = %v, want nil (unlinked ahead of the cascade)", *att.MessageID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:06:14 +02:00
|
|
|
func TestSetDMChannelName_RefusesNonDM(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
textID, err := database.CreateChannel(ctx, "general", "text", "", "", 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := database.SetDMChannelName(ctx, textID, "hijacked"); err != nil {
|
|
|
|
|
t.Fatalf("SetDMChannelName: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ch, err := database.GetChannel(ctx, textID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if ch.Name != "general" {
|
|
|
|
|
t.Errorf("a text channel was renamed through the DM route: %q", ch.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An invisible participant must read as offline to everyone but themselves,
|
|
|
|
|
// on the DM list exactly as everywhere else.
|
|
|
|
|
func TestGetDMParticipants_CollapsesInvisible(t *testing.T) {
|
|
|
|
|
database := groupDMFixture(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
group, err := database.CreateGroupDMChannel(ctx, "Ghosts", []int64{1, 2, 3})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateGroupDMChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := database.UpdateUserStatus(ctx, 2, db.StatusInvisible); err != nil {
|
|
|
|
|
t.Fatalf("UpdateUserStatus: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
others, err := database.GetDMParticipants(ctx, group.ID, 1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetDMParticipants: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, p := range others {
|
|
|
|
|
if p.ID == 2 && p.Status != db.StatusOffline {
|
|
|
|
|
t.Errorf("invisible user leaked as %q to another participant", p.Status)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self, err := database.GetDMParticipants(ctx, group.ID, 2)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetDMParticipants: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, p := range self {
|
|
|
|
|
if p.ID == 2 && p.Status != db.StatusInvisible {
|
|
|
|
|
t.Errorf("the owner of an invisible status sees %q, want invisible", p.Status)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewDMChannelInfo_ExcludesViewerAndPicksRecipient(t *testing.T) {
|
|
|
|
|
participants := []db.DMUser{
|
|
|
|
|
{ID: 1, Username: "a"},
|
|
|
|
|
{ID: 2, Username: "b"},
|
|
|
|
|
{ID: 3, Username: "c"},
|
|
|
|
|
}
|
|
|
|
|
info := db.NewDMChannelInfo(7, "Trio", true, participants, 2)
|
|
|
|
|
|
|
|
|
|
if info.ChannelID != 7 || info.Name != "Trio" || !info.IsGroup {
|
|
|
|
|
t.Errorf("unexpected header fields: %+v", info)
|
|
|
|
|
}
|
|
|
|
|
if len(info.Recipients) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 recipients, got %d", len(info.Recipients))
|
|
|
|
|
}
|
|
|
|
|
for _, r := range info.Recipients {
|
|
|
|
|
if r.ID == 2 {
|
|
|
|
|
t.Error("the viewer is in their own recipients list")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Backward compat: a pre-group client reads `recipient` alone.
|
|
|
|
|
if info.Recipient.ID != 1 {
|
|
|
|
|
t.Errorf("expected the first other participant as the compat recipient, got %d", info.Recipient.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A DM with no other participants (the moment before the row is cleaned up)
|
|
|
|
|
// must produce an empty list, not a nil one — clients iterate it directly.
|
|
|
|
|
func TestNewDMChannelInfo_EmptyRecipientsIsNotNil(t *testing.T) {
|
|
|
|
|
info := db.NewDMChannelInfo(9, "", false, []db.DMUser{{ID: 5}}, 5)
|
|
|
|
|
if info.Recipients == nil {
|
|
|
|
|
t.Fatal("recipients must be an empty slice, not nil")
|
|
|
|
|
}
|
|
|
|
|
if len(info.Recipients) != 0 {
|
|
|
|
|
t.Errorf("expected no recipients, got %d", len(info.Recipients))
|
|
|
|
|
}
|
|
|
|
|
}
|