All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
223 lines
8.1 KiB
Go
223 lines
8.1 KiB
Go
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
|
||
}
|