package database import ( "context" "path/filepath" "testing" "github.com/pressly/goose/v3" ) // TestGuildTemplatesMigration проверяет миграцию 00023 на чистой базе: // таблица личных шаблонов, индекс владельца, внешние ключи (каскад по // пользователю, обнуление сервера-источника) и откат миграции (AGENT.md 7.3). func TestGuildTemplatesMigration(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 < 23 { t.Fatalf("schema_version = %d, ожидалось >= 23", version) } columns := map[string]bool{} rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('guild_templates')`) if err != nil { t.Fatalf("pragma_table_info: %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) } for _, expected := range []string{ "id", "owner_id", "name", "description", "structure_json", "source_guild_id", "created_at", } { if !columns[expected] { t.Errorf("в guild_templates нет колонки %q", expected) } } var indexes int if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'guild_templates_owner_idx'`, ).Scan(&indexes); err != nil { t.Fatalf("чтение индекса: %v", err) } if indexes != 1 { t.Error("индекс guild_templates_owner_idx не создан") } ownerID := int64(2000) otherID := int64(2001) insertUser := `INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index, password_hash, locale, created_at, updated_at) VALUES (?, ?, ?, ?, 'enc', ?, 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')` if _, err := db.Writer.ExecContext(ctx, insertUser, ownerID, "tpl", "tpl", "Tpl", "idx-tpl"); err != nil { t.Fatalf("вставка владельца: %v", err) } if _, err := db.Writer.ExecContext(ctx, insertUser, otherID, "tpl2", "tpl2", "Tpl2", "idx-tpl2"); err != nil { t.Fatalf("вставка второго пользователя: %v", err) } guildID := int64(3000) if _, err := db.Writer.ExecContext(ctx, `INSERT INTO guilds (id, name, owner_id, created_at) VALUES (?, 'Источник', ?, '2026-01-01T00:00:00.000Z')`, guildID, ownerID); err != nil { t.Fatalf("вставка сервера: %v", err) } insertTemplate := `INSERT INTO guild_templates (id, owner_id, name, structure_json, source_guild_id, created_at) VALUES (?, ?, 'Шаблон', '{"roles":[],"channels":[]}', ?, '2026-01-01T00:00:00.000Z')` if _, err := db.Writer.ExecContext(ctx, insertTemplate, 1, ownerID, guildID); err != nil { t.Fatalf("вставка шаблона: %v", err) } // Владелец обязателен: внешний ключ на users. if _, err := db.Writer.ExecContext(ctx, insertTemplate, 2, 999999, nil); err == nil { t.Fatal("шаблон без пользователя принят, ожидалась ошибка внешнего ключа") } // Удаление сервера-источника не удаляет шаблон, а лишь обнуляет ссылку. if _, err := db.Writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, guildID); err != nil { t.Fatalf("удаление сервера: %v", err) } var source *int64 if err := db.Reader.QueryRowContext(ctx, `SELECT source_guild_id FROM guild_templates WHERE id = 1`).Scan(&source); err != nil { t.Fatalf("чтение шаблона: %v", err) } if source != nil { t.Errorf("source_guild_id = %d, ожидалось NULL", *source) } // Удаление пользователя убирает его шаблоны. if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, ownerID); err != nil { t.Fatalf("удаление владельца: %v", err) } var remaining int if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_templates`).Scan(&remaining); err != nil { t.Fatalf("подсчёт шаблонов: %v", err) } if remaining != 0 { t.Errorf("после удаления владельца осталось шаблонов: %d", remaining) } // Откат: goose должен вернуть версию 22 и убрать таблицу. 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", 22); err != nil { t.Fatalf("откат миграции: %v", err) } var tables int if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`, ).Scan(&tables); err != nil { t.Fatalf("чтение sqlite_master: %v", err) } if tables != 0 { t.Error("после отката таблица guild_templates осталась") } if version, err := db.SchemaVersion(ctx); err != nil { t.Fatalf("SchemaVersion после отката: %v", err) } else if version != 22 { t.Errorf("после отката schema_version = %d, ожидалось 22", version) } // Повторное применение возвращает таблицу: миграция идемпотентна по goose. if err := db.Migrate(ctx); err != nil { t.Fatalf("повторная миграция: %v", err) } if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`, ).Scan(&tables); err != nil { t.Fatalf("чтение sqlite_master: %v", err) } if tables != 1 { t.Error("после повторного применения таблица guild_templates не создана") } }