Files
grendervill 8696d85514 feat(dm): иконка групповой беседы в хранилище и API (Фаза 7)
D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.

Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.

Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
2026-09-26 16:48:01 +03:00

171 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}