|
|
@@ -0,0 +1,179 @@
|
|
|
+// 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,
|
|
|
+ }
|
|
|
+}
|