// academic repository 共享内核单测(Scoped 作用域注入 / TxRunner 事务透传 / 分页钳制 / 唯一键识别)。 // // 测试栈:glebarez/sqlite(纯 Go,无 CGO,CI 的 CGO_ENABLED=0 环境同样可跑,与 infra/scope 单测一致)。 // 需要"真实事务"的用例用临时文件库(t.TempDir),`:memory:` 在连接池多连接下不可共享。 package repository import ( "context" stderrors "errors" "path/filepath" "strings" "testing" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // txFixture 事务测试用 fixture 表(仅需 id/name 两列)。 type txFixture struct { ID int64 `gorm:"column:id;primaryKey"` Name string `gorm:"column:name;size:32"` } // TableName 指定表名。 func (txFixture) TableName() string { return "t_academic_tx_fixture" } // newDryRunDB DryRun 句柄(只构造 SQL,不落库;用于断言 scoping 注入的 WHERE 片段)。 func newDryRunDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(gormsqlite.Open(":memory:"), &gorm.Config{DryRun: true}) if err != nil { t.Fatalf("gorm.Open(DryRun) 失败: %v", err) } return db } // newFileDB 临时文件库(支持真实事务/回滚)。 func newFileDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_repo_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(文件库) 失败: %v", err) } // Windows 下未关闭连接会导致 t.TempDir 清理失败(unlinkat 占用),故注册清理钩子 t.Cleanup(func() { if sqlDB, e := db.DB(); e == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate(&txFixture{}); err != nil { t.Fatalf("AutoMigrate 失败: %v", err) } return db } // TestScopedInjectsTenantFilters 普通用户:强制机构 + 校区过滤。 func TestScopedInjectsTenantFilters(t *testing.T) { db := newDryRunDB(t) r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7}) q := r.Scoped(db.Session(&gorm.Session{NewDB: true})) var out []txFixture if err := q.Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } sql := q.Statement.SQL.String() if !strings.Contains(sql, "institution_id = ?") { t.Errorf("应注入 institution_id 过滤,SQL=%q", sql) } if !strings.Contains(sql, "campus_id = ?") { t.Errorf("CampusScoped=true 且 campus>0 应注入 campus_id 过滤,SQL=%q", sql) } } // TestScopedInstitutionOnly CampusScoped=false(机构级资源)→ 只注入机构过滤。 func TestScopedInstitutionOnly(t *testing.T) { db := newDryRunDB(t) r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7}) r.CampusScoped = false q := r.Scoped(db.Session(&gorm.Session{NewDB: true})) var out []txFixture if err := q.Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } sql := q.Statement.SQL.String() if !strings.Contains(sql, "institution_id = ?") { t.Errorf("应注入 institution_id 过滤,SQL=%q", sql) } if strings.Contains(sql, "campus_id = ?") { t.Errorf("CampusScoped=false 不应注入 campus_id(否则 Unknown column),SQL=%q", sql) } } // TestScopedCampusZero Sc.CampusID=0(机构级视角)→ 不限定校区。 func TestScopedCampusZero(t *testing.T) { db := newDryRunDB(t) r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 0}) q := r.Scoped(db.Session(&gorm.Session{NewDB: true})) var out []txFixture if err := q.Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } if sql := q.Statement.SQL.String(); strings.Contains(sql, "campus_id = ?") { t.Errorf("CampusID=0 不应注入 campus_id,SQL=%q", sql) } } // TestScopedSuperAdminBypass 超管跨机构:不注入任何作用域过滤。 func TestScopedSuperAdminBypass(t *testing.T) { db := newDryRunDB(t) r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}) q := r.Scoped(db.Session(&gorm.Session{NewDB: true})) var out []txFixture if err := q.Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } sql := q.Statement.SQL.String() if strings.Contains(sql, "institution_id = ?") || strings.Contains(sql, "campus_id = ?") { t.Errorf("超管不应注入作用域过滤,SQL=%q", sql) } } // TestScopedNilScopePanics 无作用域进入数据访问层 → panic(fail-fast,纵深防御)。 func TestScopedNilScopePanics(t *testing.T) { db := newDryRunDB(t) r := NewBaseRepo(db, nil) defer func() { rec := recover() if rec == nil { t.Fatal("Sc 为 nil 时应 panic") } if !strings.Contains(rec.(string), "missing tenant scope") { t.Fatalf("panic 文案不符: %v", rec) } }() _ = r.Scoped(db.Session(&gorm.Session{NewDB: true})) } // TestScopedNilDBReturnsNil db 为 nil 时返回 nil(不 panic,便于装配期防御)。 func TestScopedNilDBReturnsNil(t *testing.T) { r := NewBaseRepo(newDryRunDB(t), &appscope.Scope{InstitutionID: 1}) if got := r.Scoped(nil); got != nil { t.Fatal("Scoped(nil) 应返回 nil") } } // TestGetScopedID 读取当前作用域的机构/校区。 func TestGetScopedID(t *testing.T) { r := NewBaseRepo(nil, &appscope.Scope{InstitutionID: 42, CampusID: 7}) if inst, camp := r.GetScopedID(); inst != 42 || camp != 7 { t.Fatalf("GetScopedID = (%d,%d),期望 (42,7)", inst, camp) } if inst, camp := NewBaseRepo(nil, nil).GetScopedID(); inst != 0 || camp != 0 { t.Fatalf("无作用域应返回 (0,0),实得 (%d,%d)", inst, camp) } } // TestWithinTxRollback 顶层事务:fn 返回错误 → 已写行回滚(资金场景的核心保证)。 func TestWithinTxRollback(t *testing.T) { db := newFileDB(t) runner := NewTxRunner(db) boom := stderrors.New("boom") err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error { if InTx(tx) != true { t.Fatal("事务内 InTx(tx) 应为 true") } if e := tx.Create(&txFixture{ID: 1, Name: "a"}).Error; e != nil { return e } return boom }) if !stderrors.Is(err, infraerrors.ErrDBError) { t.Fatalf("非业务错误应包装为 ErrDBError,实得 %v", err) } var count int64 if e := db.Model(&txFixture{}).Count(&count).Error; e != nil { t.Fatalf("Count 失败: %v", e) } if count != 0 { t.Fatalf("回滚后应有 0 行,实得 %d", count) } } // TestWithinTxCommit 顶层事务:fn 返回 nil → 提交。 func TestWithinTxCommit(t *testing.T) { db := newFileDB(t) runner := NewTxRunner(db) if err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error { return tx.Create(&txFixture{ID: 2, Name: "b"}).Error }); err != nil { t.Fatalf("WithinTx 失败: %v", err) } var count int64 if err := db.Model(&txFixture{}).Count(&count).Error; err != nil { t.Fatalf("Count 失败: %v", err) } if count != 1 { t.Fatalf("提交后应有 1 行,实得 %d", count) } } // TestWithinTxNestedPassThrough 嵌套调用:内层**透传同一 tx,不重复 BEGIN**。 // // 验证两条透传通道: // - ctx 注入(WithTxContext,事务内跨 service 调用的主通道); // - runner 绑定(WithTx,显式传 tx 的场景)。 // // 并验证:内层写入后由外层回滚 → 内层数据一并丢失(说明没有独立事务提前提交)。 func TestWithinTxNestedPassThrough(t *testing.T) { db := newFileDB(t) runner := NewTxRunner(db) rollback := stderrors.New("outer-rollback") err := runner.WithinTx(context.Background(), func(outerTx *gorm.DB) error { if e := outerTx.Create(&txFixture{ID: 3, Name: "outer"}).Error; e != nil { return e } // 通道 ①:ctx 内已携带 tx(fn 收到的 tx.WithContext 已注入) if e := runner.WithinTx(outerTx.Statement.Context, func(innerTx *gorm.DB) error { if innerTx.Statement.ConnPool != outerTx.Statement.ConnPool { t.Fatal("经 ctx 透传的内层应复用外层 tx 连接(未重复 BEGIN)") } return innerTx.Create(&txFixture{ID: 4, Name: "inner"}).Error }); e != nil { return e } // 通道 ②:显式 WithTx 绑定 tx if e := runner.WithTx(outerTx).WithinTx(context.Background(), func(innerTx *gorm.DB) error { if innerTx.Statement.ConnPool != outerTx.Statement.ConnPool { t.Fatal("经 WithTx 绑定的内层应复用外层 tx 连接") } return innerTx.Create(&txFixture{ID: 5, Name: "inner2"}).Error }); e != nil { return e } return rollback }) if err == nil { t.Fatal("外层应返回回滚错误") } var count int64 if e := db.Model(&txFixture{}).Count(&count).Error; e != nil { t.Fatalf("Count 失败: %v", e) } if count != 0 { t.Fatalf("外层回滚后内层数据也应消失(无独立事务),实得 %d 行", count) } } // TestWithinTxBusinessErrorPassthrough 业务错误(*AppError)原样上抛,不被包装成 5002。 func TestWithinTxBusinessErrorPassthrough(t *testing.T) { db := newFileDB(t) runner := NewTxRunner(db) bizErr := infraerrors.New(3101, "余额不足") err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error { return bizErr }) if !stderrors.Is(err, bizErr) { t.Fatalf("业务错误应原样返回,实得 %v", err) } var appErr *infraerrors.AppError if !stderrors.As(err, &appErr) || appErr.Code != 3101 { t.Fatalf("应保留 3101 业务码,实得 %v", err) } } // TestWithinTxNilGuards 装配错误防御(DB/fn 为 nil)。 func TestWithinTxNilGuards(t *testing.T) { if err := (&TxRunner{}).WithinTx(context.Background(), func(*gorm.DB) error { return nil }); !stderrors.Is(err, infraerrors.ErrInternal) { t.Fatalf("DB 为 nil 应返回 ErrInternal,实得 %v", err) } if err := NewTxRunner(newFileDB(t)).WithinTx(context.Background(), nil); !stderrors.Is(err, infraerrors.ErrInternal) { t.Fatalf("fn 为 nil 应返回 ErrInternal,实得 %v", err) } } // TestInTx 事务判定(nil/非事务句柄)。 func TestInTx(t *testing.T) { if InTx(nil) { t.Fatal("InTx(nil) 应为 false") } if InTx(newDryRunDB(t)) { t.Fatal("连接池句柄不应判为事务中") } } // TestClampPage 分页钳制(默认 1/10,上限 200)。 func TestClampPage(t *testing.T) { cases := []struct { page, size, wantPage, wantSize int }{ {0, 0, 1, DefaultPageSize}, {-5, -1, 1, DefaultPageSize}, {2, 20, 2, 20}, {3, 999, 3, MaxPageSize}, } for _, tc := range cases { p, s := ClampPage(tc.page, tc.size) if p != tc.wantPage || s != tc.wantSize { t.Fatalf("ClampPage(%d,%d) = (%d,%d),期望 (%d,%d)", tc.page, tc.size, p, s, tc.wantPage, tc.wantSize) } } if got := PageOffset(3, 20); got != 40 { t.Fatalf("PageOffset(3,20) = %d,期望 40", got) } if got := PageOffset(0, 0); got != 0 { t.Fatalf("PageOffset(0,0) = %d,期望 0", got) } } // TestIsDupKey 唯一键冲突识别(MySQL 1062 / sqlite 文案)。 func TestIsDupKey(t *testing.T) { cases := []struct { name string err error want bool }{ {"MySQL 1062", stderrors.New("Error 1062 (23000): Duplicate entry 'r1-100' for key 'uk_request_type'"), true}, {"sqlite unique", stderrors.New("UNIQUE constraint failed: t_user_expense_school.user_id"), true}, {"普通错误", stderrors.New("sql: connection refused"), false}, {"nil", nil, false}, } for _, tc := range cases { if got := IsDupKey(tc.err); got != tc.want { t.Fatalf("%s: IsDupKey = %v,期望 %v", tc.name, got, tc.want) } } }