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 нет") } }