feat(templates): личные шаблоны серверов в хранилище и API (Фаза 7)
AGENT.md 7.3: свои шаблоны серверов — сохранить структуру сервера
(роли с правами и цветом, категории и комнаты) и создать по ней новый.
Миграция 00023 создаёт guild_templates (structure_json, source_guild_id,
каскад по владельцу) с индексом владельца.
- store: CRUD личных шаблонов с фильтром по owner_id и защитой лимитов
(20 шаблонов, 16 КиБ structure_json);
- server: POST /guild-templates, GET /guild-templates/mine и
DELETE /guild-templates/{id} (MANAGE_GUILD, чужой шаблон — 404),
создание сервера по template_id=personal:<id> (аудит template=personal:<id>);
- структура шаблона повторяет API ролей (права строкой) и хранит
slowmode_seconds, user_limit и категорию по имени.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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 не создана")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- +goose Up
|
||||
-- Фаза 7: личные шаблоны серверов (AGENT.md 7.3).
|
||||
--
|
||||
-- Шаблон — приватная сущность пользователя: он виден и используется только
|
||||
-- владельцем (owner_id), поэтому у таблицы нет guild_id и связанных с ним
|
||||
-- прав. structure_json хранит структуру в том же виде, что внутренний
|
||||
-- guildTemplate: роли с цветом и правами, категории и комнаты. source_guild_id
|
||||
-- — сервер, с которого структура снята; при удалении сервера ссылка
|
||||
-- обнуляется, а сам шаблон остаётся рабочим. Вместе с пользователем его
|
||||
-- шаблоны удаляются каскадом.
|
||||
CREATE TABLE guild_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
structure_json TEXT NOT NULL,
|
||||
source_guild_id INTEGER REFERENCES guilds (id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX guild_templates_owner_idx ON guild_templates (owner_id, id DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE guild_templates;
|
||||
Reference in New Issue
Block a user