Files
glchat/internal/database/dm_icon_migration_test.go
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

127 lines
4.5 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 database
import (
"context"
"path/filepath"
"testing"
"github.com/pressly/goose/v3"
)
// channelColumns читает имена колонок таблицы channels.
func channelColumns(ctx context.Context, t *testing.T, db *DB) map[string]bool {
t.Helper()
columns := map[string]bool{}
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('channels')`)
if err != nil {
t.Fatalf("pragma_table_info('channels'): %v", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
return columns
}
// TestDMIconMigration проверяет миграцию 00024 на чистой базе (D-070, D-069):
// колонку icon_file_id у channels, внешний ключ на files с ON DELETE SET NULL,
// откат и повторный накат.
func TestDMIconMigration(t *testing.T) {
ctx := context.Background()
db, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("Open() вернул ошибку: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
t.Errorf("Close(): %v", err)
}
}()
version, err := db.SchemaVersion(ctx)
if err != nil {
t.Fatalf("SchemaVersion(): %v", err)
}
if version < 24 {
t.Fatalf("schema_version = %d, ожидалось >= 24", version)
}
if !channelColumns(ctx, t, db)["icon_file_id"] {
t.Fatal("в channels нет колонки icon_file_id")
}
// Внешний ключ: беседа ссылается на файл, удаление файла обнуляет ссылку.
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
password_hash, locale, created_at, updated_at)
VALUES (42, 'dm_owner', 'dm_owner', 'Владелец', 'enc', 'idx', 'hash', 'ru',
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`); err != nil {
t.Fatalf("вставка пользователя: %v", err)
}
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO files (id, filename, content_type, size_bytes, storage_path)
VALUES (700, 'icon.png', 'image/png', 10, '/tmp/icon.png')`); err != nil {
t.Fatalf("вставка файла: %v", err)
}
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO channels (id, guild_id, type, name, dm_owner_id, icon_file_id)
VALUES (701, NULL, 'dm', 'Группа', 42, 700)`); err != nil {
t.Fatalf("вставка канала: %v", err)
}
var iconID *int64
if err := db.Reader.QueryRowContext(ctx,
`SELECT icon_file_id FROM channels WHERE id = 701`).Scan(&iconID); err != nil {
t.Fatalf("чтение иконки: %v", err)
}
if iconID == nil || *iconID != 700 {
t.Fatalf("icon_file_id = %v, ожидалось 700", iconID)
}
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM files WHERE id = 700`); err != nil {
t.Fatalf("удаление файла: %v", err)
}
iconID = nil
if err := db.Reader.QueryRowContext(ctx,
`SELECT icon_file_id FROM channels WHERE id = 701`).Scan(&iconID); err != nil {
t.Fatalf("чтение иконки после удаления файла: %v", err)
}
if iconID != nil {
t.Fatalf("после удаления файла icon_file_id = %v, ожидалось NULL", *iconID)
}
// Откат: goose знает версию 24, Down убирает колонку (D-069).
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatalf("SetDialect: %v", err)
}
if err := goose.DownToContext(ctx, db.Writer, "migrations", 23); err != nil {
t.Fatalf("откат миграции 00024: %v", err)
}
if channelColumns(ctx, t, db)["icon_file_id"] {
t.Fatal("после отката колонка icon_file_id осталась")
}
if version, err = db.SchemaVersion(ctx); err != nil {
t.Fatalf("SchemaVersion() после отката: %v", err)
} else if version >= 24 {
t.Fatalf("schema_version после отката = %d, ожидалось < 24", version)
}
// Повторный накат: миграция применима к уже откатанной базе.
if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil {
t.Fatalf("повторный накат: %v", err)
}
if !channelColumns(ctx, t, db)["icon_file_id"] {
t.Fatal("после повторного наката колонки icon_file_id нет")
}
}