Files
junhong_cmp_fiber/internal/infrastructure/releasegate/migration_integration_test.go
break 17782d5f8e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
实现七月迭代公共技术基础
2026-07-23 17:52:48 +09:00

223 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}