| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- // Package migrate 数据迁移端到端测试(真库,P1-04 验收:重跑幂等 + 事务边界正确)。
- //
- // DSN 纪律(对齐 identity e2e):
- // - 三个 DSN 走环境变量,**不经测试代码硬编码、不落盘**:
- // UNIFIED_MIGRATE_TEACHING_DSN 教学库 yuansiwei_i(只读源)
- // UNIFIED_MIGRATE_FINANCE_DSN 教务库 finance_bk(只读源)
- // UNIFIED_MIGRATE_TARGET_DSN 目标库(读写,建议独立测试库如 e2e_migrate)
- // - 任一缺失 → t.Skip(CI/无库环境 go test ./... 不受影响)。
- //
- // 验证链路(TestMigrateE2E):
- // 1. EnsureTables:目标库建 12 表(AutoMigrate 幂等);
- // 2. 首次 run:读 legacy 全量 → 归一 → 置信分级 → 写 5 表 + 机构/校区;
- // 3. verify:唯一键完整性 + 引用完整性(phone 撞号/unionid 重复/孤儿 均应为 0);
- // 4. 重跑 run(幂等):ULID 复用(读 t_legacy_mapping)→ UsersCreated 恒 0、
- // 全部 Skipped;身份/成员/锚点经唯一键去重,量趋 0;
- // 5. 清理:drop 12 表(回到无表态,不污染其他 e2e)。
- //
- // 跑法(终端手动,密码不经 agent):
- //
- // export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local'
- // export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
- // export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/e2e_migrate?charset=utf8mb4&parseTime=True&loc=Local'
- // go test ./internal/migrate/ -run TestMigrateE2E -v
- package migrate
- import (
- "context"
- "os"
- "testing"
- "gorm.io/gorm"
- )
- // migrateDSNsOrSkip 读三个迁移 DSN,任一缺失则跳过测试。
- func migrateDSNsOrSkip(t *testing.T) (teaching, finance, target string) {
- teaching = os.Getenv("UNIFIED_MIGRATE_TEACHING_DSN")
- finance = os.Getenv("UNIFIED_MIGRATE_FINANCE_DSN")
- target = os.Getenv("UNIFIED_MIGRATE_TARGET_DSN")
- if teaching == "" || finance == "" || target == "" {
- t.Skip("未设置 UNIFIED_MIGRATE_{TEACHING,FINANCE,TARGET}_DSN,跳过迁移端到端测试")
- }
- return
- }
- // dropAllTables 清理目标库 12 表(回到无表态,隔离其他 e2e)。
- func dropAllTables(t *testing.T, gdb *gorm.DB) {
- t.Helper()
- tables := []string{
- "t_membership", "t_guardian_link", "t_role_permission",
- "t_identity", "t_wechat_union", "t_legacy_mapping", "t_audit_log",
- "t_user", "t_campus", "t_institution", "t_permission", "t_role",
- }
- for _, tb := range tables {
- // 不建物理 FK,drop 顺序无关;忽略"表不存在"错误(首次跑前无表)
- _ = gdb.Migrator().DropTable(tb)
- }
- }
- // TestMigrateE2E 迁移端到端全链路(首次 run + verify 唯一键 + 重跑幂等)。
- func TestMigrateE2E(t *testing.T) {
- teaching, finance, target := migrateDSNsOrSkip(t)
- ctx := context.Background()
- // 目标库连接 + 清理旧表(幂等起点)
- targetDB, err := gorm.Open(gormMySQLTarget(target), gormMigrateCfg())
- if err != nil {
- t.Fatalf("目标库连接失败: %v", err)
- }
- defer func() {
- if sqlDB, err := targetDB.DB(); err == nil {
- _ = sqlDB.Close()
- }
- }()
- dropAllTables(t, targetDB)
- // ---- ① 首次 run(EnsureTables + 单事务写 5 表)----
- runner, err := NewRunner(Options{
- TeachingDSN: teaching,
- FinanceDSN: finance,
- TargetDSN: target,
- DryRun: false,
- })
- if err != nil {
- t.Fatalf("装配 Runner 失败: %v", err)
- }
- if err := runner.EnsureTables(ctx); err != nil {
- t.Fatalf("EnsureTables 失败: %v", err)
- }
- first, err := runner.Run(ctx)
- if err != nil {
- t.Fatalf("首次 run 失败: %v", err)
- }
- _ = runner.Close()
- // 首次 run 断言:用户全量新建
- if first.UsersCreated != first.UsersTotal {
- t.Errorf("首次 run UsersCreated=%d ≠ UsersTotal=%d(应有少量 username 冲突跳过)",
- first.UsersCreated, first.UsersTotal)
- }
- if first.UsersTotal <= 0 {
- t.Fatalf("UsersTotal=0(legacy t_user 读取异常)")
- }
- t.Logf("首次 run:用户 %d / 机构 %d / 校区 %d / 身份(phone=%d wechat=%d) / 锚点 %d / 成员 %d / 映射 %d",
- first.UsersCreated, first.InstitutionsCreated, first.CampusesCreated,
- first.IdentitiesPhone, first.IdentitiesWechat,
- first.WechatUnionCreated, first.MembershipsCreated, first.LegacyMappingsCreated)
- // ---- ② verify:唯一键 + 引用完整性 ----
- verifier, err := NewVerifier(target)
- if err != nil {
- t.Fatalf("装配 Verifier 失败: %v", err)
- }
- vrep, err := verifier.Verify(ctx)
- if err != nil {
- t.Fatalf("verify 失败: %v", err)
- }
- if !vrep.OK() {
- t.Errorf("verify 未通过(唯一键/引用完整性违规):\n%s", vrep.String())
- }
- _ = verifier.Close()
- // ---- ③ 重跑 run(幂等断言:ULID 复用 → 0 新建)----
- runner2, err := NewRunner(Options{
- TeachingDSN: teaching,
- FinanceDSN: finance,
- TargetDSN: target,
- DryRun: false,
- })
- if err != nil {
- t.Fatalf("装配 Runner(重跑) 失败: %v", err)
- }
- second, err := runner2.Run(ctx)
- if err != nil {
- t.Fatalf("重跑 run 失败: %v", err)
- }
- _ = runner2.Close()
- // 幂等断言:重跑时所有 legacy id 已有 ULID 映射 → t_user 0 新建、全 Skipped
- if second.UsersCreated != 0 {
- t.Errorf("幂等违反:重跑 UsersCreated=%d(期望 0,ULID 应复用)", second.UsersCreated)
- }
- if second.UsersSkipped != first.UsersTotal {
- t.Errorf("幂等违反:重跑 UsersSkipped=%d(期望 %d=首次全量)", second.UsersSkipped, first.UsersTotal)
- }
- // 成员/映射重跑也应 0 新建(唯一键去重)
- if second.MembershipsCreated != 0 {
- t.Errorf("幂等违反:重跑 MembershipsCreated=%d(期望 0)", second.MembershipsCreated)
- }
- if second.LegacyMappingsCreated != 0 {
- t.Errorf("幂等违反:重跑 LegacyMappingsCreated=%d(期望 0)", second.LegacyMappingsCreated)
- }
- t.Logf("重跑 run:UsersCreated=%d UsersSkipped=%d MembershipsCreated=%d(幂等验证)",
- second.UsersCreated, second.UsersSkipped, second.MembershipsCreated)
- // ---- ④ 清理 ----
- dropAllTables(t, targetDB)
- }
|