| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- // 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)
- }
- }
- }
|