// Package repository 仓储层集成测试(登录主链路数据访问,对齐 T1 DDL + T2 GORM model)。 // // 运行前提(DSN 含密码,不入库,经环境变量注入——同 db_test.go 纪律): // // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local // go test ./internal/identity/repository/ -v // // 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。 // 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限);测后 DropTable 清理 4 张表 // (t_user/t_identity/t_membership + 依赖的 t_institution,保留 e2e_test 库本身供 W3 e2e 复用)。 // // 覆盖(User 主表 + 登录身份 + 成员关系,登录两步协议的仓储支撑): // - UserRepo.GetByCanonicalUserID:命中 / 未命中(ErrNotFound); // - UserRepo.UpdatePasswordLazyUpgrade:argon2 重哈希 + salt 置 NULL + hash_version 升级 / 未命中用户; // - IdentityRepo.GetByTypeIdentifier:(type,identifier) 唯一键命中 / 未命中; // - MembershipRepo.ListByCanonicalUserID:多 membership 按 created_at 升序 / 无 membership 返非 nil 空切片; // - MembershipRepo.GetByUserInstitutionCampus:命中 / 同作用域多角色取最新 / 未命中。 package repository import ( "context" "os" "testing" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/infra/config" infra "yuansiwei/unified-backend/internal/infra/db" "yuansiwei/unified-backend/internal/identity/id" identitymodel "yuansiwei/unified-backend/internal/identity/model" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // dsnOrSkip 读 UNIFIED_TEST_DSN,未设置时跳过(CI / 无库环境)。 // 入参:t *testing.T。出参:string(DSN,非空)。 func dsnOrSkip(t *testing.T) string { t.Helper() dsn := os.Getenv("UNIFIED_TEST_DSN") if dsn == "" { t.Skip("未设置 UNIFIED_TEST_DSN,跳过 identity 仓储集成测试") } return dsn } // strPtr 返回字符串指针(构造可空列测试数据用)。 // 入参:s string。出参:*string。 func strPtr(s string) *string { return &s } // setup 建 e2e_test 库连接 + AutoMigrate 4 表(幂等)+ 测后 DropTable 清理。 // 入参:t *testing.T。出参:*gorm.DB(已就绪句柄;失败 Fatalf)。 func setup(t *testing.T) *gorm.DB { t.Helper() dsn := dsnOrSkip(t) cfg := config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60} gdb, err := infra.NewDB(cfg) if err != nil { t.Fatalf("NewDB: %v", err) } t.Cleanup(func() { _ = infra.Close(gdb) }) // 4 表:t_user / t_identity(identity 域)+ t_membership / t_institution(org 域,membership 引用机构) if err := infra.Migrate(gdb, &identitymodel.User{}, &identitymodel.Identity{}, &orgmodel.Membership{}, &orgmodel.Institution{}); err != nil { t.Fatalf("Migrate: %v", err) } t.Cleanup(func() { m := gdb.Migrator() _ = m.DropTable("t_user", "t_identity", "t_membership", "t_institution") }) return gdb } // fixture 登录主链路测试数据(1 用户 + 2 身份 + 3 成员关系,含同作用域双角色场景)。 // canonical 用 id 包生成合法 26 字符 ULID(非硬编码,规避 Validate 规则)。 type fixture struct { canonical string // 被测用户 ULID other string // 干扰用户 ULID(验证"无 membership 返空切片"路径) hashOld string // 旧 md5_salt 哈希(惰性升级前) hashNew string // argon2 新哈希(惰性升级后) phone string // phone 身份 identifier email string // email 身份 identifier instA int64 // 机构 A ID instB int64 // 机构 B ID } // insertFixture 在 setup 好的库中落 1 用户 + 2 身份 + 3 成员关系(raw SQL 显式 created_at 保证排序确定)。 // 入参:t *testing.T;gdb *gorm.DB;ctx context.Context。出参:fixture(已插入,调用方可断言)。 func insertFixture(t *testing.T, gdb *gorm.DB, ctx context.Context) fixture { t.Helper() canonical, err := id.NewCanonicalUserID() if err != nil { t.Fatalf("生成 canonical user id: %v", err) } other, err := id.NewCanonicalUserID() if err != nil { t.Fatalf("生成 other user id: %v", err) } now := time.Now() hashOld := "0f8d5d58d0a21f8d5d58d0a21f8d5d58" // 模拟 md5(password+salt) 32 位 hashNew := "$argon2id$v=19$m=19,t=2,p=1$c29tZXNhbHR2YWx1ZQ$abcDEF123xyz" // 模拟 argon2 PHC 串 phone := "13800000001" email := "TestUser@example.com" instA := int64(1) instB := int64(2) // 用户主表(密码三列走 md5_salt 旧版本,供惰性升级测试) if err := gdb.WithContext(ctx). Create(&identitymodel.User{ CanonicalUserID: canonical, PersonType: identitymodel.PersonTypeStaff, PasswordHash: &hashOld, PasswordSalt: strPtr("legacy_salt"), HashVersion: strPtr(identitymodel.HashVersionMd5Salt), Status: identitymodel.UserStatusActive, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("插入 t_user: %v", err) } // 干扰用户(验证 ListByCanonicalUserID 对"无 membership 用户"返非 nil 空切片) if err := gdb.WithContext(ctx). Create(&identitymodel.User{ CanonicalUserID: other, PersonType: identitymodel.PersonTypeStudent, Status: identitymodel.UserStatusActive, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("插入干扰 t_user: %v", err) } // 身份表:同 canonical 挂 phone + email 两身份(一人多登录方式,(type,identifier) 唯一) for i, it := range []identitymodel.Identity{ {CanonicalUserID: canonical, Type: identitymodel.IdentityTypePhone, Identifier: phone, Verified: true, CreatedAt: now}, {CanonicalUserID: canonical, Type: identitymodel.IdentityTypeEmail, Identifier: email, Verified: false, CreatedAt: now.Add(time.Second)}, } { if err := gdb.WithContext(ctx).Create(&it).Error; err != nil { t.Fatalf("插入 t_identity[%d]: %v", i, err) } } // 成员关系表:raw SQL 显式 created_at(秒级间隔,保证 created_at 排序确定)。 // 同 (canonical, instA) 下 M1/M2 两角色(created_at 不同)→ 验证"同作用域多角色取最新"。 // M3 在 instB → 验证"ListByCanonicalUserID 按 created_at 升序"(M3 < M1 < M2)。 memberships := []struct { inst int64 role string created time.Time }{ {inst: instB, role: "academic", created: now.Add(-30 * time.Second)}, {inst: instA, role: "teacher", created: now.Add(-20 * time.Second)}, {inst: instA, role: "finance", created: now.Add(-10 * time.Second)}, } insertMembershipSQL := `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at) VALUES (?, ?, 0, ?, 'active', ?, ?)` for i, m := range memberships { // 5 个占位符:canonical_user_id, institution_id, role, joined_at, created_at(joined_at 与 created_at 同值) if err := gdb.WithContext(ctx). Exec(insertMembershipSQL, canonical, m.inst, m.role, m.created, m.created).Error; err != nil { t.Fatalf("插入 t_membership[%d]: %v", i, err) } } return fixture{ canonical: canonical, other: other, hashOld: hashOld, hashNew: hashNew, phone: phone, email: email, instA: instA, instB: instB, } }