activity_discount_repo_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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, 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, 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, "", OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
  71. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  72. }
  73. if _, err := repo.ListUsable(ctx, user, "desc,1;--"); !errors.Is(err, infraerrors.ErrBadParam) {
  74. t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
  75. }
  76. }
  77. // TestSumUsableThreeParamScoping dump usable 3 参口径:机构 + 校区(campus=0 含全机构池)。
  78. func TestSumUsableThreeParamScoping(t *testing.T) {
  79. db := newActivityRepoDB(t)
  80. user := "01HUSER000000000000000012"
  81. seedPool(t, db, 1, 1, 2, user, "10.00", "0.00") // 本校区:可用 10
  82. seedPool(t, db, 2, 1, 0, user, "20.00", "5.00") // 机构所有自营校区:可用 15
  83. seedPool(t, db, 3, 1, 3, user, "100.00", "0.00") // 他校区:本校区视角不计
  84. seedPool(t, db, 4, 2, 2, user, "50.00", "0.00") // 他机构:一律不计
  85. seedPool(t, db, 5, 1, 2, user, "5.00", "5.00") // 用尽:不计
  86. seedPool(t, db, 6, 1, 2, "01HOTHER00000000000001", "999.00", "0.00") // 他人:不计
  87. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  88. ctx := context.Background()
  89. if got, err := repo.SumUsable(ctx, user, 1, 2); err != nil || got.Cents() != 2500 {
  90. t.Fatalf("SumUsable(1,2) 应为 25.00(10+15),实得 %s err=%v", got.String(), err)
  91. }
  92. if got, err := repo.SumUsable(ctx, user, 1, 0); err != nil || got.Cents() != 12500 {
  93. t.Fatalf("SumUsable(1,0) 应为 125.00(机构级口径含他校区 100),实得 %s err=%v", got.String(), err)
  94. }
  95. // 越权:非超管传入其它机构 → 1004
  96. if _, err := repo.SumUsable(ctx, user, 2, 2); !errors.Is(err, infraerrors.ErrUnscoped) {
  97. t.Fatalf("跨机构参数应 ErrUnscoped,实得 %v", err)
  98. }
  99. // 超管可跨机构(报表视角)
  100. admin := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2, SuperAdmin: true})
  101. if got, err := admin.SumUsable(ctx, user, 2, 2); err != nil || got.Cents() != 5000 {
  102. t.Fatalf("超管 SumUsable(2,2) 应为 50.00,实得 %s err=%v", got.String(), err)
  103. }
  104. // 参数防御
  105. if _, err := repo.SumUsable(ctx, user, 0, 2); !errors.Is(err, infraerrors.ErrBadParam) {
  106. t.Fatalf("institutionID<=0 应 ErrBadParam,实得 %v", err)
  107. }
  108. // 无可用行 → 0(不是错误)
  109. if got, err := repo.SumUsable(ctx, "01HNOBODY00000000000001", 1, 2); err != nil || !got.IsZero() {
  110. t.Fatalf("无可用行应返回 0,实得 %s err=%v", got.String(), err)
  111. }
  112. }
  113. // TestIncUsedAndBatch 原子增减 + 批量单语句 CASE(正负增量都精确到分)。
  114. func TestIncUsedAndBatch(t *testing.T) {
  115. db := newActivityRepoDB(t)
  116. user := "01HUSER000000000000000013"
  117. seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
  118. seedPool(t, db, 2, 1, 2, user, "100.00", "0.00")
  119. seedPool(t, db, 3, 1, 2, user, "100.00", "10.00")
  120. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  121. runner := NewTxRunner(db)
  122. ctx := context.Background()
  123. // 单账户:消费 12.34
  124. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  125. n, e := repo.IncUsed(ctx, tx, 1, money.FromCents(1234))
  126. if e != nil {
  127. return e
  128. }
  129. if n != 1 {
  130. t.Fatalf("IncUsed 应影响 1 行,实得 %d", n)
  131. }
  132. return nil
  133. }); err != nil {
  134. t.Fatalf("IncUsed 失败: %v", err)
  135. }
  136. if got := amountOf(t, db, 1); got.Cents() != 1234 {
  137. t.Fatalf("id=1 used 应为 12.34,实得 %s", got.String())
  138. }
  139. // 批量:id1 +1.00 / id2 +2.50 / id3 -0.01(退回冲正)
  140. sqls := captureSQL(t, db)
  141. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  142. _, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{
  143. {DiscountID: 1, Delta: money.FromCents(100)},
  144. {DiscountID: 2, Delta: money.FromCents(250)},
  145. {DiscountID: 3, Delta: money.FromCents(-1)},
  146. })
  147. return e
  148. }); err != nil {
  149. t.Fatalf("IncUsedBatch 失败: %v", err)
  150. }
  151. // 立即断言(后续读库也会被回调捕获,故此处先判再读)
  152. if len(*sqls) != 1 {
  153. t.Fatalf("批量增减必须 1 条语句(禁止逐行 UPDATE),实得 %d 条: %v", len(*sqls), *sqls)
  154. }
  155. if !strings.Contains((*sqls)[0], "CASE id") {
  156. t.Fatalf("批量增减应为 CASE WHEN 单语句,SQL=%q", (*sqls)[0])
  157. }
  158. if got := amountOf(t, db, 1); got.Cents() != 1334 {
  159. t.Fatalf("id=1 used 应为 13.34,实得 %s", got.String())
  160. }
  161. if got := amountOf(t, db, 2); got.Cents() != 250 {
  162. t.Fatalf("id=2 used 应为 2.50,实得 %s", got.String())
  163. }
  164. if got := amountOf(t, db, 3); got.Cents() != 999 {
  165. t.Fatalf("id=3 used 应为 9.99(10.00-0.01),实得 %s", got.String())
  166. }
  167. }
  168. // TestIncUsedBatchEdgeCases 空集合/零增量/非法 id 不发 SQL;同 id 重复出现以后者为准。
  169. func TestIncUsedBatchEdgeCases(t *testing.T) {
  170. db := newActivityRepoDB(t)
  171. user := "01HUSER000000000000000014"
  172. seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
  173. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  174. runner := NewTxRunner(db)
  175. ctx := context.Background()
  176. sqls := captureSQL(t, db)
  177. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  178. n, e := repo.IncUsedBatch(ctx, tx, nil)
  179. if e != nil {
  180. return e
  181. }
  182. if n != 0 {
  183. t.Fatalf("空集合应 0 行,实得 %d", n)
  184. }
  185. _, e = repo.IncUsedBatch(ctx, tx, []UsedDelta{{DiscountID: 0, Delta: money.FromCents(1)}, {DiscountID: 1, Delta: 0}})
  186. return e
  187. }); err != nil {
  188. t.Fatalf("IncUsedBatch 边界用例失败: %v", err)
  189. }
  190. if len(*sqls) != 0 {
  191. t.Fatalf("无有效条目不应发 SQL,实得 %v", *sqls)
  192. }
  193. // 同 id 两次:后者覆盖前者
  194. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  195. _, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{
  196. {DiscountID: 1, Delta: money.FromCents(500)},
  197. {DiscountID: 1, Delta: money.FromCents(700)},
  198. })
  199. return e
  200. }); err != nil {
  201. t.Fatalf("IncUsedBatch(重复 id) 失败: %v", err)
  202. }
  203. if got := amountOf(t, db, 1); got.Cents() != 700 {
  204. t.Fatalf("重复 id 应以后者为准(7.00),实得 %s", got.String())
  205. }
  206. // tx 缺失 → ErrInternal
  207. if _, err := repo.IncUsed(nil, nil, 1, money.FromCents(1)); !errors.Is(err, infraerrors.ErrInternal) {
  208. t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
  209. }
  210. if _, err := repo.IncUsedBatch(ctx, nil, []UsedDelta{{DiscountID: 1, Delta: 1}}); !errors.Is(err, infraerrors.ErrInternal) {
  211. t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
  212. }
  213. if _, err := repo.IncUsed(ctx, repo.DB, 0, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
  214. t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err)
  215. }
  216. }
  217. // TestInsertDiscountAndUsedLog 两条流水写入(参数校验 + 落库形态)。
  218. func TestInsertDiscountAndUsedLog(t *testing.T) {
  219. db := newActivityRepoDB(t)
  220. user := "01HUSER000000000000000015"
  221. seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
  222. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  223. runner := NewTxRunner(db)
  224. ctx := context.Background()
  225. stuCourse := int64(901)
  226. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  227. return repo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{
  228. ActivityUserDiscountID: 1,
  229. CoursesStudentID: &stuCourse,
  230. DiscountAmountUsed: "-10.00",
  231. CreateTime: time.Now(),
  232. })
  233. }); err != nil {
  234. t.Fatalf("InsertDiscountLog 失败: %v", err)
  235. }
  236. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  237. return repo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{
  238. ActivityID: 1,
  239. UserID: user,
  240. OrderNo: ptrString("901"),
  241. ActivityMoneyUsed: "-10.00",
  242. CreateTime: time.Now(),
  243. })
  244. }); err != nil {
  245. t.Fatalf("InsertUsedLog 失败: %v", err)
  246. }
  247. var dlogCount, ulogCount int64
  248. if err := db.Model(&model.CoursesActivityUserDiscountLog{}).Count(&dlogCount).Error; err != nil {
  249. t.Fatalf("count discount log 失败: %v", err)
  250. }
  251. if err := db.Model(&model.CoursesActivityUsedLog{}).Count(&ulogCount).Error; err != nil {
  252. t.Fatalf("count used log 失败: %v", err)
  253. }
  254. if dlogCount != 1 || ulogCount != 1 {
  255. t.Fatalf("两条流水应各 1 行,实得 discount=%d used=%d", dlogCount, ulogCount)
  256. }
  257. // 参数/事务防御
  258. if err := repo.InsertDiscountLog(ctx, nil, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 1}); !errors.Is(err, infraerrors.ErrInternal) {
  259. t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
  260. }
  261. if err := repo.InsertDiscountLog(ctx, repo.DB, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 0}); !errors.Is(err, infraerrors.ErrBadParam) {
  262. t.Fatalf("缺 discount id 应 ErrBadParam,实得 %v", err)
  263. }
  264. if err := repo.InsertUsedLog(ctx, repo.DB, &model.CoursesActivityUsedLog{ActivityID: 0, UserID: user}); !errors.Is(err, infraerrors.ErrBadParam) {
  265. t.Fatalf("缺 activity_id 应 ErrBadParam,实得 %v", err)
  266. }
  267. if err := repo.InsertUsedLog(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) {
  268. t.Fatalf("nil 行应 ErrBadParam,实得 %v", err)
  269. }
  270. }
  271. // ptrString 字符串指针构造(测试用)。
  272. func ptrString(s string) *string { return &s }