activity_discount_repo_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // ActivityDiscountRepo 单测(AC-06):可用额口径 / dump 3 参汇总 / 原子增减 / 批量单语句 CASE。
  2. package repository
  3. import (
  4. "context"
  5. "errors"
  6. "strings"
  7. "testing"
  8. "time"
  9. "gorm.io/gorm"
  10. "yuansiwei/unified-backend/internal/academic/model"
  11. "yuansiwei/unified-backend/internal/academic/money"
  12. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  13. appscope "yuansiwei/unified-backend/internal/infra/scope"
  14. )
  15. // seedPool 插入一条赠送金额池(显式 ID)。
  16. func seedPool(t *testing.T, db *gorm.DB, id, inst, campus int64, userID, discount, used string) {
  17. t.Helper()
  18. row := &model.CoursesActivityUserDiscount{
  19. ID: id,
  20. ActivityID: 1,
  21. InstitutionID: inst,
  22. CampusID: campus,
  23. UserID: userID,
  24. Income: "0.00",
  25. DiscountAmount: discount,
  26. DiscountAmountUsed: used,
  27. CreateTime: time.Now(),
  28. }
  29. if err := db.Create(row).Error; err != nil {
  30. t.Fatalf("seed pool(%d) 失败: %v", id, err)
  31. }
  32. }
  33. // amountOf 读取池的「已使用金额」(money 归类,规避 sqlite NUMERIC 回读差异)。
  34. func amountOf(t *testing.T, db *gorm.DB, id int64) money.Amount {
  35. t.Helper()
  36. var row model.CoursesActivityUserDiscount
  37. if err := db.Where("id = ?", id).First(&row).Error; err != nil {
  38. t.Fatalf("读池(%d) 失败: %v", id, err)
  39. }
  40. amt, err := money.Parse(row.DiscountAmountUsed)
  41. if err != nil {
  42. t.Fatalf("解析 used(%d) 失败: %v", id, err)
  43. }
  44. return amt
  45. }
  46. // TestListUsableFilterAndOrder 可用口径(discount > used)+ 活动维度 + 游标方向。
  47. func TestListUsableFilterAndOrder(t *testing.T) {
  48. db := newActivityRepoDB(t)
  49. user := "01HUSER000000000000000011"
  50. seedPool(t, db, 1, 1, 2, user, "10.00", "0.00") // 可用
  51. seedPool(t, db, 2, 1, 2, user, "20.00", "20.00") // 用尽:不可用
  52. seedPool(t, db, 3, 1, 2, user, "-5.00", "0.00") // 负赠送:不可用
  53. seedPool(t, db, 4, 1, 2, user, "7.50", "2.50") // 可用
  54. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  55. ctx := context.Background()
  56. asc, err := repo.ListUsable(ctx, user, 0, OrderAsc)
  57. if err != nil {
  58. t.Fatalf("ListUsable(asc) 失败: %v", err)
  59. }
  60. if len(asc) != 2 || asc[0].ID != 1 || asc[1].ID != 4 {
  61. t.Fatalf("可用行应为 {1,4}(升序),实得 %+v", asc)
  62. }
  63. desc, err := repo.ListUsable(ctx, user, 0, OrderDesc)
  64. if err != nil {
  65. t.Fatalf("ListUsable(desc) 失败: %v", err)
  66. }
  67. if len(desc) != 2 || desc[0].ID != 4 || desc[1].ID != 1 {
  68. t.Fatalf("可用行应为 {4,1}(降序),实得 %+v", desc)
  69. }
  70. if _, err := repo.ListUsable(ctx, "", 0, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
  71. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  72. }
  73. if _, err := repo.ListUsable(ctx, user, 0, "desc,1;--"); !errors.Is(err, infraerrors.ErrBadParam) {
  74. t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
  75. }
  76. if _, err := repo.ListUsable(ctx, user, -1, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
  77. t.Fatalf("activity_id<0 应 ErrBadParam,实得 %v", err)
  78. }
  79. // activityID>0 只取该活动(seedPool 里全是活动 1)
  80. onlyOne, err := repo.ListUsable(ctx, user, 1, OrderAsc)
  81. if err != nil {
  82. t.Fatalf("ListUsable(activity=1) 失败: %v", err)
  83. }
  84. if len(onlyOne) != 2 {
  85. t.Fatalf("活动 1 可用行应为 2,实得 %d", len(onlyOne))
  86. }
  87. none, err := repo.ListUsable(ctx, user, 2, OrderAsc)
  88. if err != nil {
  89. t.Fatalf("ListUsable(activity=2) 失败: %v", err)
  90. }
  91. if len(none) != 0 {
  92. t.Fatalf("活动 2 应无可用行(活动维度隔离),实得 %d", len(none))
  93. }
  94. }
  95. // TestListUsableActivityIsolation A 活动的扣减不影响 B 活动的池(audit A8,dump 763216)。
  96. //
  97. // 若 ListUsable 漏了 `activity_id = ?`(或不传 activityID),扣 A 活动时会按 id 升序先吃光 B 活动的池
  98. // → 用户"用 A 的赠送额度却扣了 B 的池",且退费时退回对象错位。本用例是该缺陷的回归门禁。
  99. func TestListUsableActivityIsolation(t *testing.T) {
  100. db := newActivityRepoDB(t)
  101. user := "01HUSER000000000000000021"
  102. // 活动 1 的池 id=1(后建),活动 2 的池 id=2(id 更大)——故意让 id 序与活动序相反
  103. if err := db.Create(&model.CoursesActivityUserDiscount{
  104. ID: 1, ActivityID: 1, InstitutionID: 1, CampusID: 2, UserID: user,
  105. Income: "0.00", DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
  106. }).Error; err != nil {
  107. t.Fatalf("seed 池 1 失败: %v", err)
  108. }
  109. if err := db.Create(&model.CoursesActivityUserDiscount{
  110. ID: 2, ActivityID: 2, InstitutionID: 1, CampusID: 2, UserID: user,
  111. Income: "0.00", DiscountAmount: "20.00", DiscountAmountUsed: "0.00",
  112. }).Error; err != nil {
  113. t.Fatalf("seed 池 2 失败: %v", err)
  114. }
  115. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  116. ctx := context.Background()
  117. rows, err := repo.ListUsable(ctx, user, 2, OrderAsc)
  118. if err != nil {
  119. t.Fatalf("ListUsable(activity=2) 失败: %v", err)
  120. }
  121. if len(rows) != 1 || rows[0].ID != 2 {
  122. t.Fatalf("活动 2 只应命中池 2,实得 %+v", rows)
  123. }
  124. if _, err := repo.IncUsed(ctx, db, 2, money.FromCents(2000)); err != nil {
  125. t.Fatalf("扣减活动 2 的池失败: %v", err)
  126. }
  127. if got := amountOf(t, db, 1); got != 0 {
  128. t.Fatalf("活动 1 的池不得被活动 2 的扣减影响,实得 %s", got.String())
  129. }
  130. }
  131. // TestSumUsableThreeParamScoping dump usable 3 参**等值且 campus 嵌套在 school 分支内**口径
  132. // (audit A7 二次修正,dump 763181-763185 逐字):
  133. //
  134. // IF inputSchoolid>0 THEN selectCondition = " AND `schoolid`=… AND `campusid`=…;
  135. // ELSE selectCondition = ''; END IF;
  136. //
  137. // 两条推论(本用例的门禁点):
  138. // 1. `campusid=0` **不是"不限校区"**,而是等值匹配 `campus_id=0` 的"机构级池";
  139. // 2. `schoolid<=0` 时**一条作用域条件都不加**,传入的 `campusid` 被**忽略**(dump 无"只按校区筛"路径)。
  140. //
  141. // 池清单(用户 user,可用 = discount - used):
  142. //
  143. // pool1 inst1 campus2 可用 10 pool2 inst1 campus0 可用 15(机构级池)
  144. // pool3 inst1 campus3 可用 100 pool4 inst2 campus2 可用 50
  145. // pool5 inst1 campus2 可用 0(用尽) pool6 他人 —— 用户过滤永远排除
  146. func TestSumUsableThreeParamScoping(t *testing.T) {
  147. db := newActivityRepoDB(t)
  148. user := "01HUSER000000000000000012"
  149. seedPool(t, db, 1, 1, 2, user, "10.00", "0.00")
  150. seedPool(t, db, 2, 1, 0, user, "20.00", "5.00")
  151. seedPool(t, db, 3, 1, 3, user, "100.00", "0.00")
  152. seedPool(t, db, 4, 2, 2, user, "50.00", "0.00")
  153. seedPool(t, db, 5, 1, 2, user, "5.00", "5.00")
  154. seedPool(t, db, 6, 1, 2, "01HOTHER00000000000001", "999.00", "0.00")
  155. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  156. ctx := context.Background()
  157. // ① (inst=1, campus=2) → 两个等值条件 → pool1(10) + pool5(0) = 10.00(**不含**机构级池 15)
  158. if got, err := repo.SumUsable(ctx, user, 1, 2); err != nil || got.Cents() != 1000 {
  159. t.Fatalf("SumUsable(1,2) 应为 10.00(等值口径,不含 campus=0 池),实得 %s err=%v", got.String(), err)
  160. }
  161. // ② (inst=1, campus=0) → `campus_id = 0` **等值** → 只有机构级池 pool2 = 15.00
  162. // (旧期望 125.00 是照"campusID=0 即不筛"的错误实现写的,dump 无此语义)
  163. if got, err := repo.SumUsable(ctx, user, 1, 0); err != nil || got.Cents() != 1500 {
  164. t.Fatalf("SumUsable(1,0) 应为 15.00(只命中 campus_id=0 的机构级池),实得 %s err=%v", got.String(), err)
  165. }
  166. // ③ (0,0) 平台视角:selectCondition='' → 不过滤机构/校区 → 10+15+100+50 = 175.00
  167. if got, err := repo.SumUsable(ctx, user, 0, 0); err != nil || got.Cents() != 17500 {
  168. t.Fatalf("SumUsable(0,0) 平台视角应为 175.00,实得 %s err=%v", got.String(), err)
  169. }
  170. // ④ (0,2) 平台视角下 **campusID 被忽略**(campusid 只出现在 school 分支内)→ 同 ③ = 175.00
  171. // (旧期望 60.00 是"单独按校区过滤"的错误实现;dump 里没有这条路径)
  172. if got, err := repo.SumUsable(ctx, user, 0, 2); err != nil || got.Cents() != 17500 {
  173. t.Fatalf("SumUsable(0,2) 应忽略 campusID → 175.00,实得 %s err=%v", got.String(), err)
  174. }
  175. // ⑤ 他机构等值:inst=2 且 campus=2 → pool4 = 50.00
  176. // (越权防线已按裁定移到 Handler 角色白名单,仓储不再拦)
  177. if got, err := repo.SumUsable(ctx, user, 2, 2); err != nil || got.Cents() != 5000 {
  178. t.Fatalf("SumUsable(2,2) 应为 50.00,实得 %s err=%v", got.String(), err)
  179. }
  180. // 参数防御:负数非法(0 是合法取值,语义见上)
  181. if _, err := repo.SumUsable(ctx, user, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  182. t.Fatalf("institutionID<0 应 ErrBadParam,实得 %v", err)
  183. }
  184. if _, err := repo.SumUsable(ctx, user, 1, -1); !errors.Is(err, infraerrors.ErrBadParam) {
  185. t.Fatalf("campusID<0 应 ErrBadParam,实得 %v", err)
  186. }
  187. if _, err := repo.SumUsable(ctx, "", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  188. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  189. }
  190. // 无可用行 → 0(不是错误)
  191. if got, err := repo.SumUsable(ctx, "01HNOBODY00000000000001", 1, 2); err != nil || !got.IsZero() {
  192. t.Fatalf("无可用行应返回 0,实得 %s err=%v", got.String(), err)
  193. }
  194. }
  195. // TestIncUsedAndBatch 原子增减 + 批量单语句 CASE(正负增量都精确到分)。
  196. func TestIncUsedAndBatch(t *testing.T) {
  197. db := newActivityRepoDB(t)
  198. user := "01HUSER000000000000000013"
  199. seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
  200. seedPool(t, db, 2, 1, 2, user, "100.00", "0.00")
  201. seedPool(t, db, 3, 1, 2, user, "100.00", "10.00")
  202. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  203. runner := NewTxRunner(db)
  204. ctx := context.Background()
  205. // 单账户:消费 12.34
  206. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  207. n, e := repo.IncUsed(ctx, tx, 1, money.FromCents(1234))
  208. if e != nil {
  209. return e
  210. }
  211. if n != 1 {
  212. t.Fatalf("IncUsed 应影响 1 行,实得 %d", n)
  213. }
  214. return nil
  215. }); err != nil {
  216. t.Fatalf("IncUsed 失败: %v", err)
  217. }
  218. if got := amountOf(t, db, 1); got.Cents() != 1234 {
  219. t.Fatalf("id=1 used 应为 12.34,实得 %s", got.String())
  220. }
  221. // 批量:id1 +1.00 / id2 +2.50 / id3 -0.01(退回冲正)
  222. sqls := captureSQL(t, db)
  223. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  224. _, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{
  225. {DiscountID: 1, Delta: money.FromCents(100)},
  226. {DiscountID: 2, Delta: money.FromCents(250)},
  227. {DiscountID: 3, Delta: money.FromCents(-1)},
  228. })
  229. return e
  230. }); err != nil {
  231. t.Fatalf("IncUsedBatch 失败: %v", err)
  232. }
  233. // 立即断言(后续读库也会被回调捕获,故此处先判再读)
  234. if len(*sqls) != 1 {
  235. t.Fatalf("批量增减必须 1 条语句(禁止逐行 UPDATE),实得 %d 条: %v", len(*sqls), *sqls)
  236. }
  237. if !strings.Contains((*sqls)[0], "CASE id") {
  238. t.Fatalf("批量增减应为 CASE WHEN 单语句,SQL=%q", (*sqls)[0])
  239. }
  240. if got := amountOf(t, db, 1); got.Cents() != 1334 {
  241. t.Fatalf("id=1 used 应为 13.34,实得 %s", got.String())
  242. }
  243. if got := amountOf(t, db, 2); got.Cents() != 250 {
  244. t.Fatalf("id=2 used 应为 2.50,实得 %s", got.String())
  245. }
  246. if got := amountOf(t, db, 3); got.Cents() != 999 {
  247. t.Fatalf("id=3 used 应为 9.99(10.00-0.01),实得 %s", got.String())
  248. }
  249. }
  250. // TestIncUsedBatchEdgeCases 空集合/零增量/非法 id 不发 SQL;同 id 重复出现以后者为准。
  251. func TestIncUsedBatchEdgeCases(t *testing.T) {
  252. db := newActivityRepoDB(t)
  253. user := "01HUSER000000000000000014"
  254. seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
  255. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  256. runner := NewTxRunner(db)
  257. ctx := context.Background()
  258. sqls := captureSQL(t, db)
  259. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  260. n, e := repo.IncUsedBatch(ctx, tx, nil)
  261. if e != nil {
  262. return e
  263. }
  264. if n != 0 {
  265. t.Fatalf("空集合应 0 行,实得 %d", n)
  266. }
  267. _, e = repo.IncUsedBatch(ctx, tx, []UsedDelta{{DiscountID: 0, Delta: money.FromCents(1)}, {DiscountID: 1, Delta: 0}})
  268. return e
  269. }); err != nil {
  270. t.Fatalf("IncUsedBatch 边界用例失败: %v", err)
  271. }
  272. if len(*sqls) != 0 {
  273. t.Fatalf("无有效条目不应发 SQL,实得 %v", *sqls)
  274. }
  275. // 同 id 两次:后者覆盖前者
  276. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  277. _, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{
  278. {DiscountID: 1, Delta: money.FromCents(500)},
  279. {DiscountID: 1, Delta: money.FromCents(700)},
  280. })
  281. return e
  282. }); err != nil {
  283. t.Fatalf("IncUsedBatch(重复 id) 失败: %v", err)
  284. }
  285. if got := amountOf(t, db, 1); got.Cents() != 700 {
  286. t.Fatalf("重复 id 应以后者为准(7.00),实得 %s", got.String())
  287. }
  288. // tx 缺失 → ErrInternal
  289. if _, err := repo.IncUsed(nil, nil, 1, money.FromCents(1)); !errors.Is(err, infraerrors.ErrInternal) {
  290. t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
  291. }
  292. if _, err := repo.IncUsedBatch(ctx, nil, []UsedDelta{{DiscountID: 1, Delta: 1}}); !errors.Is(err, infraerrors.ErrInternal) {
  293. t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
  294. }
  295. if _, err := repo.IncUsed(ctx, repo.DB, 0, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
  296. t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err)
  297. }
  298. }
  299. // TestInsertDiscountAndUsedLog 两条流水写入(参数校验 + 落库形态)。
  300. func TestInsertDiscountAndUsedLog(t *testing.T) {
  301. db := newActivityRepoDB(t)
  302. user := "01HUSER000000000000000015"
  303. seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
  304. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  305. runner := NewTxRunner(db)
  306. ctx := context.Background()
  307. stuCourse := int64(901)
  308. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  309. return repo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{
  310. ActivityUserDiscountID: 1,
  311. CoursesStudentID: &stuCourse,
  312. DiscountAmountUsed: "-10.00",
  313. CreateTime: time.Now(),
  314. })
  315. }); err != nil {
  316. t.Fatalf("InsertDiscountLog 失败: %v", err)
  317. }
  318. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  319. return repo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{
  320. ActivityID: 1,
  321. UserID: user,
  322. OrderNo: ptrString("901"),
  323. ActivityMoneyUsed: "-10.00",
  324. CreateTime: time.Now(),
  325. })
  326. }); err != nil {
  327. t.Fatalf("InsertUsedLog 失败: %v", err)
  328. }
  329. var dlogCount, ulogCount int64
  330. if err := db.Model(&model.CoursesActivityUserDiscountLog{}).Count(&dlogCount).Error; err != nil {
  331. t.Fatalf("count discount log 失败: %v", err)
  332. }
  333. if err := db.Model(&model.CoursesActivityUsedLog{}).Count(&ulogCount).Error; err != nil {
  334. t.Fatalf("count used log 失败: %v", err)
  335. }
  336. if dlogCount != 1 || ulogCount != 1 {
  337. t.Fatalf("两条流水应各 1 行,实得 discount=%d used=%d", dlogCount, ulogCount)
  338. }
  339. // 参数/事务防御
  340. if err := repo.InsertDiscountLog(ctx, nil, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 1}); !errors.Is(err, infraerrors.ErrInternal) {
  341. t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
  342. }
  343. if err := repo.InsertDiscountLog(ctx, repo.DB, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 0}); !errors.Is(err, infraerrors.ErrBadParam) {
  344. t.Fatalf("缺 discount id 应 ErrBadParam,实得 %v", err)
  345. }
  346. if err := repo.InsertUsedLog(ctx, repo.DB, &model.CoursesActivityUsedLog{ActivityID: 0, UserID: user}); !errors.Is(err, infraerrors.ErrBadParam) {
  347. t.Fatalf("缺 activity_id 应 ErrBadParam,实得 %v", err)
  348. }
  349. if err := repo.InsertUsedLog(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) {
  350. t.Fatalf("nil 行应 ErrBadParam,实得 %v", err)
  351. }
  352. }
  353. // ptrString 字符串指针构造(测试用)。
  354. func ptrString(s string) *string { return &s }