bill_repo_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. // BillRepo 单测(AC-10):4 张来源表的取数口径 / `BETWEEN` 含右端 / 分页稳定性 / 参数守卫。
  2. //
  3. // 为什么仓储层要单独测"BETWEEN 含右端":这是 dump 里**最容易被"顺手统一"掉**的一处
  4. // (期末余额用 `< to`、充值工单用 `<= to`)。Service 用例只覆盖"有一条恰好落在 to 的流水"
  5. // 的单一路径,仓储用例把 `response_time = to` 这一格单独钉住——一旦有人把 `<=` 改成 `<`,
  6. // 赠送额会**静默少算**,而账单恒等式(只算 balance 类流水)依然成立,抓不到。
  7. package repository
  8. import (
  9. "context"
  10. "path/filepath"
  11. "testing"
  12. "time"
  13. gormsqlite "github.com/glebarez/sqlite"
  14. "gorm.io/gorm"
  15. "yuansiwei/unified-backend/internal/academic/model"
  16. "yuansiwei/unified-backend/internal/academic/money"
  17. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  18. )
  19. // newBillRepoDB 账单 5 表文件库(流水 / 工单 / 报名 / used_log / 全局钱包)。
  20. func newBillRepoDB(t *testing.T) *gorm.DB {
  21. t.Helper()
  22. dsn := filepath.Join(t.TempDir(), "academic_bill_repo_test.db")
  23. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  24. if err != nil {
  25. t.Fatalf("gorm.Open(账单文件库) 失败: %v", err)
  26. }
  27. t.Cleanup(func() {
  28. if sqlDB, e := db.DB(); e == nil {
  29. _ = sqlDB.Close()
  30. }
  31. })
  32. migrateTablesDroppingPlainIndexes(t, db,
  33. &model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{},
  34. &model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{},
  35. )
  36. return db
  37. }
  38. // TestBillRepoLoadRechargeRequestNosBetweenInclusive `response_time BETWEEN` 必须**含右端**,
  39. // 且只取 `approved=1 AND type=100`。
  40. func TestBillRepoLoadRechargeRequestNosBetweenInclusive(t *testing.T) {
  41. db := newBillRepoDB(t)
  42. ctx := context.Background()
  43. userID := "01HUSER000000000000000901"
  44. from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  45. to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  46. at := func(day int, hour int) *time.Time {
  47. v := time.Date(2026, 3, day, hour, 0, 0, 0, time.UTC)
  48. return &v
  49. }
  50. rows := []model.UserBalanceRequest{
  51. {BizNo: "IN-FROM", Type: model.BalanceRequestTypeRecharge, UserID: userID,
  52. Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(1, 0)}, // 恰在 from(含)
  53. {BizNo: "IN-TO", Type: model.BalanceRequestTypeRecharge, UserID: userID,
  54. Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(to)}, // 恰在 to(**必须含**)
  55. {BizNo: "BEFORE", Type: model.BalanceRequestTypeRecharge, UserID: userID,
  56. Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(time.Date(2026, 2, 28, 23, 59, 0, 0, time.UTC))},
  57. {BizNo: "AFTER", Type: model.BalanceRequestTypeRecharge, UserID: userID,
  58. Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(to.Add(time.Second))},
  59. {BizNo: "PENDING", Type: model.BalanceRequestTypeRecharge, UserID: userID,
  60. Approved: model.BalanceRequestApprovedPending, ResponseTime: at(10, 8)}, // 未通过 → 排除
  61. {BizNo: "BADDEBT", Type: model.BalanceRequestTypeBadDebt, UserID: userID,
  62. Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(10, 9)}, // 非 100 → 排除
  63. {BizNo: "OTHER-USER", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER000000000000000902",
  64. Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(10, 10)}, // 他人 → 排除
  65. {BizNo: "NO-TIME", Type: model.BalanceRequestTypeRecharge, UserID: userID,
  66. Approved: model.BalanceRequestApprovedPassed, ResponseTime: nil}, // response_time 空 → 排除
  67. }
  68. if err := db.Create(&rows).Error; err != nil {
  69. t.Fatalf("seed 充值工单失败: %v", err)
  70. }
  71. repo := NewBillRepo(db)
  72. nos, err := repo.LoadRechargeRequestNos(ctx, userID, from, to)
  73. if err != nil {
  74. t.Fatalf("LoadRechargeRequestNos 失败: %v", err)
  75. }
  76. got := map[string]bool{}
  77. for _, no := range nos {
  78. got[no] = true
  79. }
  80. want := []string{"IN-FROM", "IN-TO"}
  81. if len(nos) != len(want) {
  82. t.Fatalf("命中工单 = %v,期望 %v(BETWEEN 含两端)", nos, want)
  83. }
  84. for _, no := range want {
  85. if !got[no] {
  86. t.Fatalf("缺少工单 %s(命中 %v)", no, nos)
  87. }
  88. }
  89. for _, no := range []string{"BEFORE", "AFTER", "PENDING", "BADDEBT", "OTHER-USER", "NO-TIME"} {
  90. if got[no] {
  91. t.Fatalf("工单 %s 不应命中(命中 %v)", no, nos)
  92. }
  93. }
  94. }
  95. // TestBillRepoLoadLogsAndPageOrdering 全量取数 vs 窗口内分页(两者作用域不同,必须都对)。
  96. //
  97. // v1.4 起:`LoadLogs` 仍是**全量**(dump 的 `temp_user_expense_log` 等价物,供汇总用),
  98. // 而 `PageLogs` / `CountLogs` **按窗口过滤**(`from <= create_time < to`,右开)。
  99. // 本用例同时钉住这两条,防止"为了明细过滤顺手把全量也裁了"(那会把期初算错)。
  100. func TestBillRepoLoadLogsAndPageOrdering(t *testing.T) {
  101. db := newBillRepoDB(t)
  102. ctx := context.Background()
  103. userID := "01HUSER000000000000000910"
  104. // 同一秒两行:分页必须靠 id DESC 兜底(否则翻页会重复/漏行)
  105. same := time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC)
  106. logs := []model.UserExpenseLog{
  107. {UserID: userID, Type: model.ExpenseLogTypeRecharge, Money: "100.00", CreateTime: same},
  108. {UserID: userID, Type: model.ExpenseLogTypeRecommend, Money: "10.00", CreateTime: same},
  109. {UserID: userID, Type: model.ExpenseLogTypeCourseCampus, Money: "-5.00",
  110. CreateTime: time.Date(2026, 3, 6, 9, 0, 0, 0, time.UTC)},
  111. }
  112. if err := db.Create(&logs).Error; err != nil {
  113. t.Fatalf("seed 流水失败: %v", err)
  114. }
  115. repo := NewBillRepo(db)
  116. all, err := repo.LoadLogs(ctx, userID)
  117. if err != nil {
  118. t.Fatalf("LoadLogs 失败: %v", err)
  119. }
  120. if len(all) != 3 {
  121. t.Fatalf("全量流水 = %d 条,期望 3(**不带时间/类型过滤**,同 dump 临时表)", len(all))
  122. }
  123. from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  124. to := time.Date(2026, 3, 6, 9, 0, 0, 0, time.UTC) // 右开:03-06 09:00 那笔**恰好落在 to**,应被排除
  125. total, err := repo.CountLogs(ctx, userID, from, to)
  126. if err != nil {
  127. t.Fatalf("CountLogs 失败: %v", err)
  128. }
  129. if total != 2 {
  130. t.Fatalf("CountLogs = %d,期望 2(窗口内 2 笔;右端点那笔被 `create_time < to` 排除)", total)
  131. }
  132. page1, err := repo.PageLogs(ctx, userID, from, to, PageQuery{Page: 1, PageSize: 2})
  133. if err != nil {
  134. t.Fatalf("PageLogs(1) 失败: %v", err)
  135. }
  136. page2, err := repo.PageLogs(ctx, userID, from, to, PageQuery{Page: 2, PageSize: 2})
  137. if err != nil {
  138. t.Fatalf("PageLogs(2) 失败: %v", err)
  139. }
  140. if len(page1) != 2 || len(page2) != 0 {
  141. t.Fatalf("分页条数 = %d/%d,期望 2/0(窗口内只有 2 笔;右端点那笔已被排除)", len(page1), len(page2))
  142. }
  143. // 同一秒两行按 id DESC:第 1 页首行应是 id 更大的 107,次行是 100
  144. if page1[0].Type != model.ExpenseLogTypeRecommend || page1[1].Type != model.ExpenseLogTypeRecharge {
  145. t.Fatalf("同一秒内排序不稳定:page1=%+v", page1)
  146. }
  147. // 窗口外(恰好等于 to)那笔不得出现在明细
  148. for _, row := range page1 {
  149. if !row.CreateTime.Before(to) {
  150. t.Fatalf("明细含右端点流水:%+v", row)
  151. }
  152. }
  153. }
  154. // TestBillRepoActivityLoaders 报名 / used_log 均**不带时间过滤**(时间窗在 Service 侧做)。
  155. func TestBillRepoActivityLoaders(t *testing.T) {
  156. db := newBillRepoDB(t)
  157. ctx := context.Background()
  158. userID := "01HUSER000000000000000920"
  159. no := "REQ-X"
  160. if err := db.Create(&model.CoursesActivitySigned{
  161. ActivityID: 91, UserID: userID, ActivityMoney: "40.00", RequestNo: &no,
  162. CreateTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
  163. }).Error; err != nil {
  164. t.Fatalf("seed 报名失败: %v", err)
  165. }
  166. if err := db.Create(&model.CoursesActivityUsedLog{
  167. ActivityID: 91, UserID: userID, ActivityMoneyUsed: "-15.00",
  168. CreateTime: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
  169. }).Error; err != nil {
  170. t.Fatalf("seed used_log 失败: %v", err)
  171. }
  172. repo := NewBillRepo(db)
  173. signed, err := repo.LoadActivitySigned(ctx, userID)
  174. if err != nil {
  175. t.Fatalf("LoadActivitySigned 失败: %v", err)
  176. }
  177. if len(signed) != 1 || !sameRepoAmount(t, signed[0].ActivityMoney, "40.00") {
  178. t.Fatalf("报名行 = %+v,期望 1 条 40.00", signed)
  179. }
  180. if signed[0].RequestNo == nil || *signed[0].RequestNo != no {
  181. t.Fatalf("报名行的 request_no 未取出:%+v", signed[0])
  182. }
  183. used, err := repo.LoadActivityUsedLogs(ctx, userID)
  184. if err != nil {
  185. t.Fatalf("LoadActivityUsedLogs 失败: %v", err)
  186. }
  187. if len(used) != 1 || !sameRepoAmount(t, used[0].ActivityMoneyUsed, "-15.00") {
  188. t.Fatalf("used_log = %+v,期望 1 条 -15.00", used)
  189. }
  190. }
  191. // TestBillRepoParamGuards 空 user / from>to 的守卫(禁止"看起来筛了其实没筛"的静默查询)。
  192. func TestBillRepoParamGuards(t *testing.T) {
  193. db := newBillRepoDB(t)
  194. ctx := context.Background()
  195. repo := NewBillRepo(db)
  196. from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  197. to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  198. _, err := repo.LoadLogs(ctx, "")
  199. wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadLogs 空 user")
  200. _, err = repo.PageLogs(ctx, "", from, to, PageQuery{})
  201. wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs 空 user")
  202. _, err = repo.CountLogs(ctx, "", from, to)
  203. wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs 空 user")
  204. // from>to:分页与计数都必须拒绝(否则会静默返回空集,掩盖调用方的时间窗写反)
  205. _, err = repo.PageLogs(ctx, "01HUSER000000000000000930", to, from, PageQuery{})
  206. wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs from 晚于 to")
  207. _, err = repo.CountLogs(ctx, "01HUSER000000000000000930", to, from)
  208. wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs from 晚于 to")
  209. _, err = repo.LoadActivitySigned(ctx, "")
  210. wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivitySigned 空 user")
  211. _, err = repo.LoadActivityUsedLogs(ctx, "")
  212. wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivityUsedLogs 空 user")
  213. _, err = repo.LoadRechargeRequestNos(ctx, "", time.Now(), time.Now())
  214. wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadRechargeRequestNos 空 user")
  215. _, err = repo.LoadRechargeRequestNos(ctx, "01HUSER000000000000000930",
  216. time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC))
  217. wantRepoErr(t, err, infraerrors.CodeBadParam, "from 晚于 to")
  218. }
  219. // timePtr 时间指针构造(time.Time 不可寻址,取址需经变量)。
  220. func timePtr(v time.Time) *time.Time { return &v }
  221. // sameRepoAmount 金额语义比较(sqlite 的 NUMERIC 亲和性会把 "40.00" 读回 "40",
  222. // MySQL 读回 "40.00"——两者定点值相同,故按 money.Amount 比较,不做字符串比)。
  223. func sameRepoAmount(t *testing.T, got, want string) bool {
  224. t.Helper()
  225. g, gerr := money.Parse(got)
  226. w, werr := money.Parse(want)
  227. if gerr != nil || werr != nil {
  228. t.Fatalf("金额解析失败: got=%q(%v) want=%q(%v)", got, gerr, want, werr)
  229. }
  230. return g == w
  231. }