| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- // 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.GetByTypeIdentifierAppID:(type,identifier,appid) 唯一键命中 / 未命中(v1.2);
- // - 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,
- }
- }
|