package store import ( "context" "path/filepath" "testing" "glchat/internal/database" ) // newDMIconStore открывает временную БД и создаёт трёх пользователей: владельца // группы и двух участников (AGENT.md 7.8). func newDMIconStore(t *testing.T) (*Store, []uint64) { t.Helper() ctx := context.Background() db, err := database.Open(ctx, database.Options{ Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, }) if err != nil { t.Fatalf("открыть тестовую БД: %v", err) } t.Cleanup(func() { if err := db.Close(); err != nil { t.Errorf("закрыть тестовую БД: %v", err) } }) st := New(db) users := make([]uint64, 0, 3) for index, name := range []string{"icon_owner", "icon_bob", "icon_carol"} { user, err := st.CreateUser(ctx, CreateUserParams{ Username: name, EmailEnc: "enc", EmailIndex: name + "-idx", PasswordHash: "hash", }) if err != nil { t.Fatalf("создать пользователя %d: %v", index, err) } users = append(users, user.ID) } return st, users } // dmIconFile регистрирует файл иконки так же, как это делает сервер: обычная // загрузка с назначением dm_icon, привязанная к беседе. func dmIconFile(ctx context.Context, t *testing.T, st *Store, uploaderID, channelID uint64, name string) *File { t.Helper() file, err := st.CreateFile(ctx, CreateFileParams{ UploaderID: uploaderID, ChannelID: &channelID, Filename: name, ContentType: "image/png", SizeBytes: 128, StoragePath: filepath.Join(t.TempDir(), name), SHA256: name, Purpose: "dm_icon", }) if err != nil { t.Fatalf("создать файл иконки: %v", err) } return file } // groupIconSummary ищет беседу в списке пользователя. func groupIconSummary(ctx context.Context, t *testing.T, st *Store, userID, channelID uint64) DMChannelSummary { t.Helper() channels, err := st.ListDMChannels(ctx, userID) if err != nil { t.Fatalf("ListDMChannels: %v", err) } for _, summary := range channels { if summary.Channel.ID == channelID { return summary } } t.Fatalf("беседа %d не найдена в списке пользователя %d", channelID, userID) return DMChannelSummary{} } // TestDMChannelIconLifecycle проверяет иконку групповой беседы (D-070): // установку и снятие через UpdateChannel, видимость в списке бесед у всех // участников и ON DELETE SET NULL при удалении файла. func TestDMChannelIconLifecycle(t *testing.T) { ctx := context.Background() st, users := newDMIconStore(t) channel, err := st.CreateGroupDMChannel(ctx, users[0], "Игровая", users[1:]) if err != nil { t.Fatalf("CreateGroupDMChannel: %v", err) } file := dmIconFile(ctx, t, st, users[0], channel.ID, "icon.png") updated, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}) if err != nil { t.Fatalf("UpdateChannel(IconFileID): %v", err) } if updated.IconFileID == nil || *updated.IconFileID != file.ID { t.Fatalf("icon_file_id = %v, ожидался %d", updated.IconFileID, file.ID) } // Иконка видна каждому участнику: список бесед читает ту же колонку. for _, userID := range users { summary := groupIconSummary(ctx, t, st, userID, channel.ID) if !summary.IsGroup { t.Fatalf("беседа не групповая для пользователя %d: %+v", userID, summary) } if summary.Channel.IconFileID == nil || *summary.Channel.IconFileID != file.ID { t.Fatalf("иконка в списке пользователя %d = %v", userID, summary.Channel.IconFileID) } // У группы нет собеседника: аватар 1:1 остаётся пустым. if summary.AvatarFileID != nil { t.Fatalf("у группы появился аватар собеседника: %v", *summary.AvatarFileID) } } // Снятие иконки очищает колонку. cleared, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{ClearIcon: true}) if err != nil { t.Fatalf("UpdateChannel(ClearIcon): %v", err) } if cleared.IconFileID != nil { t.Fatalf("после снятия icon_file_id = %v, ожидался NULL", *cleared.IconFileID) } // Удаление файла обнуляет ссылку (ON DELETE SET NULL), а не ломает беседу. if _, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}); err != nil { t.Fatalf("повторная установка иконки: %v", err) } if err := st.DeleteFile(ctx, file.ID); err != nil { t.Fatalf("DeleteFile: %v", err) } after, err := st.GetChannel(ctx, channel.ID) if err != nil { t.Fatalf("GetChannel после удаления файла: %v", err) } if after.IconFileID != nil { t.Fatalf("после удаления файла icon_file_id = %v, ожидался NULL", *after.IconFileID) } if after.Name != "Игровая" || after.DMOwnerID == nil { t.Fatalf("беседа пострадала при удалении файла: %+v", after) } } // TestDMChannelIconResetOnDemotion: когда в беседе остаётся два участника, она // снова обычная личная — иконка группы сбрасывается вместе с именем и // владельцем (AGENT.md 7.8, D-070). func TestDMChannelIconResetOnDemotion(t *testing.T) { ctx := context.Background() st, users := newDMIconStore(t) channel, err := st.CreateGroupDMChannel(ctx, users[0], "Игровая", users[1:]) if err != nil { t.Fatalf("CreateGroupDMChannel: %v", err) } file := dmIconFile(ctx, t, st, users[0], channel.ID, "icon.png") if _, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}); err != nil { t.Fatalf("UpdateChannel(IconFileID): %v", err) } if err := st.RemoveDMParticipant(ctx, channel.ID, users[2]); err != nil { t.Fatalf("RemoveDMParticipant: %v", err) } demoted, err := st.GetChannel(ctx, channel.ID) if err != nil { t.Fatalf("GetChannel: %v", err) } if demoted.IconFileID != nil || demoted.DMOwnerID != nil || demoted.Name != "" { t.Fatalf("беседа с двумя участниками не сброшена: %+v", demoted) } }