package releasegate_test import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "github.com/google/uuid" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/infrastructure/releasegate" "github.com/break/junhong_cmp_fiber/internal/testutil" ) func TestPublicFoundationMigrationsOnEmptyAndCompatibleDatabase(t *testing.T) { db := testutil.NewPostgresTransaction(t) schema := prepareMigrationSchema(t, db, 164) checker := releasegate.NewCheckerWithSchema(db, schema) pre, err := checker.Run(context.Background(), releasegate.PhasePre) if err != nil { t.Fatalf("执行迁移前检查失败:%v", err) } if !pre.Passed() { t.Fatalf("空数据库迁移前检查不应阻断:%+v", pre.Findings) } if err := db.Exec("CREATE TABLE compatible_existing_data (id bigint PRIMARY KEY, value text NOT NULL)").Error; err != nil { t.Fatalf("准备兼容存量表失败:%v", err) } if err := db.Exec("INSERT INTO compatible_existing_data (id, value) VALUES (1, 'keep')").Error; err != nil { t.Fatalf("准备兼容存量数据失败:%v", err) } executeMigration(t, db, "000165_create_public_outbox.up.sql") executeMigration(t, db, "000166_create_system_config.up.sql") if err := db.Exec("UPDATE schema_migrations SET version = 166, dirty = false").Error; err != nil { t.Fatalf("更新隔离迁移版本失败:%v", err) } post, err := checker.Run(context.Background(), releasegate.PhasePost) if err != nil { t.Fatalf("执行迁移后检查失败:%v", err) } if !post.Passed() { t.Fatalf("迁移后检查应通过:%+v", post.Findings) } postAgain, err := checker.Run(context.Background(), releasegate.PhasePost) if err != nil || !postAgain.Passed() { t.Fatalf("迁移后检查必须可重复执行:%v,结果:%+v", err, postAgain.Findings) } var compatibleCount int64 if err := db.Table("compatible_existing_data").Count(&compatibleCount).Error; err != nil || compatibleCount != 1 { t.Fatalf("迁移破坏了兼容存量数据:%v,数量:%d", err, compatibleCount) } if err := db.Transaction(func(tx *gorm.DB) error { return executeMigrationWithError(tx, "000165_create_public_outbox.up.sql") }); err == nil { t.Fatal("公共对象已创建时不得通过重复 DDL 静默掩盖定义") } } func TestMigrationGateBlocksAnomaliesWithoutDestructiveWrites(t *testing.T) { db := testutil.NewPostgresTransaction(t) schema := prepareMigrationSchema(t, db, 166) executeMigration(t, db, "000165_create_public_outbox.up.sql") executeMigration(t, db, "000166_create_system_config.up.sql") if err := db.Exec(`INSERT INTO tb_outbox_event (event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload) VALUES ('blocked-event', 'foundation.blocked', 1, 'example', '1', 'example', '1', '{}'::jsonb)`).Error; err != nil { t.Fatalf("准备未投递异常失败:%v", err) } if err := db.Exec("ALTER TABLE tb_outbox_event DROP CONSTRAINT ck_outbox_event_status").Error; err != nil { t.Fatalf("准备非法状态定义失败:%v", err) } if err := db.Exec(`INSERT INTO tb_outbox_event (event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload, status) VALUES ('invalid-status', 'foundation.invalid', 1, 'example', '2', 'example', '2', '{}'::jsonb, 9)`).Error; err != nil { t.Fatalf("准备非法状态数据失败:%v", err) } report, err := releasegate.NewCheckerWithSchema(db, schema).Run(context.Background(), releasegate.PhasePost) if err != nil { t.Fatalf("执行异常门禁失败:%v", err) } if report.Passed() || !hasFinding(report, "OUTBOX_UNDELIVERED") || !hasFinding(report, "OUTBOX_INVALID_STATUS") || !hasFinding(report, "FOUNDATION_CONSTRAINT_MISSING") { t.Fatalf("异常数据和定义未被完整阻断:%+v", report.Findings) } var count int64 if err := db.Table("tb_outbox_event").Count(&count).Error; err != nil || count != 2 { t.Fatalf("只读门禁修改了异常数据:%v,数量:%d", err, count) } } func TestDownMigrationAllowsEmptyStructuresAndRejectsExistingFacts(t *testing.T) { t.Run("空结构可回滚", func(t *testing.T) { db := testutil.NewPostgresTransaction(t) schema := prepareMigrationSchema(t, db, 166) executeMigration(t, db, "000165_create_public_outbox.up.sql") executeMigration(t, db, "000166_create_system_config.up.sql") executeMigration(t, db, "000166_create_system_config.down.sql") executeMigration(t, db, "000165_create_public_outbox.down.sql") for _, table := range []string{"tb_outbox_event", "tb_system_config"} { var exists bool if err := db.Raw("SELECT to_regclass(?) IS NOT NULL", schema+"."+table).Scan(&exists).Error; err != nil || exists { t.Fatalf("空结构回滚失败:%s,错误:%v", table, err) } } }) t.Run("已有事实拒绝删表", func(t *testing.T) { db := testutil.NewPostgresTransaction(t) _ = prepareMigrationSchema(t, db, 166) executeMigration(t, db, "000165_create_public_outbox.up.sql") executeMigration(t, db, "000166_create_system_config.up.sql") if err := db.Exec(`INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES ('foundation.test.fact', 'true', 'bool', 'foundation', '回滚保护测试')`).Error; err != nil { t.Fatalf("准备配置事实失败:%v", err) } if err := db.Transaction(func(tx *gorm.DB) error { return executeMigrationWithError(tx, "000166_create_system_config.down.sql") }); err == nil || !strings.Contains(err.Error(), "禁止删表回滚") { t.Fatalf("已有事实时 down 迁移必须明确拒绝:%v", err) } var count int64 if err := db.Table("tb_system_config").Count(&count).Error; err != nil || count != 1 { t.Fatalf("拒绝回滚后配置事实丢失:%v,数量:%d", err, count) } }) } func prepareMigrationSchema(t *testing.T, db *gorm.DB, version uint) string { t.Helper() schema := "foundation_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") if err := db.Exec(fmt.Sprintf(`CREATE SCHEMA %s`, schema)).Error; err != nil { t.Fatalf("创建隔离迁移 schema 失败:%v", err) } if err := db.Exec(fmt.Sprintf(`SET LOCAL search_path TO %s`, schema)).Error; err != nil { t.Fatalf("切换隔离迁移 schema 失败:%v", err) } if err := db.Exec("CREATE TABLE schema_migrations (version bigint NOT NULL, dirty boolean NOT NULL)").Error; err != nil { t.Fatalf("创建隔离迁移版本表失败:%v", err) } if err := db.Exec("INSERT INTO schema_migrations (version, dirty) VALUES (?, false)", version).Error; err != nil { t.Fatalf("写入隔离迁移版本失败:%v", err) } return schema } func executeMigration(t *testing.T, db *gorm.DB, name string) { t.Helper() if err := executeMigrationWithError(db, name); err != nil { t.Fatalf("执行迁移 %s 失败:%v", name, err) } } func executeMigrationWithError(db *gorm.DB, name string) error { path := filepath.Join("..", "..", "..", "migrations", name) content, err := os.ReadFile(path) if err != nil { return err } for _, statement := range splitSQLStatements(string(content)) { if err := db.Exec(statement).Error; err != nil { return err } } return nil } func splitSQLStatements(content string) []string { statements := make([]string, 0) start := 0 inSingleQuote := false inDoubleQuote := false inDollarBlock := false for index := 0; index < len(content); index++ { if index+1 < len(content) && content[index:index+2] == "$$" && !inSingleQuote && !inDoubleQuote { inDollarBlock = !inDollarBlock index++ continue } if inDollarBlock { continue } switch content[index] { case '\'': if !inDoubleQuote { inSingleQuote = !inSingleQuote } case '"': if !inSingleQuote { inDoubleQuote = !inDoubleQuote } case ';': if !inSingleQuote && !inDoubleQuote { statement := strings.TrimSpace(content[start : index+1]) if statement != "" { statements = append(statements, statement) } start = index + 1 } } } if tail := strings.TrimSpace(content[start:]); tail != "" { statements = append(statements, tail) } return statements } func hasFinding(report releasegate.Report, code string) bool { for _, finding := range report.Findings { if finding.Code == code { return true } } return false }