test_helpers_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. // Package repository 仓储层集成测试(登录主链路数据访问,对齐 T1 DDL + T2 GORM model)。
  2. //
  3. // 运行前提(DSN 含密码,不入库,经环境变量注入——同 db_test.go 纪律):
  4. //
  5. // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
  6. // go test ./internal/identity/repository/ -v
  7. //
  8. // 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。
  9. // 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限);测后 DropTable 清理 4 张表
  10. // (t_user/t_identity/t_membership + 依赖的 t_institution,保留 e2e_test 库本身供 W3 e2e 复用)。
  11. //
  12. // 覆盖(User 主表 + 登录身份 + 成员关系,登录两步协议的仓储支撑):
  13. // - UserRepo.GetByCanonicalUserID:命中 / 未命中(ErrNotFound);
  14. // - UserRepo.UpdatePasswordLazyUpgrade:argon2 重哈希 + salt 置 NULL + hash_version 升级 / 未命中用户;
  15. // - IdentityRepo.GetByTypeIdentifier:(type,identifier) 唯一键命中 / 未命中;
  16. // - MembershipRepo.ListByCanonicalUserID:多 membership 按 created_at 升序 / 无 membership 返非 nil 空切片;
  17. // - MembershipRepo.GetByUserInstitutionCampus:命中 / 同作用域多角色取最新 / 未命中。
  18. package repository
  19. import (
  20. "context"
  21. "os"
  22. "testing"
  23. "time"
  24. "gorm.io/gorm"
  25. "yuansiwei/unified-backend/internal/infra/config"
  26. infra "yuansiwei/unified-backend/internal/infra/db"
  27. "yuansiwei/unified-backend/internal/identity/id"
  28. identitymodel "yuansiwei/unified-backend/internal/identity/model"
  29. orgmodel "yuansiwei/unified-backend/internal/org/model"
  30. )
  31. // dsnOrSkip 读 UNIFIED_TEST_DSN,未设置时跳过(CI / 无库环境)。
  32. // 入参:t *testing.T。出参:string(DSN,非空)。
  33. func dsnOrSkip(t *testing.T) string {
  34. t.Helper()
  35. dsn := os.Getenv("UNIFIED_TEST_DSN")
  36. if dsn == "" {
  37. t.Skip("未设置 UNIFIED_TEST_DSN,跳过 identity 仓储集成测试")
  38. }
  39. return dsn
  40. }
  41. // strPtr 返回字符串指针(构造可空列测试数据用)。
  42. // 入参:s string。出参:*string。
  43. func strPtr(s string) *string { return &s }
  44. // setup 建 e2e_test 库连接 + AutoMigrate 4 表(幂等)+ 测后 DropTable 清理。
  45. // 入参:t *testing.T。出参:*gorm.DB(已就绪句柄;失败 Fatalf)。
  46. func setup(t *testing.T) *gorm.DB {
  47. t.Helper()
  48. dsn := dsnOrSkip(t)
  49. cfg := config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60}
  50. gdb, err := infra.NewDB(cfg)
  51. if err != nil {
  52. t.Fatalf("NewDB: %v", err)
  53. }
  54. t.Cleanup(func() { _ = infra.Close(gdb) })
  55. // 4 表:t_user / t_identity(identity 域)+ t_membership / t_institution(org 域,membership 引用机构)
  56. if err := infra.Migrate(gdb, &identitymodel.User{}, &identitymodel.Identity{},
  57. &orgmodel.Membership{}, &orgmodel.Institution{}); err != nil {
  58. t.Fatalf("Migrate: %v", err)
  59. }
  60. t.Cleanup(func() {
  61. m := gdb.Migrator()
  62. _ = m.DropTable("t_user", "t_identity", "t_membership", "t_institution")
  63. })
  64. return gdb
  65. }
  66. // fixture 登录主链路测试数据(1 用户 + 2 身份 + 3 成员关系,含同作用域双角色场景)。
  67. // canonical 用 id 包生成合法 26 字符 ULID(非硬编码,规避 Validate 规则)。
  68. type fixture struct {
  69. canonical string // 被测用户 ULID
  70. other string // 干扰用户 ULID(验证"无 membership 返空切片"路径)
  71. hashOld string // 旧 md5_salt 哈希(惰性升级前)
  72. hashNew string // argon2 新哈希(惰性升级后)
  73. phone string // phone 身份 identifier
  74. email string // email 身份 identifier
  75. instA int64 // 机构 A ID
  76. instB int64 // 机构 B ID
  77. }
  78. // insertFixture 在 setup 好的库中落 1 用户 + 2 身份 + 3 成员关系(raw SQL 显式 created_at 保证排序确定)。
  79. // 入参:t *testing.T;gdb *gorm.DB;ctx context.Context。出参:fixture(已插入,调用方可断言)。
  80. func insertFixture(t *testing.T, gdb *gorm.DB, ctx context.Context) fixture {
  81. t.Helper()
  82. canonical, err := id.NewCanonicalUserID()
  83. if err != nil {
  84. t.Fatalf("生成 canonical user id: %v", err)
  85. }
  86. other, err := id.NewCanonicalUserID()
  87. if err != nil {
  88. t.Fatalf("生成 other user id: %v", err)
  89. }
  90. now := time.Now()
  91. hashOld := "0f8d5d58d0a21f8d5d58d0a21f8d5d58" // 模拟 md5(password+salt) 32 位
  92. hashNew := "$argon2id$v=19$m=19,t=2,p=1$c29tZXNhbHR2YWx1ZQ$abcDEF123xyz" // 模拟 argon2 PHC 串
  93. phone := "13800000001"
  94. email := "TestUser@example.com"
  95. instA := int64(1)
  96. instB := int64(2)
  97. // 用户主表(密码三列走 md5_salt 旧版本,供惰性升级测试)
  98. if err := gdb.WithContext(ctx).
  99. Create(&identitymodel.User{
  100. CanonicalUserID: canonical,
  101. PersonType: identitymodel.PersonTypeStaff,
  102. PasswordHash: &hashOld,
  103. PasswordSalt: strPtr("legacy_salt"),
  104. HashVersion: strPtr(identitymodel.HashVersionMd5Salt),
  105. Status: identitymodel.UserStatusActive,
  106. CreatedAt: now,
  107. UpdatedAt: now,
  108. }).Error; err != nil {
  109. t.Fatalf("插入 t_user: %v", err)
  110. }
  111. // 干扰用户(验证 ListByCanonicalUserID 对"无 membership 用户"返非 nil 空切片)
  112. if err := gdb.WithContext(ctx).
  113. Create(&identitymodel.User{
  114. CanonicalUserID: other,
  115. PersonType: identitymodel.PersonTypeStudent,
  116. Status: identitymodel.UserStatusActive,
  117. CreatedAt: now,
  118. UpdatedAt: now,
  119. }).Error; err != nil {
  120. t.Fatalf("插入干扰 t_user: %v", err)
  121. }
  122. // 身份表:同 canonical 挂 phone + email 两身份(一人多登录方式,(type,identifier) 唯一)
  123. for i, it := range []identitymodel.Identity{
  124. {CanonicalUserID: canonical, Type: identitymodel.IdentityTypePhone,
  125. Identifier: phone, Verified: true, CreatedAt: now},
  126. {CanonicalUserID: canonical, Type: identitymodel.IdentityTypeEmail,
  127. Identifier: email, Verified: false, CreatedAt: now.Add(time.Second)},
  128. } {
  129. if err := gdb.WithContext(ctx).Create(&it).Error; err != nil {
  130. t.Fatalf("插入 t_identity[%d]: %v", i, err)
  131. }
  132. }
  133. // 成员关系表:raw SQL 显式 created_at(秒级间隔,保证 created_at 排序确定)。
  134. // 同 (canonical, instA) 下 M1/M2 两角色(created_at 不同)→ 验证"同作用域多角色取最新"。
  135. // M3 在 instB → 验证"ListByCanonicalUserID 按 created_at 升序"(M3 < M1 < M2)。
  136. memberships := []struct {
  137. inst int64
  138. role string
  139. created time.Time
  140. }{
  141. {inst: instB, role: "academic", created: now.Add(-30 * time.Second)},
  142. {inst: instA, role: "teacher", created: now.Add(-20 * time.Second)},
  143. {inst: instA, role: "finance", created: now.Add(-10 * time.Second)},
  144. }
  145. insertMembershipSQL := `INSERT INTO t_membership
  146. (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
  147. VALUES (?, ?, 0, ?, 'active', ?, ?)`
  148. for i, m := range memberships {
  149. // 5 个占位符:canonical_user_id, institution_id, role, joined_at, created_at(joined_at 与 created_at 同值)
  150. if err := gdb.WithContext(ctx).
  151. Exec(insertMembershipSQL, canonical, m.inst, m.role, m.created, m.created).Error; err != nil {
  152. t.Fatalf("插入 t_membership[%d]: %v", i, err)
  153. }
  154. }
  155. return fixture{
  156. canonical: canonical,
  157. other: other,
  158. hashOld: hashOld,
  159. hashNew: hashNew,
  160. phone: phone,
  161. email: email,
  162. instA: instA,
  163. instB: instB,
  164. }
  165. }