migrate_e2e_test.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. // Package migrate 数据迁移端到端测试(真库,P1-04 验收:重跑幂等 + 事务边界正确)。
  2. //
  3. // DSN 纪律(对齐 identity e2e):
  4. // - 三个 DSN 走环境变量,**不经测试代码硬编码、不落盘**:
  5. // UNIFIED_MIGRATE_TEACHING_DSN 教学库 yuansiwei_i(只读源)
  6. // UNIFIED_MIGRATE_FINANCE_DSN 教务库 finance_bk(只读源)
  7. // UNIFIED_MIGRATE_TARGET_DSN 目标库(读写,建议独立测试库如 e2e_migrate)
  8. // - 任一缺失 → t.Skip(CI/无库环境 go test ./... 不受影响)。
  9. //
  10. // 验证链路(TestMigrateE2E):
  11. // 1. EnsureTables:目标库建 12 表(AutoMigrate 幂等);
  12. // 2. 首次 run:读 legacy 全量 → 归一 → 置信分级 → 写 5 表 + 机构/校区;
  13. // 3. verify:唯一键完整性 + 引用完整性(phone 撞号/unionid 重复/孤儿 均应为 0);
  14. // 4. 重跑 run(幂等):ULID 复用(读 t_legacy_mapping)→ UsersCreated 恒 0、
  15. // 全部 Skipped;身份/成员/锚点经唯一键去重,量趋 0;
  16. // 5. 清理:drop 12 表(回到无表态,不污染其他 e2e)。
  17. //
  18. // 跑法(终端手动,密码不经 agent):
  19. //
  20. // export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local'
  21. // export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
  22. // export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/e2e_migrate?charset=utf8mb4&parseTime=True&loc=Local'
  23. // go test ./internal/migrate/ -run TestMigrateE2E -v
  24. package migrate
  25. import (
  26. "context"
  27. "os"
  28. "testing"
  29. "gorm.io/gorm"
  30. )
  31. // migrateDSNsOrSkip 读三个迁移 DSN,任一缺失则跳过测试。
  32. func migrateDSNsOrSkip(t *testing.T) (teaching, finance, target string) {
  33. teaching = os.Getenv("UNIFIED_MIGRATE_TEACHING_DSN")
  34. finance = os.Getenv("UNIFIED_MIGRATE_FINANCE_DSN")
  35. target = os.Getenv("UNIFIED_MIGRATE_TARGET_DSN")
  36. if teaching == "" || finance == "" || target == "" {
  37. t.Skip("未设置 UNIFIED_MIGRATE_{TEACHING,FINANCE,TARGET}_DSN,跳过迁移端到端测试")
  38. }
  39. return
  40. }
  41. // dropAllTables 清理目标库 12 表(回到无表态,隔离其他 e2e)。
  42. func dropAllTables(t *testing.T, gdb *gorm.DB) {
  43. t.Helper()
  44. tables := []string{
  45. "t_membership", "t_guardian_link", "t_role_permission",
  46. "t_identity", "t_wechat_union", "t_legacy_mapping", "t_audit_log",
  47. "t_user", "t_campus", "t_institution", "t_permission", "t_role",
  48. }
  49. for _, tb := range tables {
  50. // 不建物理 FK,drop 顺序无关;忽略"表不存在"错误(首次跑前无表)
  51. _ = gdb.Migrator().DropTable(tb)
  52. }
  53. }
  54. // TestMigrateE2E 迁移端到端全链路(首次 run + verify 唯一键 + 重跑幂等)。
  55. func TestMigrateE2E(t *testing.T) {
  56. teaching, finance, target := migrateDSNsOrSkip(t)
  57. ctx := context.Background()
  58. // 目标库连接 + 清理旧表(幂等起点)
  59. targetDB, err := gorm.Open(gormMySQLTarget(target), gormMigrateCfg())
  60. if err != nil {
  61. t.Fatalf("目标库连接失败: %v", err)
  62. }
  63. defer func() {
  64. if sqlDB, err := targetDB.DB(); err == nil {
  65. _ = sqlDB.Close()
  66. }
  67. }()
  68. dropAllTables(t, targetDB)
  69. // ---- ① 首次 run(EnsureTables + 单事务写 5 表)----
  70. runner, err := NewRunner(Options{
  71. TeachingDSN: teaching,
  72. FinanceDSN: finance,
  73. TargetDSN: target,
  74. DryRun: false,
  75. })
  76. if err != nil {
  77. t.Fatalf("装配 Runner 失败: %v", err)
  78. }
  79. if err := runner.EnsureTables(ctx); err != nil {
  80. t.Fatalf("EnsureTables 失败: %v", err)
  81. }
  82. first, err := runner.Run(ctx)
  83. if err != nil {
  84. t.Fatalf("首次 run 失败: %v", err)
  85. }
  86. _ = runner.Close()
  87. // 首次 run 断言:用户全量新建
  88. if first.UsersCreated != first.UsersTotal {
  89. t.Errorf("首次 run UsersCreated=%d ≠ UsersTotal=%d(应有少量 username 冲突跳过)",
  90. first.UsersCreated, first.UsersTotal)
  91. }
  92. if first.UsersTotal <= 0 {
  93. t.Fatalf("UsersTotal=0(legacy t_user 读取异常)")
  94. }
  95. t.Logf("首次 run:用户 %d / 机构 %d / 校区 %d / 身份(phone=%d wechat=%d) / 锚点 %d / 成员 %d / 映射 %d",
  96. first.UsersCreated, first.InstitutionsCreated, first.CampusesCreated,
  97. first.IdentitiesPhone, first.IdentitiesWechat,
  98. first.WechatUnionCreated, first.MembershipsCreated, first.LegacyMappingsCreated)
  99. // ---- ② verify:唯一键 + 引用完整性 ----
  100. verifier, err := NewVerifier(target)
  101. if err != nil {
  102. t.Fatalf("装配 Verifier 失败: %v", err)
  103. }
  104. vrep, err := verifier.Verify(ctx)
  105. if err != nil {
  106. t.Fatalf("verify 失败: %v", err)
  107. }
  108. if !vrep.OK() {
  109. t.Errorf("verify 未通过(唯一键/引用完整性违规):\n%s", vrep.String())
  110. }
  111. _ = verifier.Close()
  112. // ---- ③ 重跑 run(幂等断言:ULID 复用 → 0 新建)----
  113. runner2, err := NewRunner(Options{
  114. TeachingDSN: teaching,
  115. FinanceDSN: finance,
  116. TargetDSN: target,
  117. DryRun: false,
  118. })
  119. if err != nil {
  120. t.Fatalf("装配 Runner(重跑) 失败: %v", err)
  121. }
  122. second, err := runner2.Run(ctx)
  123. if err != nil {
  124. t.Fatalf("重跑 run 失败: %v", err)
  125. }
  126. _ = runner2.Close()
  127. // 幂等断言:重跑时所有 legacy id 已有 ULID 映射 → t_user 0 新建、全 Skipped
  128. if second.UsersCreated != 0 {
  129. t.Errorf("幂等违反:重跑 UsersCreated=%d(期望 0,ULID 应复用)", second.UsersCreated)
  130. }
  131. if second.UsersSkipped != first.UsersTotal {
  132. t.Errorf("幂等违反:重跑 UsersSkipped=%d(期望 %d=首次全量)", second.UsersSkipped, first.UsersTotal)
  133. }
  134. // 成员/映射重跑也应 0 新建(唯一键去重)
  135. if second.MembershipsCreated != 0 {
  136. t.Errorf("幂等违反:重跑 MembershipsCreated=%d(期望 0)", second.MembershipsCreated)
  137. }
  138. if second.LegacyMappingsCreated != 0 {
  139. t.Errorf("幂等违反:重跑 LegacyMappingsCreated=%d(期望 0)", second.LegacyMappingsCreated)
  140. }
  141. t.Logf("重跑 run:UsersCreated=%d UsersSkipped=%d MembershipsCreated=%d(幂等验证)",
  142. second.UsersCreated, second.UsersSkipped, second.MembershipsCreated)
  143. // ---- ④ 清理 ----
  144. dropAllTables(t, targetDB)
  145. }