activity_admin_service_test.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. // ActivityAdminService 单测(AC-13b):活动管理面(端点 22~25 / 28 / 37)的**服务级**判据。
  2. //
  3. // 与 handler 测试的分工:
  4. //
  5. // handler 测试 证明"端点通、码对、跨表效果对"(请求 → 响应 → 库)
  6. // 本文件 证明"服务内部规则"(`signable` 的时间窗/名额判定、跨机构校区拒绝、
  7. // `RowsAffected==0 → 3001`、可用额构成的分组与排序、3121 的 R8 分界)
  8. //
  9. // 每条用例的第一段注释都写清"改哪一行会让它变红"——只写"期望值"的用例不算判据
  10. // (它只能证明实现与断言一致,不能证明实现正确)。
  11. package service
  12. import (
  13. "context"
  14. "testing"
  15. "time"
  16. "yuansiwei/unified-backend/internal/academic/model"
  17. "yuansiwei/unified-backend/internal/academic/money"
  18. "yuansiwei/unified-backend/internal/academic/port"
  19. )
  20. // TestSignableMatrix `signable` 的四条子判据(端点 22 的展示位)。
  21. //
  22. // 会变红的条件(逐条对应一个分支):
  23. // - 去掉 `status != Enabled` ⇒ 停用活动 signable=true → 红;
  24. // - 去掉名额判定 ⇒ 满员活动 signable=true → 红;
  25. // - 去掉 `now < start` 判定 ⇒ 未开始活动 signable=true → 红;
  26. // - 去掉 `now > end` 判定 ⇒ 已结束活动 signable=true → 红。
  27. //
  28. // ⚠ `signable` **只喂前端展示**:它变 true 不代表后端会收报名(dump 的 signed_add 无可用性校验,
  29. // §10.1-3110 已收窄)。本用例只锁"展示位算法",**不得**据此在写路径加校验。
  30. func TestSignableMatrix(t *testing.T) {
  31. db := newActivityDomainDB(t)
  32. svc := NewActivityAdminService(db, activityScopeFor(1, 10))
  33. fixed := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC)
  34. svc.WithClock(func() time.Time { return fixed })
  35. start := fixed.Add(-24 * time.Hour)
  36. end := fixed.Add(24 * time.Hour)
  37. before := fixed.Add(24 * time.Hour)
  38. after := fixed.Add(-24 * time.Hour)
  39. cases := []struct {
  40. name string
  41. act model.CoursesActivity
  42. want bool
  43. }{
  44. {"开启+在窗口内+有名额", model.CoursesActivity{
  45. Status: model.ActivityStatusEnabled, NumberMax: 5, NumberSign: 4, StartTime: &start, EndTime: &end,
  46. }, true},
  47. {"开启+不限名额+无窗口", model.CoursesActivity{Status: model.ActivityStatusEnabled}, true},
  48. {"停用", model.CoursesActivity{Status: model.ActivityStatusDisabled, StartTime: &start, EndTime: &end}, false},
  49. {"满员", model.CoursesActivity{
  50. Status: model.ActivityStatusEnabled, NumberMax: 3, NumberSign: 3, StartTime: &start, EndTime: &end,
  51. }, false},
  52. {"未开始", model.CoursesActivity{Status: model.ActivityStatusEnabled, StartTime: &before}, false},
  53. {"已结束", model.CoursesActivity{Status: model.ActivityStatusEnabled, EndTime: &after}, false},
  54. }
  55. for _, tc := range cases {
  56. act := tc.act
  57. if got := svc.signable(&act); got != tc.want {
  58. t.Errorf("%s:signable 期望 %v,实得 %v", tc.name, tc.want, got)
  59. }
  60. }
  61. }
  62. // TestCreateActivityRejectsCrossCampus 新建的校区归属校验:跨机构 → **1004**;校区不存在 → **3104**。
  63. //
  64. // 会变红的条件:把 `assertCampusInScope` 的 `schoolID != institutionID` 判断删掉 ⇒ 跨机构写入成功 → 红。
  65. func TestCreateActivityRejectsCrossCampus(t *testing.T) {
  66. db := newActivityDomainDB(t)
  67. // 复用 fund_service_test.go 的 seedCampus(self_support=true 自营)
  68. seedCampus(t, db, 10, 1, true)
  69. seedCampus(t, db, 20, 2, true)
  70. svc := NewActivityAdminService(db, activityScopeFor(1, 10)).WithCampusReader(port.NewOrgCampusReader(db))
  71. ctx := context.Background()
  72. base := CreateActivityInput{
  73. InstitutionID: 1, Type: model.ActivityTypeRecharge, Title: "跨机构测试",
  74. Money: money.FromCents(10000), Status: model.ActivityStatusEnabled,
  75. }
  76. cross := base
  77. cross.CampusID = 20
  78. if _, err := svc.CreateActivity(ctx, cross); err == nil {
  79. t.Fatalf("校区 20 属于机构 2,写入应被拒(1004),实得 nil")
  80. } else {
  81. wantAppErr(t, err, 1004, "跨机构校区写入")
  82. }
  83. missing := base
  84. missing.CampusID = 999
  85. if _, err := svc.CreateActivity(ctx, missing); err == nil {
  86. t.Fatalf("校区 999 不存在,应 3104,实得 nil")
  87. } else {
  88. wantAppErr(t, err, 3104, "校区不存在")
  89. }
  90. ok := base
  91. ok.CampusID = 10
  92. res, err := svc.CreateActivity(ctx, ok)
  93. if err != nil {
  94. t.Fatalf("本机构校区写入应成功,实得 %v", err)
  95. }
  96. var row model.CoursesActivity
  97. if e := db.Where("id = ?", res.ID).Take(&row).Error; e != nil {
  98. t.Fatalf("读新建活动失败: %v", e)
  99. }
  100. if row.NumberSign != 0 {
  101. t.Fatalf("number_sign 必须为 0,实得 %d", row.NumberSign)
  102. }
  103. if row.InstitutionID != 1 {
  104. t.Fatalf("institution_id 应为 1,实得 %d", row.InstitutionID)
  105. }
  106. }
  107. // TestUpdateActivityMissingRowIs3001 更新不存在/越权活动 → **3001**(`RowsAffected==0` 守卫)。
  108. //
  109. // 会变红的条件:删掉 `if affected == 0 { return ErrNotFound }` ⇒ 该用例实得 nil(假成功)→ 红。
  110. // 这正是 §10.3 红线「写路径必须校验影响行数」在活动域的落点。
  111. func TestUpdateActivityMissingRowIs3001(t *testing.T) {
  112. db := newActivityDomainDB(t)
  113. svc := NewActivityAdminService(db, activityScopeFor(1, 10))
  114. _, err := svc.UpdateActivity(context.Background(), UpdateActivityInput{
  115. ID: 123456, InstitutionID: 1, Type: model.ActivityTypeRecharge,
  116. Title: "改名", Money: money.FromCents(1), Status: model.ActivityStatusEnabled,
  117. })
  118. wantAppErr(t, err, 3001, "更新不存在的活动")
  119. }
  120. // TestUsableBreakdownGroupsByActivity 可用赠送按活动分组:总额 = Σ 构成、按 activityId 升序、不可用行不计。
  121. //
  122. // 会变红的条件:
  123. // - 把 `usable` 改成"再查一次 SQL 汇总"(而不是 Σ items)⇒ 两者在并发/脏行下会不一致 → 红;
  124. // - 去掉 `amount <= 0` 跳过 ⇒ 出现 0 元 item → 红;
  125. // - 去掉排序 ⇒ map 遍历顺序随机,断言 flaky(本用例固定期望 [1001,1002])。
  126. func TestUsableBreakdownGroupsByActivity(t *testing.T) {
  127. db := newActivityDomainDB(t)
  128. userID := "01HSTUDENT000000000000001"
  129. pools := []model.CoursesActivityUserDiscount{
  130. {ID: 1, ActivityID: 1001, InstitutionID: 1, CampusID: 10, UserID: userID,
  131. Income: "0.00", DiscountAmount: "100.00", DiscountAmountUsed: "40.00"},
  132. {ID: 2, ActivityID: 1002, InstitutionID: 1, CampusID: 10, UserID: userID,
  133. Income: "0.00", DiscountAmount: "30.00", DiscountAmountUsed: "0.00"},
  134. // 不可用行(已用 >= 面额)→ 两个方法都不计
  135. {ID: 3, ActivityID: 1003, InstitutionID: 1, CampusID: 10, UserID: userID,
  136. Income: "0.00", DiscountAmount: "10.00", DiscountAmountUsed: "10.00"},
  137. }
  138. for i := range pools {
  139. if err := db.Create(&pools[i]).Error; err != nil {
  140. t.Fatalf("seed 池失败: %v", err)
  141. }
  142. }
  143. svc := NewActivityDiscountService(db, activityScopeFor(1, 10))
  144. res, err := svc.UsableBreakdown(context.Background(), UsableAmountInput{
  145. UserID: userID, InstitutionID: 1, CampusID: 10,
  146. })
  147. if err != nil {
  148. t.Fatalf("UsableBreakdown 失败: %v", err)
  149. }
  150. if len(res.Items) != 2 {
  151. t.Fatalf("items 应为 2 项(1003 不可用被排除),实得 %d: %+v", len(res.Items), res.Items)
  152. }
  153. if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 {
  154. t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items)
  155. }
  156. if res.Usable != money.FromCents(9000) {
  157. t.Fatalf("usable 应为 90.00 元(9000 分),实得 %s", res.Usable.String())
  158. }
  159. sum := money.Amount(0)
  160. for _, item := range res.Items {
  161. sum = sum.Add(item.Amount)
  162. }
  163. if sum != res.Usable {
  164. t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable.String(), sum.String())
  165. }
  166. }
  167. // TestCancelSignUnresolvableTypeIs3121 服务层 R8:报名行在、活动行缺失 ⇒ 3121(不是 3001)。
  168. //
  169. // 会变红的条件:删掉 `ExistsAnySigned` 分支 ⇒ 落回 3001 → 红。
  170. // 本用例与 handler 的 `TestCancelSignUnresolvableTypeIs3121` 成对:一个锁服务分界,
  171. // 一个锁 HTTP 码不被 handler 改写(handler 是纯透传,两层都得有判据)。
  172. func TestCancelSignUnresolvableTypeIs3121(t *testing.T) {
  173. db := newActivityDomainDB(t)
  174. userID := "01HSTUDENT000000000000002"
  175. // 只有报名行,没有活动行(模拟活动被物理删除后的历史脏数据)
  176. if err := db.Create(&model.CoursesActivitySigned{
  177. ID: 91, ActivityID: 9101, UserID: userID, InstitutionID: 1, CampusID: 10,
  178. Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
  179. }).Error; err != nil {
  180. t.Fatalf("seed 报名行失败: %v", err)
  181. }
  182. svc := NewActivityService(db, activityScopeFor(1, 10))
  183. _, err := svc.CancelSign(context.Background(), CancelSignInput{UserID: userID, ActivityID: 9101})
  184. wantAppErr(t, err, 3121, "取消报名取不到活动类型")
  185. // 反向确认:完全没有报名行时仍是 3001(§3.8-R7),两者不得合并
  186. _, err2 := svc.CancelSign(context.Background(), CancelSignInput{UserID: userID, ActivityID: 9199})
  187. wantAppErr(t, err2, 3001, "无报名行")
  188. }