409 lines
21 KiB
Go
409 lines
21 KiB
Go
package migrations
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
// This test resets only the explicitly designated disposable migration database.
|
|
// It is opt-in so normal package tests cannot erase a developer's local data.
|
|
func TestMigrationsUpDownUpMySQL(t *testing.T) {
|
|
if os.Getenv("CHORUS_RUN_MIGRATION_TESTS") != "1" {
|
|
t.Skip("set CHORUS_RUN_MIGRATION_TESTS=1 for the disposable MySQL migration database")
|
|
}
|
|
migrationURL := strings.TrimSpace(os.Getenv("CHORUS_MIGRATE_URL"))
|
|
dsn := strings.TrimSpace(os.Getenv("CHORUS_DSN"))
|
|
if migrationURL == "" || dsn == "" {
|
|
t.Fatal("CHORUS_MIGRATE_URL and CHORUS_DSN are required for migration integration tests")
|
|
}
|
|
if _, err := exec.LookPath("migrate"); err != nil {
|
|
t.Fatalf("migrate executable is required: %v", err)
|
|
}
|
|
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
t.Fatalf("connect disposable migration database: %v", err)
|
|
}
|
|
var databaseName string
|
|
if err := db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&databaseName); err != nil {
|
|
t.Fatalf("read disposable migration database name: %v", err)
|
|
}
|
|
expectedDatabase := strings.TrimSpace(os.Getenv("CHORUS_MIGRATION_TEST_DATABASE"))
|
|
if expectedDatabase == "" {
|
|
expectedDatabase = "chorus_test"
|
|
}
|
|
if databaseName != expectedDatabase {
|
|
t.Fatalf("refusing destructive migration test against database %q; expected %s", databaseName, expectedDatabase)
|
|
}
|
|
|
|
runMigrate := func(args ...string) {
|
|
t.Helper()
|
|
commandArgs := append([]string{"-path", ".", "-database", migrationURL}, args...)
|
|
command := exec.CommandContext(ctx, "migrate", commandArgs...)
|
|
output, err := command.CombinedOutput()
|
|
if err != nil && !strings.Contains(string(output), "no change") {
|
|
t.Fatalf("migrate %s: %v\n%s", strings.Join(args, " "), err, output)
|
|
}
|
|
}
|
|
runMigrateExpectFailure := func(args ...string) {
|
|
t.Helper()
|
|
commandArgs := append([]string{"-path", ".", "-database", migrationURL}, args...)
|
|
command := exec.CommandContext(ctx, "migrate", commandArgs...)
|
|
if output, err := command.CombinedOutput(); err == nil {
|
|
t.Fatalf("migrate %s unexpectedly succeeded\n%s", strings.Join(args, " "), output)
|
|
}
|
|
}
|
|
|
|
resetDisposableSchema(t, ctx, db)
|
|
runMigrate("goto", "1")
|
|
seedMVP0MigrationFixture(t, ctx, db)
|
|
|
|
runMigrate("goto", "4")
|
|
assertTableExists(t, ctx, db, "provider_credentials", true)
|
|
assertTableExists(t, ctx, db, "route_pools", true)
|
|
assertTableExists(t, ctx, db, "sys_user", true)
|
|
|
|
var credentialCount int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM provider_credentials WHERE provider_id = 1 AND credential_version = 1 AND key_id = 'migration-test-key'`).Scan(&credentialCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if credentialCount != 1 {
|
|
t.Fatalf("credential backfill count = %d, want 1", credentialCount)
|
|
}
|
|
var activeCredential sql.NullInt64
|
|
var oldEnvelope sql.NullString
|
|
if err := db.QueryRowContext(ctx, `SELECT active_credential_id, api_key_enc FROM providers WHERE id = 1`).Scan(&activeCredential, &oldEnvelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !activeCredential.Valid || oldEnvelope.Valid {
|
|
t.Fatalf("provider credential migration did not activate the new credential safely")
|
|
}
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM provider_model_capabilities WHERE provider_model_id = 1 AND capability = 'text'`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_role WHERE role_key = 'chorus_operator'`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.providers.credential.rotate'`, 1)
|
|
|
|
runMigrate("down", "3")
|
|
assertTableExists(t, ctx, db, "provider_credentials", false)
|
|
assertTableExists(t, ctx, db, "sys_user", false)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM providers WHERE id = 1 AND api_key_enc IS NOT NULL`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM generations WHERE id = 1`, 1)
|
|
|
|
runMigrate("goto", "4")
|
|
assertTableExists(t, ctx, db, "provider_credentials", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM provider_credentials WHERE provider_id = 1 AND credential_version = 1`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_role WHERE role_key = 'chorus_operator'`, 1)
|
|
|
|
// Encrypted envelopes cannot be converted in SQL. Simulate the explicitly
|
|
// controlled pre-migration cleanup, then verify the plaintext migration in
|
|
// both directions without carrying a real credential through the test.
|
|
if _, err := db.ExecContext(ctx, `UPDATE providers SET auth_type = 'none', active_credential_id = NULL WHERE id = 1`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `DELETE FROM provider_credentials WHERE provider_id = 1`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runMigrate("goto", "5")
|
|
assertColumnExists(t, ctx, db, "provider_credentials", "api_key", true)
|
|
assertColumnExists(t, ctx, db, "provider_credentials", "api_key_enc", false)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.providers.credential.get'`, 1)
|
|
runMigrate("goto", "6")
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO api_keys (user_id, name, public_id, key_prefix, secret_hash)
|
|
VALUES (1, 'Rollback guard', 'rollback0000000000000000', 'chorus_rollback0000000000000000_', UNHEX(REPEAT('ab', 32)))`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runMigrateExpectFailure("down", "1")
|
|
if _, err := db.ExecContext(ctx, `DELETE FROM api_keys WHERE public_id = 'rollback0000000000000000'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runMigrate("force", "6")
|
|
runMigrate("down", "1")
|
|
assertTableExists(t, ctx, db, "api_keys", false)
|
|
assertTableExists(t, ctx, db, "api_audit_events", false)
|
|
assertColumnExists(t, ctx, db, "generations", "available_at", false)
|
|
assertColumnExists(t, ctx, db, "provider_credentials", "api_key", true)
|
|
assertColumnExists(t, ctx, db, "provider_credentials", "api_key_enc", false)
|
|
runMigrate("up")
|
|
assertColumnExists(t, ctx, db, "provider_credentials", "api_key", true)
|
|
assertTableExists(t, ctx, db, "api_keys", true)
|
|
assertTableExists(t, ctx, db, "api_audit_events", true)
|
|
assertColumnExists(t, ctx, db, "generations", "available_at", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.api-keys.revoke'`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus/api-keys'`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM generations WHERE id = 1 AND available_at IS NOT NULL`, 1)
|
|
assertColumnExists(t, ctx, db, "users", "username", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
|
|
|
|
assertGroupedAdminNavigation(t, ctx, db)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus'`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path LIKE '/chorus/system/%'`, 5)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.%'`, 20)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.menus.%' AND action <> 'GET'`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.apis.%' AND action <> 'GET'`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.login-logs.%' AND action <> 'GET'`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 LIKE '/api/v1/chorus/generations/%' AND v2 = 'GET'`, 4)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 IN ('/api/v1/chorus/users', '/api/v1/chorus/users/:id/password') AND v2 IN ('POST', 'PUT')`, 2)
|
|
assertTableExists(t, ctx, db, "portal_registration_policy", true)
|
|
assertTableExists(t, ctx, db, "portal_auth_events", true)
|
|
assertColumnNullable(t, ctx, db, "users", "email", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM portal_registration_policy WHERE id = 1 AND enabled = FALSE AND version = 1`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.registration-policy.get', 'chorus.registration-policy.update')`, 2)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 = '/api/v1/chorus/registration-policy' AND v2 IN ('GET', 'PUT')`, 2)
|
|
|
|
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, email, password_hash, display_name, status) VALUES ('no_email_user', NULL, 'synthetic', 'No Email User', 'active')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runMigrateExpectFailure("down", "1")
|
|
assertTableExists(t, ctx, db, "portal_registration_policy", true)
|
|
assertTableExists(t, ctx, db, "portal_auth_events", true)
|
|
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE username = 'no_email_user'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runMigrate("force", "12")
|
|
|
|
runMigrate("down", "1")
|
|
assertTableExists(t, ctx, db, "portal_registration_policy", false)
|
|
assertTableExists(t, ctx, db, "portal_auth_events", false)
|
|
assertColumnNullable(t, ctx, db, "users", "email", false)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.registration-policy.get', 'chorus.registration-policy.update')`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2)
|
|
|
|
runMigrate("down", "1")
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
|
assertGroupedAdminNavigation(t, ctx, db)
|
|
runMigrate("down", "1")
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 0)
|
|
assertGroupedAdminNavigation(t, ctx, db)
|
|
runMigrate("down", "1")
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus'`, 1)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system')`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path LIKE '/chorus/system/%'`, 0)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.%'`, 0)
|
|
assertFlatAdminNavigation(t, ctx, db)
|
|
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, email, password_hash, display_name, status) VALUES ('UpperCase', 'uppercase@chorus.invalid', 'synthetic', 'Invalid Username', 'active')`); err == nil {
|
|
t.Fatal("username check accepted uppercase input")
|
|
}
|
|
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, email, password_hash, display_name, status) VALUES ('email@example.com', 'email-value@chorus.invalid', 'synthetic', 'Invalid Username', 'active')`); err == nil {
|
|
t.Fatal("username check accepted an email-shaped input")
|
|
}
|
|
|
|
runMigrate("down", "1")
|
|
assertColumnExists(t, ctx, db, "users", "username", false)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
|
|
runMigrate("down", "1")
|
|
assertTableExists(t, ctx, db, "api_keys", true)
|
|
assertTableExists(t, ctx, db, "api_audit_events", true)
|
|
assertColumnExists(t, ctx, db, "generations", "available_at", true)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus", "Chorus", 900)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "Providers", 10)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API Keys", 70)
|
|
runMigrate("up", "1")
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80)
|
|
runMigrate("up", "1")
|
|
assertColumnExists(t, ctx, db, "users", "username", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
|
|
|
|
runMigrate("down", "3")
|
|
assertTableExists(t, ctx, db, "api_keys", false)
|
|
assertTableExists(t, ctx, db, "api_audit_events", false)
|
|
assertColumnExists(t, ctx, db, "generations", "available_at", false)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.api-keys.revoke'`, 0)
|
|
runMigrate("up")
|
|
assertTableExists(t, ctx, db, "api_keys", true)
|
|
assertTableExists(t, ctx, db, "api_audit_events", true)
|
|
assertColumnExists(t, ctx, db, "generations", "available_at", true)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
|
|
assertColumnExists(t, ctx, db, "users", "username", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
|
|
assertGroupedAdminNavigation(t, ctx, db)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2)
|
|
assertTableExists(t, ctx, db, "portal_registration_policy", true)
|
|
assertTableExists(t, ctx, db, "portal_auth_events", true)
|
|
assertColumnNullable(t, ctx, db, "users", "email", true)
|
|
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.registration-policy.get', 'chorus.registration-policy.update')`, 2)
|
|
}
|
|
|
|
func assertGroupedAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) {
|
|
t.Helper()
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/configuration", "生成配置", 900)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/monitoring", "运行监控", 910)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/access", "用户与访问", 920)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/system", "系统管理", 930)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/generations", "生成记录", 20)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/users", "终端用户", 10)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 20)
|
|
assertMenuParentPath(t, ctx, db, "/chorus/providers", "/chorus/configuration")
|
|
assertMenuParentPath(t, ctx, db, "/chorus/generations", "/chorus/monitoring")
|
|
assertMenuParentPath(t, ctx, db, "/chorus/users", "/chorus/access")
|
|
assertMenuParentPath(t, ctx, db, "/chorus/system/login-logs", "/chorus/system")
|
|
assertCount(t, ctx, db, `
|
|
SELECT COUNT(*)
|
|
FROM sys_role_menu role_menu
|
|
JOIN sys_role role_record ON role_record.role_id = role_menu.role_id
|
|
JOIN sys_menu menu ON menu.menu_id = role_menu.menu_id
|
|
WHERE role_record.role_key = 'chorus_operator'
|
|
AND menu.path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system')`, 4)
|
|
assertCount(t, ctx, db, `
|
|
SELECT COUNT(*)
|
|
FROM sys_casbin_rule
|
|
WHERE ptype = 'p' AND v0 = 'chorus_operator'
|
|
AND v1 IN ('/api/v1/menu', '/api/v1/menu/:id', '/api/v1/sys-api', '/api/v1/sys-api/:id',
|
|
'/api/v1/sys-login-log', '/api/v1/sys-login-log/:id')
|
|
AND v2 <> 'GET'`, 0)
|
|
}
|
|
|
|
func assertFlatAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) {
|
|
t.Helper()
|
|
assertMenuTitleSort(t, ctx, db, "/chorus", "Chorus 运营", 900)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/models", "模型配置", 20)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/routes", "路由策略", 30)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/templates", "提示词模板", 40)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/health", "上游健康", 50)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/generations", "生成记录", 60)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/users", "用户管理", 70)
|
|
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80)
|
|
assertMenuParentPath(t, ctx, db, "/chorus/api-keys", "/chorus")
|
|
}
|
|
|
|
func resetDisposableSchema(t *testing.T, ctx context.Context, db *sql.DB) {
|
|
t.Helper()
|
|
rows, err := db.QueryContext(ctx, `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()`)
|
|
if err != nil {
|
|
t.Fatalf("list disposable migration tables: %v", err)
|
|
}
|
|
var tables []string
|
|
for rows.Next() {
|
|
var table string
|
|
if err := rows.Scan(&table); err != nil {
|
|
rows.Close()
|
|
t.Fatalf("scan disposable migration table: %v", err)
|
|
}
|
|
tables = append(tables, table)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
t.Fatalf("close disposable migration table rows: %v", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `SET FOREIGN_KEY_CHECKS = 0`); err != nil {
|
|
t.Fatalf("disable disposable migration foreign keys: %v", err)
|
|
}
|
|
defer func() {
|
|
if _, err := db.ExecContext(context.Background(), `SET FOREIGN_KEY_CHECKS = 1`); err != nil {
|
|
t.Errorf("restore disposable migration foreign keys: %v", err)
|
|
}
|
|
}()
|
|
for _, table := range tables {
|
|
quoted := "`" + strings.ReplaceAll(table, "`", "``") + "`"
|
|
if _, err := db.ExecContext(ctx, `DROP TABLE `+quoted); err != nil {
|
|
t.Fatalf("drop disposable migration table %s: %v", table, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func seedMVP0MigrationFixture(t *testing.T, ctx context.Context, db *sql.DB) {
|
|
t.Helper()
|
|
statements := []string{
|
|
`INSERT INTO users (id, email, password_hash, display_name, status) VALUES (1, 'migration-user@chorus.invalid', 'synthetic', 'Migration User', 'active')`,
|
|
`INSERT INTO providers (id, slug, name, base_url, auth_type, api_key_enc, enabled) VALUES (1, 'migration-provider', 'Migration Provider', 'https://provider.invalid', 'bearer', JSON_OBJECT('version', 1, 'key_id', 'migration-test-key', 'nonce', 'AA==', 'ciphertext', 'AA=='), FALSE)`,
|
|
`INSERT INTO provider_models (id, provider_id, name, model_id, api_type, kind, extra_body, timeout_ms, weight, enabled) VALUES (1, 1, 'Migration Model', 'migration-model', 'chat', 'text', JSON_OBJECT(), 30000, 100, TRUE)`,
|
|
`INSERT INTO prompt_templates (id, template_key, kind, api_type, name, version, template_text, enabled) VALUES (1, 'migration-template', 'text', 'chat', 'Migration Template', 1, '{{.UserPrompt}}', TRUE)`,
|
|
`INSERT INTO generations (id, user_id, provider_model_id, kind, status, idempotency_key, user_prompt, rendered_prompt, attempts, attempt_count) VALUES (1, 1, 1, 'text', 'pending', 'migration-key', 'migration input', 'migration input', JSON_ARRAY(), 0)`,
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
|
t.Fatalf("seed MVP-0 fixture: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertTableExists(t *testing.T, ctx context.Context, db *sql.DB, table string, want bool) {
|
|
t.Helper()
|
|
var count int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`, table).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if (count == 1) != want {
|
|
t.Fatalf("table %s exists = %t, want %t", table, count == 1, want)
|
|
}
|
|
}
|
|
|
|
func assertCount(t *testing.T, ctx context.Context, db *sql.DB, query string, want int) {
|
|
t.Helper()
|
|
var got int
|
|
if err := db.QueryRowContext(ctx, query).Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatal(fmt.Sprintf("query count = %d, want %d", got, want))
|
|
}
|
|
}
|
|
|
|
func assertMenuTitleSort(t *testing.T, ctx context.Context, db *sql.DB, path, wantTitle string, wantSort int) {
|
|
t.Helper()
|
|
var title string
|
|
var sort int
|
|
if err := db.QueryRowContext(ctx, `SELECT title, sort FROM sys_menu WHERE path = ?`, path).Scan(&title, &sort); err != nil {
|
|
t.Fatalf("load menu %s: %v", path, err)
|
|
}
|
|
if title != wantTitle || sort != wantSort {
|
|
t.Fatalf("menu %s = title %q sort %d, want title %q sort %d", path, title, sort, wantTitle, wantSort)
|
|
}
|
|
}
|
|
|
|
func assertMenuParentPath(t *testing.T, ctx context.Context, db *sql.DB, childPath, wantParentPath string) {
|
|
t.Helper()
|
|
var parentPath string
|
|
if err := db.QueryRowContext(ctx, `
|
|
SELECT parent.path
|
|
FROM sys_menu child
|
|
JOIN sys_menu parent ON parent.menu_id = child.parent_id
|
|
WHERE child.path = ?`, childPath).Scan(&parentPath); err != nil {
|
|
t.Fatalf("load parent for menu %s: %v", childPath, err)
|
|
}
|
|
if parentPath != wantParentPath {
|
|
t.Fatalf("menu %s parent = %q, want %q", childPath, parentPath, wantParentPath)
|
|
}
|
|
}
|
|
|
|
func assertColumnExists(t *testing.T, ctx context.Context, db *sql.DB, table, column string, want bool) {
|
|
t.Helper()
|
|
var count int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, table, column).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if (count == 1) != want {
|
|
t.Fatalf("column %s.%s exists = %t, want %t", table, column, count == 1, want)
|
|
}
|
|
}
|
|
|
|
func assertColumnNullable(t *testing.T, ctx context.Context, db *sql.DB, table, column string, want bool) {
|
|
t.Helper()
|
|
var nullable string
|
|
if err := db.QueryRowContext(ctx, `SELECT is_nullable FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, table, column).Scan(&nullable); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := nullable == "YES"; got != want {
|
|
t.Fatalf("column %s.%s nullable = %t, want %t", table, column, got, want)
|
|
}
|
|
}
|