package database import ( "fmt" "net/url" "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) const captainSkillMigrationBaseline = ` CREATE TABLE accounts (id INTEGER PRIMARY KEY); CREATE TABLE captain_assistants (id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL); CREATE TABLE captain_skills (id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL); CREATE TABLE captain_assistant_skills ( id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, assistant_id INTEGER NOT NULL REFERENCES captain_assistants(id) ON DELETE CASCADE, skill_id INTEGER NOT NULL REFERENCES captain_skills(id) ON DELETE CASCADE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (assistant_id, skill_id) ); CREATE INDEX idx_captain_assistant_skills_account ON captain_assistant_skills(account_id); CREATE INDEX idx_captain_assistant_skills_skill ON captain_assistant_skills(skill_id); ` func captainSkillMigrationDir(t *testing.T, dialect string) string { t.Helper() dir := t.TempDir() for _, direction := range []string{"up", "down"} { require.NoError(t, os.WriteFile(filepath.Join(dir, "000080_baseline."+direction+".sql"), []byte("SELECT 1;"), 0o600)) } for _, direction := range []string{"up", "down"} { name := "000081_enforce_captain_skill_tenant_consistency." + direction + ".sql" parts := []string{"..", "..", "migrations"} if dialect != "" { parts = append(parts, dialect) } data, err := os.ReadFile(filepath.Join(append(parts, name)...)) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600)) } return dir } func TestCaptainSkillSQLiteMigrationRoundTrip(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "captain-skill.db") dbURL := "sqlite3://" + dbPath + "?_foreign_keys=on" db, err := gorm.Open(sqlite.Open(dbPath+"?_foreign_keys=on"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { _ = sqlDB.Close() }) require.NoError(t, db.Exec(captainSkillMigrationBaseline).Error) require.NoError(t, db.Exec("INSERT INTO accounts VALUES (1), (2); INSERT INTO captain_assistants VALUES (1, 1), (2, 2); INSERT INTO captain_skills VALUES (1, 1), (2, 2)").Error) migrations := captainSkillMigrationDir(t, "sqlite") require.NoError(t, ForceVersion(dbURL, migrations, 80)) require.NoError(t, MigrateSteps(dbURL, migrations, 1)) version, dirty, err := CurrentVersion(dbURL, migrations) require.NoError(t, err) assert.Equal(t, uint(81), version) assert.False(t, dirty) require.NoError(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (1, 1, 1, 1)").Error) assert.Error(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (2, 1, 2, 1)").Error) assert.Error(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (3, 1, 1, 2)").Error) require.NoError(t, MigrateSteps(dbURL, migrations, -1)) version, dirty, err = CurrentVersion(dbURL, migrations) require.NoError(t, err) assert.Equal(t, uint(80), version) assert.False(t, dirty) require.NoError(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (2, 1, 2, 1), (3, 1, 1, 2)").Error) assert.False(t, db.Migrator().HasIndex("captain_assistants", "idx_captain_assistants_account_id_id")) assert.False(t, db.Migrator().HasIndex("captain_skills", "idx_captain_skills_account_id_id")) } func openCaptainMigrationPostgres(t *testing.T) (*gorm.DB, string) { t.Helper() dsn := os.Getenv("GOCHAT_TEST_DB_URL") if dsn == "" { dsn = "postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable" } admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) adminDB, err := admin.DB() require.NoError(t, err) t.Cleanup(func() { _ = adminDB.Close() }) schema := fmt.Sprintf("captain_skill_migration_%d", time.Now().UnixNano()) require.NoError(t, admin.Exec("CREATE SCHEMA "+schema).Error) t.Cleanup(func() { _ = admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error }) migrationURL, err := url.Parse(dsn) require.NoError(t, err) require.NotEmpty(t, migrationURL.Scheme, "GOCHAT_TEST_DB_URL must be a PostgreSQL URL") query := migrationURL.Query() query.Set("search_path", schema) migrationURL.RawQuery = query.Encode() db, err := gorm.Open(postgres.Open(migrationURL.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { _ = sqlDB.Close() }) require.NoError(t, db.Exec(captainSkillMigrationBaseline).Error) return db, migrationURL.String() } func runCaptainSkillMigration(t *testing.T, fn func() error) { t.Helper() require.NoError(t, withSanitizedPQEnvironment(fn)) } func TestCaptainSkillPostgresMigrationRoundTrip(t *testing.T) { if os.Getenv("GOCHAT_TEST_DB") == "sqlite" { t.Skip("PostgreSQL migration test") } db, dbURL := openCaptainMigrationPostgres(t) require.NoError(t, db.Exec("INSERT INTO accounts VALUES (1), (2); INSERT INTO captain_assistants VALUES (1, 1), (2, 2); INSERT INTO captain_skills VALUES (1, 1), (2, 2)").Error) migrations := captainSkillMigrationDir(t, "") runCaptainSkillMigration(t, func() error { return ForceVersion(dbURL, migrations, 80) }) runCaptainSkillMigration(t, func() error { return MigrateSteps(dbURL, migrations, 1) }) require.NoError(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (1, 1, 1, 1)").Error) assert.Error(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (2, 1, 2, 1)").Error) assert.Error(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (3, 1, 1, 2)").Error) runCaptainSkillMigration(t, func() error { return MigrateSteps(dbURL, migrations, -1) }) require.NoError(t, db.Exec("INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (2, 1, 2, 1), (3, 1, 1, 2)").Error) var count int64 require.NoError(t, db.Raw("SELECT count(*) FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN ('idx_captain_assistants_account_id_id', 'idx_captain_skills_account_id_id')").Scan(&count).Error) assert.Zero(t, count) } func TestCaptainSkillPostgresMigrationRejectsDirtyBindingsAtomically(t *testing.T) { if os.Getenv("GOCHAT_TEST_DB") == "sqlite" { t.Skip("PostgreSQL migration test") } db, dbURL := openCaptainMigrationPostgres(t) require.NoError(t, db.Exec("INSERT INTO accounts VALUES (1), (2); INSERT INTO captain_assistants VALUES (1, 1), (2, 2); INSERT INTO captain_skills VALUES (1, 1), (2, 2); INSERT INTO captain_assistant_skills(id, account_id, assistant_id, skill_id) VALUES (1, 1, 2, 1)").Error) migrations := captainSkillMigrationDir(t, "") runCaptainSkillMigration(t, func() error { return ForceVersion(dbURL, migrations, 80) }) err := withSanitizedPQEnvironment(func() error { return MigrateSteps(dbURL, migrations, 1) }) require.Error(t, err) var count int64 require.NoError(t, db.Table("captain_assistant_skills").Count(&count).Error) assert.Equal(t, int64(1), count) require.NoError(t, db.Raw("SELECT count(*) FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN ('idx_captain_assistants_account_id_id', 'idx_captain_skills_account_id_id')").Scan(&count).Error) assert.Zero(t, count) require.NoError(t, db.Raw("SELECT count(*) FROM pg_constraint WHERE connamespace = current_schema()::regnamespace AND conname IN ('fk_captain_assistant_skills_assistant_tenant', 'fk_captain_assistant_skills_skill_tenant')").Scan(&count).Error) assert.Zero(t, count) version, dirty, versionErr := CurrentVersion(dbURL, migrations) require.NoError(t, versionErr) assert.Equal(t, uint(81), version) assert.True(t, dirty) }