package db_test import ( "context" "testing" "github.com/owncord/server/db" ) // 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") } } // 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) } } 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)) } }