expense_repo_columns_test.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. // ExpenseRepo 新增原语单测(AC-04/AC-05 依赖):多列原子增减 / 精确机构+校区定位 /
  2. // 幂等键流水查询。
  3. //
  4. // 测试栈(与 AC-03 既有 expense_repo_test.go 同一套手法,助手直接复用):
  5. // - **SQL 形状(UPDATE)**:真实 sqlite 库 + SQL 抓取器 —— `UpdateColumns` 在 DryRun 下
  6. // 仍会走 ConnPool,故 UPDATE 形状必须在**真实可执行的库**上抓(查询类才用 MySQL DryRun);
  7. // - **SQL 形状(查询)**:MySQL 方言 DryRun(sqlite 不渲染行锁 clause,只有 MySQL 方言能证明
  8. // `FOR UPDATE` 真进了 SQL);
  9. // - **行为**:sqlite 文件库(原子表达式结果 / 账户缺失 / 列名白名单 / 幂等查询命中语义)。
  10. package repository
  11. import (
  12. "context"
  13. "errors"
  14. "strings"
  15. "testing"
  16. "gorm.io/gorm"
  17. "yuansiwei/unified-backend/internal/academic/model"
  18. "yuansiwei/unified-backend/internal/academic/money"
  19. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  20. appscope "yuansiwei/unified-backend/internal/infra/scope"
  21. )
  22. // TestAddColumnsGlobalThenCampusSQL 多列增减必须**每表一条** UPDATE,且全局在前、校区带机构过滤。
  23. func TestAddColumnsGlobalThenCampusSQL(t *testing.T) {
  24. db, capture := newExpenseRepoDBWithCapture(t)
  25. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
  26. ctx := context.Background()
  27. user := "01HUSER000000000000000901"
  28. // 先建户(否则原子语句返回 0 行 → ErrAccountNotFound)
  29. if err := db.Create(&model.UserExpense{UserID: user}).Error; err != nil {
  30. t.Fatalf("seed 全局失败: %v", err)
  31. }
  32. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2}).Error; err != nil {
  33. t.Fatalf("seed 校区失败: %v", err)
  34. }
  35. capture.Reset()
  36. if err := db.Transaction(func(tx *gorm.DB) error {
  37. _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "TestSQL", user, 1, 2,
  38. ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(-1050)},
  39. ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(-1050)},
  40. )
  41. return e
  42. }); err != nil {
  43. t.Fatalf("多列增减失败: %v", err)
  44. }
  45. stmts := capture.All()
  46. if len(stmts) != 2 {
  47. t.Fatalf("应只产生 2 条 UPDATE(全局 + 校区),实得 %d 条:%v", len(stmts), stmts)
  48. }
  49. first := compactSQL(stmts[0])
  50. second := compactSQL(stmts[1])
  51. if !strings.Contains(first, "UPDATE`t_user_expense`SET") {
  52. t.Fatalf("第 1 条必须是全局钱包 UPDATE(顺序=死锁防线),实得:%s", first)
  53. }
  54. if strings.Contains(first, "SELECT") || strings.Contains(second, "SELECT") {
  55. t.Fatalf("多列增减不得退化为读-改-写(禁止 SELECT):%v", stmts)
  56. }
  57. // 两列必须合并在同一条 UPDATE(拆开会产生"余额已减、总充值未减"的中间态)
  58. // 断言 `=balance+` / `=total_income+`(跳过方言的标识符引号差异:sqlite 用反引号、MySQL 也是)
  59. if !strings.Contains(first, "=balance+") || !strings.Contains(first, "=total_income+") {
  60. t.Fatalf("全局 UPDATE 必须用原子表达式并合并两列:%s", first)
  61. }
  62. if !strings.Contains(second, "UPDATE`t_user_expense_school`SET") {
  63. t.Fatalf("第 2 条必须是校区账户 UPDATE,实得:%s", second)
  64. }
  65. if !strings.Contains(second, "institution_id=1") || !strings.Contains(second, "campus_id=2") {
  66. t.Fatalf("校区 UPDATE 必须按机构+校区精确定位(BUG#2 同类缺陷):%s", second)
  67. }
  68. }
  69. // TestAddCampusColumnsNeverTouchesGlobal 转结专用原语:只动校区表,全局表不出现。
  70. func TestAddCampusColumnsNeverTouchesGlobal(t *testing.T) {
  71. db, capture := newExpenseRepoDBWithCapture(t)
  72. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
  73. user := "01HUSER000000000000000902"
  74. if err := db.Create(&model.UserExpense{UserID: user}).Error; err != nil {
  75. t.Fatalf("seed 全局失败: %v", err)
  76. }
  77. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2}).Error; err != nil {
  78. t.Fatalf("seed 校区失败: %v", err)
  79. }
  80. capture.Reset()
  81. if err := db.Transaction(func(tx *gorm.DB) error {
  82. _, e := repo.AddCampusColumns(context.Background(), tx, "TestCampusOnly", user, 1, 2,
  83. ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(500)})
  84. return e
  85. }); err != nil {
  86. t.Fatalf("校区单表增减失败: %v", err)
  87. }
  88. stmts := capture.All()
  89. if len(stmts) != 1 {
  90. t.Fatalf("应只产生 1 条 UPDATE,实得 %d 条:%v", len(stmts), stmts)
  91. }
  92. if strings.Contains(compactSQL(stmts[0]), "UPDATE`t_user_expense`SET") {
  93. t.Fatalf("转结原语不得更新全局钱包(dump:余额不动、全局不参与):%s", stmts[0])
  94. }
  95. }
  96. // TestGetCampusForUpdateExactSQL MySQL 方言必须渲染 FOR UPDATE,且**不叠** Sc.CampusID 等值过滤。
  97. func TestGetCampusForUpdateExactSQL(t *testing.T) {
  98. mdb, capture := newMySQLDryRunDB(t)
  99. // 作用域校区=99(故意与入参校区=2 不同):证明跨校区操作不被 Sc.CampusID 卡死
  100. repo := NewExpenseRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 99})
  101. capture.Reset()
  102. _, _ = repo.GetCampusForUpdateExact(context.Background(), mdb, "01HUSER000000000000000903", 1, 2)
  103. raw := capture.Last()
  104. sql := compactSQL(raw)
  105. if !strings.Contains(sql, "FORUPDATE") {
  106. t.Fatalf("精确校区加锁必须渲染 FOR UPDATE:%q", sql)
  107. }
  108. if !strings.Contains(sql, "institution_id=1") || !strings.Contains(sql, "campus_id=2") {
  109. t.Fatalf("必须按机构 + 入参校区精确定位:%q", sql)
  110. }
  111. if strings.Contains(sql, "campus_id=99") {
  112. t.Fatalf("不得叠加作用域校区等值过滤(跨校区转结会被卡死):%q", sql)
  113. }
  114. }
  115. // TestAddColumnsGlobalThenCampusBehaviour sqlite 行为:两表同步增减 + 账户缺失报错 + 全零 no-op + 列名白名单。
  116. func TestAddColumnsGlobalThenCampusBehaviour(t *testing.T) {
  117. db := newExpenseRepoDB(t)
  118. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
  119. ctx := context.Background()
  120. user := "01HUSER000000000000000904"
  121. if err := db.Create(&model.UserExpense{UserID: user, Balance: "100.00", TotalIncome: "100.00"}).Error; err != nil {
  122. t.Fatalf("seed 全局失败: %v", err)
  123. }
  124. if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2,
  125. Balance: "100.00", TotalIncome: "100.00"}).Error; err != nil {
  126. t.Fatalf("seed 校区失败: %v", err)
  127. }
  128. if err := db.Transaction(func(tx *gorm.DB) error {
  129. _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "Behaviour", user, 1, 2,
  130. ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(-2500)},
  131. ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(-2500)},
  132. ColumnDelta{Column: ExpenseColumnSpend, Delta: 0}, // 零增量列必须被跳过
  133. )
  134. return e
  135. }); err != nil {
  136. t.Fatalf("多列增减失败: %v", err)
  137. }
  138. global, err := repo.GetGlobalAccount(ctx, nil, user)
  139. if err != nil {
  140. t.Fatalf("读全局账户失败: %v", err)
  141. }
  142. if parseAmount(t, global.Balance) != 7500 || parseAmount(t, global.TotalIncome) != 7500 {
  143. t.Fatalf("全局两列应各 7500 分,实得 %s/%s", global.Balance, global.TotalIncome)
  144. }
  145. school, err := repo.GetCampusAccount(ctx, nil, user, 1, 2)
  146. if err != nil {
  147. t.Fatalf("读校区账户失败: %v", err)
  148. }
  149. if parseAmount(t, school.Balance) != 7500 {
  150. t.Fatalf("校区 balance 应 7500 分,实得 %s", school.Balance)
  151. }
  152. // 校区账户缺失 → ErrAccountNotFound(不静默跳过,否则"全局扣了、校区没扣")
  153. if err := db.Transaction(func(tx *gorm.DB) error {
  154. _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "MissingCampus", user, 1, 77,
  155. ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(100)})
  156. return e
  157. }); !errors.Is(err, ErrAccountNotFound) {
  158. t.Fatalf("校区账户缺失应返回 ErrAccountNotFound,实得: %v", err)
  159. }
  160. // 全部增量为 0 → no-op(不报错、不产生 SQL)
  161. err = db.Transaction(func(tx *gorm.DB) error {
  162. rows, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "Noop", user, 1, 2,
  163. ColumnDelta{Column: ExpenseColumnBalance, Delta: 0})
  164. if e != nil {
  165. return e
  166. }
  167. if rows != 0 {
  168. t.Fatalf("全零增量应 no-op,实得 rows=%d", rows)
  169. }
  170. return nil
  171. })
  172. if err != nil {
  173. t.Fatalf("全零增量不应报错,实得: %v", err)
  174. }
  175. // 非法列名 → ErrInternal(列名白名单,防注入)
  176. if err := db.Transaction(func(tx *gorm.DB) error {
  177. _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "BadColumn", user, 1, 2,
  178. ColumnDelta{Column: "balance); DROP TABLE t_user_expense; --", Delta: money.Amount(1)})
  179. return e
  180. }); !errors.Is(err, infraerrors.ErrInternal) {
  181. t.Fatalf("非法列名应返回 ErrInternal,实得: %v", err)
  182. }
  183. }
  184. // TestFindLogByRequest 幂等键查询:命中 / type 不同不命中 / 空 requestID 不享幂等。
  185. func TestFindLogByRequest(t *testing.T) {
  186. db := newExpenseRepoDB(t)
  187. repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
  188. ctx := context.Background()
  189. user := "01HUSER000000000000000905"
  190. reqID := "REQ-FIND-1"
  191. row := &model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
  192. Money: "10.00", RequestID: &reqID}
  193. if err := db.Create(row).Error; err != nil {
  194. t.Fatalf("seed 流水失败: %v", err)
  195. }
  196. hit, err := repo.FindLogByRequest(ctx, nil, user, reqID, model.ExpenseLogTypeRecharge)
  197. if err != nil || hit == nil || hit.ID != row.ID {
  198. t.Fatalf("命中的流水应为 id=%d,实得 (%+v, %v)", row.ID, hit, err)
  199. }
  200. // type 不同 → 不命中(type 参与唯一键)
  201. if hit, err := repo.FindLogByRequest(ctx, nil, user, reqID, model.ExpenseLogTypeRefund); err != nil || hit != nil {
  202. t.Fatalf("不同类型不应命中,实得 (%+v, %v)", hit, err)
  203. }
  204. // 空 requestID → 恒 (nil,nil):NULL 不享幂等(设计 §1.5 缺省)
  205. if hit, err := repo.FindLogByRequest(ctx, nil, user, "", model.ExpenseLogTypeRecharge); err != nil || hit != nil {
  206. t.Fatalf("空 requestID 应返回 (nil,nil),实得 (%+v, %v)", hit, err)
  207. }
  208. }