base_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. // academic repository 共享内核单测(Scoped 作用域注入 / TxRunner 事务透传 / 分页钳制 / 唯一键识别)。
  2. //
  3. // 测试栈:glebarez/sqlite(纯 Go,无 CGO,CI 的 CGO_ENABLED=0 环境同样可跑,与 infra/scope 单测一致)。
  4. // 需要"真实事务"的用例用临时文件库(t.TempDir),`:memory:` 在连接池多连接下不可共享。
  5. package repository
  6. import (
  7. "context"
  8. stderrors "errors"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. gormsqlite "github.com/glebarez/sqlite"
  13. "gorm.io/gorm"
  14. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  15. appscope "yuansiwei/unified-backend/internal/infra/scope"
  16. )
  17. // txFixture 事务测试用 fixture 表(仅需 id/name 两列)。
  18. type txFixture struct {
  19. ID int64 `gorm:"column:id;primaryKey"`
  20. Name string `gorm:"column:name;size:32"`
  21. }
  22. // TableName 指定表名。
  23. func (txFixture) TableName() string { return "t_academic_tx_fixture" }
  24. // newDryRunDB DryRun 句柄(只构造 SQL,不落库;用于断言 scoping 注入的 WHERE 片段)。
  25. func newDryRunDB(t *testing.T) *gorm.DB {
  26. t.Helper()
  27. db, err := gorm.Open(gormsqlite.Open(":memory:"), &gorm.Config{DryRun: true})
  28. if err != nil {
  29. t.Fatalf("gorm.Open(DryRun) 失败: %v", err)
  30. }
  31. return db
  32. }
  33. // newFileDB 临时文件库(支持真实事务/回滚)。
  34. func newFileDB(t *testing.T) *gorm.DB {
  35. t.Helper()
  36. dsn := filepath.Join(t.TempDir(), "academic_repo_test.db")
  37. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  38. if err != nil {
  39. t.Fatalf("gorm.Open(文件库) 失败: %v", err)
  40. }
  41. // Windows 下未关闭连接会导致 t.TempDir 清理失败(unlinkat 占用),故注册清理钩子
  42. t.Cleanup(func() {
  43. if sqlDB, e := db.DB(); e == nil {
  44. _ = sqlDB.Close()
  45. }
  46. })
  47. if err := db.AutoMigrate(&txFixture{}); err != nil {
  48. t.Fatalf("AutoMigrate 失败: %v", err)
  49. }
  50. return db
  51. }
  52. // TestScopedInjectsTenantFilters 普通用户:强制机构 + 校区过滤。
  53. func TestScopedInjectsTenantFilters(t *testing.T) {
  54. db := newDryRunDB(t)
  55. r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7})
  56. q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
  57. var out []txFixture
  58. if err := q.Find(&out).Error; err != nil {
  59. t.Fatalf("DryRun Find 失败: %v", err)
  60. }
  61. sql := q.Statement.SQL.String()
  62. if !strings.Contains(sql, "institution_id = ?") {
  63. t.Errorf("应注入 institution_id 过滤,SQL=%q", sql)
  64. }
  65. if !strings.Contains(sql, "campus_id = ?") {
  66. t.Errorf("CampusScoped=true 且 campus>0 应注入 campus_id 过滤,SQL=%q", sql)
  67. }
  68. }
  69. // TestScopedInstitutionOnly CampusScoped=false(机构级资源)→ 只注入机构过滤。
  70. func TestScopedInstitutionOnly(t *testing.T) {
  71. db := newDryRunDB(t)
  72. r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7})
  73. r.CampusScoped = false
  74. q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
  75. var out []txFixture
  76. if err := q.Find(&out).Error; err != nil {
  77. t.Fatalf("DryRun Find 失败: %v", err)
  78. }
  79. sql := q.Statement.SQL.String()
  80. if !strings.Contains(sql, "institution_id = ?") {
  81. t.Errorf("应注入 institution_id 过滤,SQL=%q", sql)
  82. }
  83. if strings.Contains(sql, "campus_id = ?") {
  84. t.Errorf("CampusScoped=false 不应注入 campus_id(否则 Unknown column),SQL=%q", sql)
  85. }
  86. }
  87. // TestScopedCampusZero Sc.CampusID=0(机构级视角)→ 不限定校区。
  88. func TestScopedCampusZero(t *testing.T) {
  89. db := newDryRunDB(t)
  90. r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 0})
  91. q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
  92. var out []txFixture
  93. if err := q.Find(&out).Error; err != nil {
  94. t.Fatalf("DryRun Find 失败: %v", err)
  95. }
  96. if sql := q.Statement.SQL.String(); strings.Contains(sql, "campus_id = ?") {
  97. t.Errorf("CampusID=0 不应注入 campus_id,SQL=%q", sql)
  98. }
  99. }
  100. // TestScopedSuperAdminBypass 超管跨机构:不注入任何作用域过滤。
  101. func TestScopedSuperAdminBypass(t *testing.T) {
  102. db := newDryRunDB(t)
  103. r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true})
  104. q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
  105. var out []txFixture
  106. if err := q.Find(&out).Error; err != nil {
  107. t.Fatalf("DryRun Find 失败: %v", err)
  108. }
  109. sql := q.Statement.SQL.String()
  110. if strings.Contains(sql, "institution_id = ?") || strings.Contains(sql, "campus_id = ?") {
  111. t.Errorf("超管不应注入作用域过滤,SQL=%q", sql)
  112. }
  113. }
  114. // TestScopedNilScopePanics 无作用域进入数据访问层 → panic(fail-fast,纵深防御)。
  115. func TestScopedNilScopePanics(t *testing.T) {
  116. db := newDryRunDB(t)
  117. r := NewBaseRepo(db, nil)
  118. defer func() {
  119. rec := recover()
  120. if rec == nil {
  121. t.Fatal("Sc 为 nil 时应 panic")
  122. }
  123. if !strings.Contains(rec.(string), "missing tenant scope") {
  124. t.Fatalf("panic 文案不符: %v", rec)
  125. }
  126. }()
  127. _ = r.Scoped(db.Session(&gorm.Session{NewDB: true}))
  128. }
  129. // TestScopedNilDBReturnsNil db 为 nil 时返回 nil(不 panic,便于装配期防御)。
  130. func TestScopedNilDBReturnsNil(t *testing.T) {
  131. r := NewBaseRepo(newDryRunDB(t), &appscope.Scope{InstitutionID: 1})
  132. if got := r.Scoped(nil); got != nil {
  133. t.Fatal("Scoped(nil) 应返回 nil")
  134. }
  135. }
  136. // TestGetScopedID 读取当前作用域的机构/校区。
  137. func TestGetScopedID(t *testing.T) {
  138. r := NewBaseRepo(nil, &appscope.Scope{InstitutionID: 42, CampusID: 7})
  139. if inst, camp := r.GetScopedID(); inst != 42 || camp != 7 {
  140. t.Fatalf("GetScopedID = (%d,%d),期望 (42,7)", inst, camp)
  141. }
  142. if inst, camp := NewBaseRepo(nil, nil).GetScopedID(); inst != 0 || camp != 0 {
  143. t.Fatalf("无作用域应返回 (0,0),实得 (%d,%d)", inst, camp)
  144. }
  145. }
  146. // TestWithinTxRollback 顶层事务:fn 返回错误 → 已写行回滚(资金场景的核心保证)。
  147. func TestWithinTxRollback(t *testing.T) {
  148. db := newFileDB(t)
  149. runner := NewTxRunner(db)
  150. boom := stderrors.New("boom")
  151. err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error {
  152. if InTx(tx) != true {
  153. t.Fatal("事务内 InTx(tx) 应为 true")
  154. }
  155. if e := tx.Create(&txFixture{ID: 1, Name: "a"}).Error; e != nil {
  156. return e
  157. }
  158. return boom
  159. })
  160. if !stderrors.Is(err, infraerrors.ErrDBError) {
  161. t.Fatalf("非业务错误应包装为 ErrDBError,实得 %v", err)
  162. }
  163. var count int64
  164. if e := db.Model(&txFixture{}).Count(&count).Error; e != nil {
  165. t.Fatalf("Count 失败: %v", e)
  166. }
  167. if count != 0 {
  168. t.Fatalf("回滚后应有 0 行,实得 %d", count)
  169. }
  170. }
  171. // TestWithinTxCommit 顶层事务:fn 返回 nil → 提交。
  172. func TestWithinTxCommit(t *testing.T) {
  173. db := newFileDB(t)
  174. runner := NewTxRunner(db)
  175. if err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error {
  176. return tx.Create(&txFixture{ID: 2, Name: "b"}).Error
  177. }); err != nil {
  178. t.Fatalf("WithinTx 失败: %v", err)
  179. }
  180. var count int64
  181. if err := db.Model(&txFixture{}).Count(&count).Error; err != nil {
  182. t.Fatalf("Count 失败: %v", err)
  183. }
  184. if count != 1 {
  185. t.Fatalf("提交后应有 1 行,实得 %d", count)
  186. }
  187. }
  188. // TestWithinTxNestedPassThrough 嵌套调用:内层**透传同一 tx,不重复 BEGIN**。
  189. //
  190. // 验证两条透传通道:
  191. // - ctx 注入(WithTxContext,事务内跨 service 调用的主通道);
  192. // - runner 绑定(WithTx,显式传 tx 的场景)。
  193. //
  194. // 并验证:内层写入后由外层回滚 → 内层数据一并丢失(说明没有独立事务提前提交)。
  195. func TestWithinTxNestedPassThrough(t *testing.T) {
  196. db := newFileDB(t)
  197. runner := NewTxRunner(db)
  198. rollback := stderrors.New("outer-rollback")
  199. err := runner.WithinTx(context.Background(), func(outerTx *gorm.DB) error {
  200. if e := outerTx.Create(&txFixture{ID: 3, Name: "outer"}).Error; e != nil {
  201. return e
  202. }
  203. // 通道 ①:ctx 内已携带 tx(fn 收到的 tx.WithContext 已注入)
  204. if e := runner.WithinTx(outerTx.Statement.Context, func(innerTx *gorm.DB) error {
  205. if innerTx.Statement.ConnPool != outerTx.Statement.ConnPool {
  206. t.Fatal("经 ctx 透传的内层应复用外层 tx 连接(未重复 BEGIN)")
  207. }
  208. return innerTx.Create(&txFixture{ID: 4, Name: "inner"}).Error
  209. }); e != nil {
  210. return e
  211. }
  212. // 通道 ②:显式 WithTx 绑定 tx
  213. if e := runner.WithTx(outerTx).WithinTx(context.Background(), func(innerTx *gorm.DB) error {
  214. if innerTx.Statement.ConnPool != outerTx.Statement.ConnPool {
  215. t.Fatal("经 WithTx 绑定的内层应复用外层 tx 连接")
  216. }
  217. return innerTx.Create(&txFixture{ID: 5, Name: "inner2"}).Error
  218. }); e != nil {
  219. return e
  220. }
  221. return rollback
  222. })
  223. if err == nil {
  224. t.Fatal("外层应返回回滚错误")
  225. }
  226. var count int64
  227. if e := db.Model(&txFixture{}).Count(&count).Error; e != nil {
  228. t.Fatalf("Count 失败: %v", e)
  229. }
  230. if count != 0 {
  231. t.Fatalf("外层回滚后内层数据也应消失(无独立事务),实得 %d 行", count)
  232. }
  233. }
  234. // TestWithinTxBusinessErrorPassthrough 业务错误(*AppError)原样上抛,不被包装成 5002。
  235. func TestWithinTxBusinessErrorPassthrough(t *testing.T) {
  236. db := newFileDB(t)
  237. runner := NewTxRunner(db)
  238. bizErr := infraerrors.New(3101, "余额不足")
  239. err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error { return bizErr })
  240. if !stderrors.Is(err, bizErr) {
  241. t.Fatalf("业务错误应原样返回,实得 %v", err)
  242. }
  243. var appErr *infraerrors.AppError
  244. if !stderrors.As(err, &appErr) || appErr.Code != 3101 {
  245. t.Fatalf("应保留 3101 业务码,实得 %v", err)
  246. }
  247. }
  248. // TestWithinTxNilGuards 装配错误防御(DB/fn 为 nil)。
  249. func TestWithinTxNilGuards(t *testing.T) {
  250. if err := (&TxRunner{}).WithinTx(context.Background(), func(*gorm.DB) error { return nil }); !stderrors.Is(err, infraerrors.ErrInternal) {
  251. t.Fatalf("DB 为 nil 应返回 ErrInternal,实得 %v", err)
  252. }
  253. if err := NewTxRunner(newFileDB(t)).WithinTx(context.Background(), nil); !stderrors.Is(err, infraerrors.ErrInternal) {
  254. t.Fatalf("fn 为 nil 应返回 ErrInternal,实得 %v", err)
  255. }
  256. }
  257. // TestInTx 事务判定(nil/非事务句柄)。
  258. func TestInTx(t *testing.T) {
  259. if InTx(nil) {
  260. t.Fatal("InTx(nil) 应为 false")
  261. }
  262. if InTx(newDryRunDB(t)) {
  263. t.Fatal("连接池句柄不应判为事务中")
  264. }
  265. }
  266. // TestClampPage 分页钳制(默认 1/10,上限 200)。
  267. func TestClampPage(t *testing.T) {
  268. cases := []struct {
  269. page, size, wantPage, wantSize int
  270. }{
  271. {0, 0, 1, DefaultPageSize},
  272. {-5, -1, 1, DefaultPageSize},
  273. {2, 20, 2, 20},
  274. {3, 999, 3, MaxPageSize},
  275. }
  276. for _, tc := range cases {
  277. p, s := ClampPage(tc.page, tc.size)
  278. if p != tc.wantPage || s != tc.wantSize {
  279. t.Fatalf("ClampPage(%d,%d) = (%d,%d),期望 (%d,%d)", tc.page, tc.size, p, s, tc.wantPage, tc.wantSize)
  280. }
  281. }
  282. if got := PageOffset(3, 20); got != 40 {
  283. t.Fatalf("PageOffset(3,20) = %d,期望 40", got)
  284. }
  285. if got := PageOffset(0, 0); got != 0 {
  286. t.Fatalf("PageOffset(0,0) = %d,期望 0", got)
  287. }
  288. }
  289. // TestIsDupKey 唯一键冲突识别(MySQL 1062 / sqlite 文案)。
  290. func TestIsDupKey(t *testing.T) {
  291. cases := []struct {
  292. name string
  293. err error
  294. want bool
  295. }{
  296. {"MySQL 1062", stderrors.New("Error 1062 (23000): Duplicate entry 'r1-100' for key 'uk_request_type'"), true},
  297. {"sqlite unique", stderrors.New("UNIQUE constraint failed: t_user_expense_school.user_id"), true},
  298. {"普通错误", stderrors.New("sql: connection refused"), false},
  299. {"nil", nil, false},
  300. }
  301. for _, tc := range cases {
  302. if got := IsDupKey(tc.err); got != tc.want {
  303. t.Fatalf("%s: IsDupKey = %v,期望 %v", tc.name, got, tc.want)
  304. }
  305. }
  306. }