activity_handler_test.go 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  1. // 活动域 Handler 单测(AC-13b:端点 22~37,16 个)。
  2. //
  3. // 断言纪律(与 AC-12 同源,只断 body 契约:HTTP 恒 200 + body.code):
  4. //
  5. // 每条用例都写成"**实现改错就变红**"的形状,判据写在每个 `Test` 的注释第一段。
  6. // "用例全绿"不算验收——全绿只说明断言与实现同时存在,必须能指出"哪一行改掉它就会红"。
  7. //
  8. // 覆盖结构:
  9. //
  10. // ① 端点覆盖 + 路由顺序 → TestActivityRouteCoverage
  11. // ② 列表:作用域/派生字段/枚举 → TestListActivitiesScopesAndDerives
  12. // ③ 新建:2002/3104/1004/成功 → TestCreateActivityValidationAndCrossCampus
  13. // ④ 详情:3001/字段映射/空数组 → TestActivityDetailScopeAndShape
  14. // ⑤ 报名列表:2002/空列表 → TestListSignedsInvalidStatusIs2002
  15. // ⑥ 更新:3140(numberSign)/3001 → TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001
  16. // ⑦ 报名/取消:3105/跨表三列归零 → TestSignThenCancelCrossTableEffects
  17. // ⑧ 取消:3107 审核态零副作用 → TestCancelSignAuditedIs3107WithZeroSideEffects
  18. // ⑨ 取消:3121 type 读不到 → TestCancelSignUnresolvableTypeIs3121
  19. // ⑩ 老带新:settled=false 零写入 → TestRecommendSettleNoopDoesNotWrite
  20. // ⑪ 取消充值:3104 → TestCancelRechargeUnknownCampusIs3104
  21. // ⑫ 可用赠送:1004/1003/items → TestUsableBreakdownScopeAndShape
  22. // ⑬ 赠送使用:no-op 与 3109 → TestDiscountUseNoopAndInsufficient
  23. // ⑭ 充值赠送使用:status=2 前置 → TestRechargeGiftUseRequiresAttendedStatus
  24. // ⑮ 三条退回:refunded 语义 → TestRefundEndpointsReportRefundedFlag
  25. // ⑯ 回访:跨活动拒绝 + 落库 → TestVisitRejectsCrossActivitySigned
  26. package handler
  27. import (
  28. "encoding/json"
  29. "strings"
  30. "testing"
  31. "github.com/gofiber/fiber/v2"
  32. "gorm.io/gorm"
  33. "yuansiwei/unified-backend/internal/academic/model"
  34. "yuansiwei/unified-backend/internal/academic/money"
  35. appscope "yuansiwei/unified-backend/internal/infra/scope"
  36. )
  37. // ---------------------------------------------------------------- 测试基础设施
  38. // newActivityTestApp 构造只挂活动域 16 条端点的 app(含作用域注入中间件)。
  39. //
  40. // 中间件按 `X-Test-Scope` 注入不同作用域(缺省 finance,与 `testScope` 一致):
  41. //
  42. // none / super / academic / student
  43. //
  44. // 路由注册顺序**有意**与 `activity_handler.go` 文件头的"静态优先"一致:
  45. // 若把 `PathActivityDetail` 注册在 `PathActivityDiscountsUsable` 之前,
  46. // `GET /admin/activities/discounts/usable` 会被 `:id="discounts"` 吃掉(见 `TestActivityRouteCoverage`)。
  47. func newActivityTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  48. t.Helper()
  49. h, err := New(Config{DB: db})
  50. if err != nil {
  51. t.Fatalf("handler.New 失败: %v", err)
  52. }
  53. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  54. app.Use(func(c *fiber.Ctx) error {
  55. switch c.Get("X-Test-Scope") {
  56. case "none":
  57. // 不写作用域 → 未登录语义(1001)
  58. case "super":
  59. appscope.Set(c, testSuperScope())
  60. case "academic":
  61. appscope.Set(c, &appscope.Scope{
  62. InstitutionID: 1, CampusID: 10,
  63. UserID: "01HACADEMIC0000000000001", Role: "academic",
  64. })
  65. default:
  66. appscope.Set(c, testScope())
  67. }
  68. return c.Next()
  69. })
  70. // ① 静态段(顺序敏感,必须全在 `:id` 之前)
  71. app.Get(PathActivities, h.ListActivities)
  72. app.Post(PathActivities, h.CreateActivity)
  73. app.Get(PathActivityDiscountsUsable, h.GetActivityUsableDiscount)
  74. app.Post(PathActivityDiscountsUse, h.UseActivityDiscount)
  75. app.Post(PathActivityDiscountsRefund, h.RefundActivityDiscount)
  76. app.Post(PathActivityDiscountsRefundByActivity, h.RefundDiscountByActivity)
  77. app.Post(PathActivityRechargeGiftsUse, h.UseRechargeGift)
  78. app.Post(PathActivityRechargeGiftsRefund, h.RefundRechargeGift)
  79. // ② 带 :id 的段
  80. app.Get(PathActivityDetail, h.GetActivity)
  81. app.Put(PathActivityDetail, h.UpdateActivity)
  82. app.Post(PathActivitySign, h.SignActivity)
  83. app.Post(PathActivityCancelSign, h.CancelActivitySign)
  84. app.Get(PathActivitySigneds, h.ListActivitySigneds)
  85. app.Post(PathActivityRecommendSettle, h.SettleActivityRecommend)
  86. app.Post(PathActivityCancelRecharge, h.CancelActivityRecharge)
  87. app.Post(PathActivityVisits, h.CreateActivityVisit)
  88. return app
  89. }
  90. // seedActivityRow 落一行活动(显式 ID,便于断言语义)。
  91. func seedActivityRow(t *testing.T, db *gorm.DB, row model.CoursesActivity) {
  92. t.Helper()
  93. if row.NumberSign == 0 && row.NumberMax == 0 {
  94. row.NumberMax = 0
  95. }
  96. if row.Title == nil {
  97. title := "活动"
  98. row.Title = &title
  99. }
  100. if err := db.Create(&row).Error; err != nil {
  101. t.Fatalf("seed 活动(%d) 失败: %v", row.ID, err)
  102. }
  103. }
  104. // seedSignedRow 落一行报名(status 显式传;responseUserID 非空表示已审核 → 3107 判据)。
  105. func seedSignedRow(
  106. t *testing.T, db *gorm.DB, id, activityID int64, userID string, status int32,
  107. moneyStr string, responseUserID *string,
  108. ) {
  109. t.Helper()
  110. row := &model.CoursesActivitySigned{
  111. ID: id, ActivityID: activityID, UserID: userID,
  112. InstitutionID: 1, CampusID: 10,
  113. Status: status, ActivityMoney: moneyStr, ActivityMoneyUsed: "0.00",
  114. ResponseUserID: responseUserID,
  115. }
  116. if err := db.Create(row).Error; err != nil {
  117. t.Fatalf("seed 报名(id=%d) 失败: %v", id, err)
  118. }
  119. }
  120. // seedDiscountPool 落一行赠送池(amount/used 为元字符串)。
  121. func seedDiscountPool(
  122. t *testing.T, db *gorm.DB, id, activityID int64, userID string, amount, used string,
  123. ) {
  124. t.Helper()
  125. row := &model.CoursesActivityUserDiscount{
  126. ID: id, ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID,
  127. Income: "0.00", DiscountAmount: amount, DiscountAmountUsed: used,
  128. }
  129. if err := db.Create(row).Error; err != nil {
  130. t.Fatalf("seed 赠送池(id=%d) 失败: %v", id, err)
  131. }
  132. }
  133. // readActivityByID 读活动行(断言库值用;不存在 → 失败)。
  134. func readActivityByID(t *testing.T, db *gorm.DB, id int64) model.CoursesActivity {
  135. t.Helper()
  136. var row model.CoursesActivity
  137. if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
  138. t.Fatalf("读活动(%d) 失败: %v", id, err)
  139. }
  140. return row
  141. }
  142. // countRows 统计表内满足条件的行数(**跨表断言**用:响应体之外的唯一事实)。
  143. func countRows(t *testing.T, db *gorm.DB, entity any, query string, args ...any) int64 {
  144. t.Helper()
  145. var n int64
  146. if err := db.Model(entity).Where(query, args...).Count(&n).Error; err != nil {
  147. t.Fatalf("统计行数失败(%s): %v", query, err)
  148. }
  149. return n
  150. }
  151. // decodeData 把响应 data 解成目标结构(解不出 → 失败)。
  152. func decodeData[T any](t *testing.T, raw json.RawMessage) T {
  153. t.Helper()
  154. var out T
  155. if err := json.Unmarshal(raw, &out); err != nil {
  156. t.Fatalf("data 解码失败(%s): %v", string(raw), err)
  157. }
  158. return out
  159. }
  160. // ---------------------------------------------------------------- ① 端点覆盖 + 路由顺序
  161. // TestActivityRouteCoverage 16 条端点全注册、路径不含 `/api`、且**静态段优先**。
  162. //
  163. // 会变红的条件:
  164. // - 任何一条 `PathActivity*` 改了名字/漏注册 → 对应 `registered[...]` 断言失败;
  165. // - 把 `GET /admin/activities/:id` 注册在 `discounts/usable` 之前 →
  166. // `/admin/activities/discounts/usable?userId=X` 会命中详情 handler 并因 id="discounts" 解析成 0
  167. // ⇒ 返回 **2002**,本用例断言它必须是 **200**(真实业务码)→ 红。
  168. func TestActivityRouteCoverage(t *testing.T) {
  169. db := newHandlerTestDB(t)
  170. app := newActivityTestApp(t, db)
  171. expected := []struct{ method, path, name string }{
  172. {"GET", PathActivities, "端点22 活动列表"},
  173. {"POST", PathActivities, "端点23 新建"},
  174. {"GET", PathActivityDetail, "端点24 详情"},
  175. {"PUT", PathActivityDetail, "端点25 更新"},
  176. {"POST", PathActivitySign, "端点26 报名"},
  177. {"POST", PathActivityCancelSign, "端点27 取消报名"},
  178. {"GET", PathActivitySigneds, "端点28 报名列表"},
  179. {"POST", PathActivityRecommendSettle, "端点29 老带新结算"},
  180. {"POST", PathActivityCancelRecharge, "端点30 取消充值"},
  181. {"GET", PathActivityDiscountsUsable, "端点31 可用赠送"},
  182. {"POST", PathActivityDiscountsUse, "端点32 使用赠送"},
  183. {"POST", PathActivityDiscountsRefund, "端点33 赠送退回"},
  184. {"POST", PathActivityDiscountsRefundByActivity, "端点34 按活动退回"},
  185. {"POST", PathActivityRechargeGiftsUse, "端点35 充值赠送使用"},
  186. {"POST", PathActivityRechargeGiftsRefund, "端点36 充值赠送退回"},
  187. {"POST", PathActivityVisits, "端点37 回访"},
  188. }
  189. registered := map[string]bool{}
  190. for _, r := range app.GetRoutes() {
  191. registered[r.Method+" "+r.Path] = true
  192. if strings.Contains(r.Path, "/api/") {
  193. t.Errorf("后端路由不得含 /api 前缀(§1.6):%s %s", r.Method, r.Path)
  194. }
  195. }
  196. for _, want := range expected {
  197. if !registered[want.method+" "+want.path] {
  198. t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path)
  199. }
  200. }
  201. if len(expected) != 16 {
  202. t.Fatalf("活动端点数应为 16,实为 %d(契约 §2.2)", len(expected))
  203. }
  204. // 路由顺序的**行为断言**(比检查注册表更硬):静态路径必须走到自己的 handler。
  205. code, msg, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U1", nil)
  206. if code != 200 {
  207. t.Fatalf("GET discounts/usable 期望 200,实得 %d(%s)——"+
  208. "若为 2002 说明 :id 路由先注册吃掉了 discounts", code, msg)
  209. }
  210. }
  211. // ---------------------------------------------------------------- ② 列表
  212. // TestListActivitiesScopesAndDerives 列表作用域 + `signed`/`numberSign`/`signable` 派生字段。
  213. //
  214. // 会变红的条件(逐条):
  215. // - 去掉 `scopedCampusRows` ⇒ 列表里出现 institutionId=2 的活动 → 红;
  216. // - `CountSignedByActivityIDs` 少写 `status <> 0` ⇒ `signed` 变 3(期望 2)→ 红;
  217. // - `signable` 恒 true ⇒ status=0 的活动也 true → 红;
  218. // - `name`/`total` 没有从 `title`/`number_max` 映射 ⇒ 断言失败 → 红。
  219. func TestListActivitiesScopesAndDerives(t *testing.T) {
  220. db := newHandlerTestDB(t)
  221. app := newActivityTestApp(t, db)
  222. title1 := "充值赠送 A"
  223. seedActivityRow(t, db, model.CoursesActivity{
  224. ID: 101, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  225. Title: &title1, Money: "100.00", NumberMax: 5, NumberSign: 2, Status: 1,
  226. })
  227. seedActivityRow(t, db, model.CoursesActivity{
  228. ID: 102, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
  229. Money: "0.00", NumberMax: 0, Status: 0, // 停用 → signable=false
  230. })
  231. seedActivityRow(t, db, model.CoursesActivity{
  232. ID: 103, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
  233. Money: "1.00", NumberMax: 0, Status: 1, // 别的机构 → 不得出现
  234. })
  235. seedSignedRow(t, db, 1, 101, "U1", model.ActivitySignedSigned, "100.00", nil)
  236. seedSignedRow(t, db, 2, 101, "U2", model.ActivitySignedAttended, "100.00", nil)
  237. seedSignedRow(t, db, 3, 101, "U3", model.ActivitySignedCancelled, "0.00", nil) // 取消 → 不计
  238. code, msg, data := doJSON(t, app, "GET", "/admin/activities?page=1&pageSize=10", nil)
  239. if code != 200 {
  240. t.Fatalf("列表期望 200,实得 %d(%s)", code, msg)
  241. }
  242. page := decodeData[struct {
  243. List []ActivityViewForTest `json:"list"`
  244. Total int64 `json:"total"`
  245. Page int `json:"page"`
  246. PageSize int `json:"pageSize"`
  247. Pages int64 `json:"pages"`
  248. }](t, data)
  249. if page.Total != 2 {
  250. t.Fatalf("列表总数应为 2(本机构 2 个),实得 %d(含跨机构活动则说明作用域失效)", page.Total)
  251. }
  252. if page.Pages != 1 {
  253. t.Fatalf("pages 应为 ceil(2/10)=1,实得 %d", page.Pages)
  254. }
  255. byID := map[int64]ActivityViewForTest{}
  256. for _, row := range page.List {
  257. byID[row.ID] = row
  258. }
  259. if _, ok := byID[103]; ok {
  260. t.Fatalf("跨机构活动 103 出现在列表里(作用域过滤失效,1004 类越权)")
  261. }
  262. first, ok := byID[101]
  263. if !ok {
  264. t.Fatalf("本机构活动 101 不在列表里")
  265. }
  266. if first.Name != "充值赠送 A" {
  267. t.Fatalf("name 应映射 title(\"充值赠送 A\"),实得 %q", first.Name)
  268. }
  269. if first.Total != 5 {
  270. t.Fatalf("total 应映射 number_max=5,实得 %d", first.Total)
  271. }
  272. if first.NumberSign != 2 {
  273. t.Fatalf("numberSign 应为库值 2,实得 %d", first.NumberSign)
  274. }
  275. if first.Signed != 2 {
  276. t.Fatalf("signed 应为 2(status<>0),实得 %d——"+
  277. "若为 3 说明把 status=0(已取消)也统计进来了", first.Signed)
  278. }
  279. if !first.Signable {
  280. t.Fatalf("活动 101 应为 signable(status=1、未满员、无时间窗约束)")
  281. }
  282. disabled := byID[102]
  283. if disabled.Signable {
  284. t.Fatalf("活动 102 status=0(停用)应 signable=false,实得 true")
  285. }
  286. // 空数组而非 null:`list` 必须在 data 顶层且为数组
  287. if !strings.Contains(string(data), `"list":[`) {
  288. t.Fatalf("data.list 必须是数组(不得为 null/缺键):%s", string(data))
  289. }
  290. }
  291. // ActivityViewForTest 列表行(只声明本用例要断言的字段,避免抄一份 DTO 造成"两处定义")。
  292. type ActivityViewForTest struct {
  293. ID int64 `json:"id"`
  294. Name string `json:"name"`
  295. Total int32 `json:"total"`
  296. NumberSign int32 `json:"numberSign"`
  297. Signed int32 `json:"signed"`
  298. Signable bool `json:"signable"`
  299. Type int8 `json:"type"`
  300. }
  301. // TestListActivitiesInvalidEnumsAre2002 type/status 过滤枚举越界 → **2002**(不是 2001、不是静默忽略)。
  302. //
  303. // 会变红的条件:删掉 `parseEnumQuery` 的白名单判断 ⇒ 变成 200 且返回全量列表 → 红。
  304. func TestListActivitiesInvalidEnumsAre2002(t *testing.T) {
  305. db := newHandlerTestDB(t)
  306. app := newActivityTestApp(t, db)
  307. for _, q := range []string{"type=1", "type=9", "status=3", "status=abc"} {
  308. code, _, _ := doJSON(t, app, "GET", "/admin/activities?"+q, nil)
  309. if code != 2002 {
  310. t.Errorf("查询 %s 期望 2002(枚举/格式非法),实得 %d", q, code)
  311. }
  312. }
  313. }
  314. // ---------------------------------------------------------------- ③ 新建
  315. // TestCreateActivityValidationAndCrossCampus 新建活动的四类拒绝 + 成功路径的库值断言。
  316. //
  317. // 会变红的条件:
  318. // - 删除 `validateActivityFields` 的 type 白名单 ⇒ type=1 变 200 → 红;
  319. // - 删除时间窗倒挂判断 ⇒ start>end 变 200 → 红;
  320. // - 删除 `assertCampusInScope` ⇒ 传别家机构校区(20)变 200 → 红(**这是 1004 越权判据**);
  321. // - 删除校区端口调用 ⇒ 不存在的校区(777)变 5002/200 而不是 3104 → 红;
  322. // - 成功路径:`institution_id` 必须取作用域(1),且 `number_sign` 必须为 0。
  323. func TestCreateActivityValidationAndCrossCampus(t *testing.T) {
  324. db := newHandlerTestDB(t)
  325. app := newActivityTestApp(t, db)
  326. // 造一个"属于机构 2"的校区(跨机构写入判据;测试库默认只有 10 → 机构 1)
  327. if err := db.Exec(
  328. `INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(20,2,1,'active')`,
  329. ).Error; err != nil {
  330. t.Fatalf("seed t_campus(20) 失败: %v", err)
  331. }
  332. base := map[string]any{"type": 2, "title": "新活动", "money": "100.00"}
  333. cases := []struct {
  334. name string
  335. body map[string]any
  336. want int
  337. }{
  338. {"类型 1(试听,旧遗留)", withFields(base, "type", 1), 2002},
  339. {"类型 9(不存在)", withFields(base, "type", 9), 2002},
  340. {"时间窗倒挂", withFields(base, "startTime", "2026-12-31", "endTime", "2026-01-01"), 2002},
  341. {"校区不存在", withFields(base, "campusId", 777), 3104},
  342. {"校区属别的机构", withFields(base, "campusId", 20), 1004},
  343. {"机构参数跨作用域", withFields(base, "institutionId", 2), 1004},
  344. {"金额格式非法", withFields(base, "money", "abc"), 2002},
  345. }
  346. for _, tc := range cases {
  347. code, msg, _ := doJSON(t, app, "POST", "/admin/activities", tc.body)
  348. if code != tc.want {
  349. t.Errorf("%s:期望 %d,实得 %d(%s)", tc.name, tc.want, code, msg)
  350. }
  351. }
  352. code, msg, data := doJSON(t, app, "POST", "/admin/activities", base)
  353. if code != 200 {
  354. t.Fatalf("新建期望 200,实得 %d(%s)", code, msg)
  355. }
  356. created := decodeData[struct {
  357. ID int64 `json:"id"`
  358. BizNo string `json:"bizNo"`
  359. }](t, data)
  360. if created.ID <= 0 {
  361. t.Fatalf("新建应回 id>0,实得 %d", created.ID)
  362. }
  363. if created.BizNo != "" {
  364. t.Fatalf("bizNo 不应下发(t_courses_activity 无 biz_no 列):实得 %q", created.BizNo)
  365. }
  366. row := readActivityByID(t, db, created.ID)
  367. if row.InstitutionID != 1 {
  368. t.Fatalf("institution_id 应取作用域机构 1(不信任 body),实得 %d", row.InstitutionID)
  369. }
  370. if row.NumberSign != 0 {
  371. t.Fatalf("number_sign 必须为 0(计数列不得由新建写入),实得 %d", row.NumberSign)
  372. }
  373. if row.Title == nil || *row.Title != "新活动" {
  374. t.Fatalf("title 落库错误:%v", row.Title)
  375. }
  376. }
  377. // ---------------------------------------------------------------- ④ 详情
  378. // TestActivityDetailScopeAndShape 详情的作用域(3001)与字段映射(name/total/signeds[].money)。
  379. //
  380. // 会变红的条件:
  381. // - 去掉 `GetByID` 的作用域 ⇒ 别家机构活动(103)返回 200 → 红;
  382. // - `SignedView.Money` 用 float/未转 money.Amount ⇒ `"100.00"` 变 `100` → 红;
  383. // - `signeds` 用 nil 切片 ⇒ JSON 为 null(断言是 `"signeds":[]`)→ 红。
  384. func TestActivityDetailScopeAndShape(t *testing.T) {
  385. db := newHandlerTestDB(t)
  386. app := newActivityTestApp(t, db)
  387. title := "详情活动"
  388. seedActivityRow(t, db, model.CoursesActivity{
  389. ID: 201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  390. Title: &title, Money: "500.00", NumberMax: 10, Status: 1,
  391. })
  392. seedSignedRow(t, db, 11, 201, "U1", model.ActivitySignedSigned, "500.00", nil)
  393. seedActivityRow(t, db, model.CoursesActivity{
  394. ID: 202, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
  395. Money: "1.00", Status: 1,
  396. })
  397. code, msg, data := doJSON(t, app, "GET", "/admin/activities/201", nil)
  398. if code != 200 {
  399. t.Fatalf("详情期望 200,实得 %d(%s)", code, msg)
  400. }
  401. detail := decodeData[struct {
  402. ID int64 `json:"id"`
  403. Name string `json:"name"`
  404. Total int32 `json:"total"`
  405. Signed int32 `json:"signed"`
  406. Signeds []struct {
  407. ID int64 `json:"id"`
  408. ActivityID int64 `json:"activityId"`
  409. UserID string `json:"userId"`
  410. Status int32 `json:"status"`
  411. Money string `json:"money"`
  412. CreateTime string `json:"createTime"`
  413. } `json:"signeds"`
  414. }](t, data)
  415. if detail.Name != "详情活动" || detail.Total != 10 || detail.Signed != 1 {
  416. t.Fatalf("详情字段映射错:%+v", detail)
  417. }
  418. if len(detail.Signeds) != 1 {
  419. t.Fatalf("详情应内嵌 1 条报名行,实得 %d", len(detail.Signeds))
  420. }
  421. if detail.Signeds[0].Money != "500.00" {
  422. t.Fatalf("signeds[].money 必须是两位小数字符串(activity_money=500.00),实得 %q",
  423. detail.Signeds[0].Money)
  424. }
  425. if !strings.Contains(detail.Signeds[0].CreateTime, "T") {
  426. t.Fatalf("signeds[].createTime 必须是 RFC3339(含 T),实得 %q", detail.Signeds[0].CreateTime)
  427. }
  428. // 跨机构 → 3001(不泄露存在性;若变 200 说明作用域被绕过)
  429. if code, _, _ := doJSON(t, app, "GET", "/admin/activities/202", nil); code != 3001 {
  430. t.Fatalf("跨机构活动详情期望 3001,实得 %d", code)
  431. }
  432. // 空报名列表 → `[]`(不是 null)
  433. seedActivityRow(t, db, model.CoursesActivity{
  434. ID: 203, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  435. Money: "0.00", Status: 1,
  436. })
  437. _, _, emptyData := doJSON(t, app, "GET", "/admin/activities/203", nil)
  438. if !strings.Contains(string(emptyData), `"signeds":[]`) {
  439. t.Fatalf("无报名时 signeds 必须是 [](不得 null):%s", string(emptyData))
  440. }
  441. }
  442. // ---------------------------------------------------------------- ⑤ 报名列表
  443. // TestListSignedsInvalidStatusIs2002 报名列表:status 越界 → 2002;合法空集 → `list:[]`。
  444. //
  445. // 会变红的条件:删掉 0~4 白名单 ⇒ status=5 返回 200 → 红;
  446. // 若列表用 nil 切片 ⇒ `"list":[]` 断言失败 → 红。
  447. func TestListSignedsInvalidStatusIs2002(t *testing.T) {
  448. db := newHandlerTestDB(t)
  449. app := newActivityTestApp(t, db)
  450. seedActivityRow(t, db, model.CoursesActivity{
  451. ID: 301, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  452. Money: "10.00", Status: 1,
  453. })
  454. if code, _, _ := doJSON(t, app, "GET", "/admin/activities/301/signeds?status=5", nil); code != 2002 {
  455. t.Fatalf("status=5 期望 2002,实得 %d", code)
  456. }
  457. code, msg, data := doJSON(t, app, "GET", "/admin/activities/301/signeds", nil)
  458. if code != 200 {
  459. t.Fatalf("报名列表期望 200,实得 %d(%s)", code, msg)
  460. }
  461. if !strings.Contains(string(data), `"list":[]`) {
  462. t.Fatalf("无报名时 list 必须是 []:%s", string(data))
  463. }
  464. // 活动越权 → 3001(**不是** 空列表:两者语义不同)
  465. if code, _, _ := doJSON(t, app, "GET", "/admin/activities/999/signeds", nil); code != 3001 {
  466. t.Fatalf("不存在/越权活动的报名列表期望 3001,实得 %d", code)
  467. }
  468. }
  469. // ---------------------------------------------------------------- ⑥ 更新
  470. // TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001 更新:`numberSign` 传即 3140;跨机构 3001。
  471. //
  472. // 会变红的条件:
  473. // - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 3140 变 200,且 DB 里 number_sign 被改写 → 红;
  474. // - 去掉 `UpdateActivity` 的 `RowsAffected==0` 守卫 ⇒ 更新别家机构活动返回 200 → 红。
  475. func TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001(t *testing.T) {
  476. db := newHandlerTestDB(t)
  477. app := newActivityTestApp(t, db)
  478. seedActivityRow(t, db, model.CoursesActivity{
  479. ID: 401, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  480. Money: "10.00", NumberMax: 3, NumberSign: 2, Status: 1,
  481. })
  482. seedActivityRow(t, db, model.CoursesActivity{
  483. ID: 402, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
  484. Money: "10.00", Status: 1,
  485. })
  486. body := map[string]any{"type": 2, "title": "改标题", "money": "10.00", "status": 1, "numberSign": 7}
  487. code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body)
  488. if code != 3140 {
  489. t.Fatalf("numberSign 传即 3140(契约 §3.4 B 行 25),实得 %d(%s)", code, msg)
  490. }
  491. if got := readActivityByID(t, db, 401).NumberSign; got != 2 {
  492. t.Fatalf("3140 被拒后 number_sign 必须一字不动(2),实得 %d", got)
  493. }
  494. // 去掉 numberSign 后正常更新
  495. delete(body, "numberSign")
  496. if code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body); code != 200 {
  497. t.Fatalf("正常更新期望 200,实得 %d(%s)", code, msg)
  498. }
  499. updated := readActivityByID(t, db, 401)
  500. if updated.Title == nil || *updated.Title != "改标题" {
  501. t.Fatalf("title 未更新:%v", updated.Title)
  502. }
  503. if updated.NumberSign != 2 {
  504. t.Fatalf("更新不得改动 number_sign,实得 %d", updated.NumberSign)
  505. }
  506. // 跨机构 → 3001(RowsAffected==0 守卫;删掉守卫会变 200)
  507. if code, _, _ := doJSON(t, app, "PUT", "/admin/activities/402", body); code != 3001 {
  508. t.Fatalf("跨机构更新期望 3001,实得 %d", code)
  509. }
  510. }
  511. // ---------------------------------------------------------------- ⑦ 报名 / 取消报名
  512. // TestSignThenCancelCrossTableEffects 报名 → 取消的**跨表**效果(端点 26/27)。
  513. //
  514. // 会变红的条件:
  515. // - 去掉 3105 判据 ⇒ 第二次报名返回 200 → 红;
  516. // - 取消时只改 status 不归零金额(少写 activity_money/activity_money_used)⇒ 库值断言红;
  517. // - 取消时漏 `number_sign -1` ⇒ number_sign 仍为 1 → 红;
  518. // - 取消后再查 3001(无有效报名行)⇒ 若变 200 说明"已取消"被判成有效 → 红。
  519. func TestSignThenCancelCrossTableEffects(t *testing.T) {
  520. db := newHandlerTestDB(t)
  521. app := newActivityTestApp(t, db)
  522. seedActivityRow(t, db, model.CoursesActivity{
  523. ID: 501, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  524. Money: "100.00", NumberMax: 0, Status: 1,
  525. })
  526. code, msg, data := doJSON(t, app, "POST", "/admin/activities/501/sign",
  527. map[string]any{"userId": "U100"})
  528. if code != 200 {
  529. t.Fatalf("报名期望 200,实得 %d(%s)", code, msg)
  530. }
  531. sign := decodeData[struct {
  532. SignedID int64 `json:"signedId"`
  533. }](t, data)
  534. if sign.SignedID <= 0 {
  535. t.Fatalf("报名应回 signedId>0,实得 %d", sign.SignedID)
  536. }
  537. if n := countRows(t, db, &model.CoursesActivitySigned{}, "activity_id = ? AND user_id = ?", 501, "U100"); n != 1 {
  538. t.Fatalf("报名行应恰 1 条,实得 %d", n)
  539. }
  540. if got := readActivityByID(t, db, 501).NumberSign; got != 1 {
  541. t.Fatalf("报名后 number_sign 应为 1,实得 %d", got)
  542. }
  543. // 重复报名 → 3105(判**报名行** status)
  544. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/sign",
  545. map[string]any{"userId": "U100"}); code != 3105 {
  546. t.Fatalf("重复报名期望 3105,实得 %d", code)
  547. }
  548. // 取消 → 三列归零 + number_sign-1(跨表断言,不看响应体)
  549. if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
  550. map[string]any{"userId": "U100"}); code != 200 {
  551. t.Fatalf("取消报名期望 200,实得 %d(%s)", code, msg)
  552. }
  553. var signed model.CoursesActivitySigned
  554. if err := db.Where("activity_id = ? AND user_id = ?", 501, "U100").Take(&signed).Error; err != nil {
  555. t.Fatalf("读报名行失败: %v", err)
  556. }
  557. if signed.Status != model.ActivitySignedCancelled {
  558. t.Fatalf("取消后 status 应为 0,实得 %d", signed.Status)
  559. }
  560. if !sameMoney(signed.ActivityMoney, "0.00") || !sameMoney(signed.ActivityMoneyUsed, "0.00") {
  561. t.Fatalf("取消后 activity_money/activity_money_used 必须同置 0.00(dump 763028),实得 %q/%q",
  562. signed.ActivityMoney, signed.ActivityMoneyUsed)
  563. }
  564. if got := readActivityByID(t, db, 501).NumberSign; got != 0 {
  565. t.Fatalf("取消后 number_sign 应为 0,实得 %d", got)
  566. }
  567. // 再次取消 → 3001(已无有效报名行)
  568. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
  569. map[string]any{"userId": "U100"}); code != 3001 {
  570. t.Fatalf("重复取消期望 3001,实得 %d", code)
  571. }
  572. }
  573. // ---------------------------------------------------------------- ⑧ 3107 审核态
  574. // TestCancelSignAuditedIs3107WithZeroSideEffects 已审核(type=2 且 response_user_id 有值)→ 3107,且**零副作用**。
  575. //
  576. // 会变红的条件:
  577. // - 去掉 3107 判据 ⇒ 返回 200 且 status 变 0 → 红;
  578. // - 把 `number_sign-1` 挪到 3107 守卫之前 ⇒ number_sign 变 0(期望 1)→ 红("拒绝必须零副作用")。
  579. func TestCancelSignAuditedIs3107WithZeroSideEffects(t *testing.T) {
  580. db := newHandlerTestDB(t)
  581. app := newActivityTestApp(t, db)
  582. seedActivityRow(t, db, model.CoursesActivity{
  583. ID: 601, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  584. Money: "100.00", NumberSign: 1, Status: 1,
  585. })
  586. auditor := "01HAUDITOR00000000000001"
  587. seedSignedRow(t, db, 61, 601, "U200", model.ActivitySignedSigned, "100.00", &auditor)
  588. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/601/cancel-sign",
  589. map[string]any{"userId": "U200"}); code != 3107 {
  590. t.Fatalf("已审核报名期望 3107,实得 %d", code)
  591. }
  592. var signed model.CoursesActivitySigned
  593. if err := db.Where("id = ?", 61).Take(&signed).Error; err != nil {
  594. t.Fatalf("读报名行失败: %v", err)
  595. }
  596. if signed.Status != model.ActivitySignedSigned {
  597. t.Fatalf("3107 被拒后 status 必须仍为 1,实得 %d", signed.Status)
  598. }
  599. if !sameMoney(signed.ActivityMoney, "100.00") {
  600. t.Fatalf("3107 被拒后 activity_money 必须仍为 100.00,实得 %q", signed.ActivityMoney)
  601. }
  602. if got := readActivityByID(t, db, 601).NumberSign; got != 1 {
  603. t.Fatalf("3107 被拒后 number_sign 必须仍为 1(守卫在 UPDATE 之前),实得 %d", got)
  604. }
  605. }
  606. // ---------------------------------------------------------------- ⑨ 3121 type 读不到
  607. // TestCancelSignUnresolvableTypeIs3121 报名行在、活动行没了 ⇒ `type` 读不到 ⇒ **3121**(§3.8-R8)。
  608. //
  609. // 会变红的条件(**本用例是 R8 的唯一判据**):
  610. // - 把 `ExistsAnySigned` 分支删掉 ⇒ 落回 3001 → 红(那就是"读不到 type 就放行/当成不存在");
  611. // - 反过来把分界写反(有报名行 → 3001)⇒ 期望 3121 实得 3001 → 红。
  612. //
  613. // 为什么这种情况不是 3001:报名行**确实存在**(§3.8-R7 的 3001 是"查不到报名");
  614. // 缺的是活动行 ⇒ 3107/3108 两条审核态保护无法判定 ⇒ 显式 3121,不得静默取消。
  615. func TestCancelSignUnresolvableTypeIs3121(t *testing.T) {
  616. db := newHandlerTestDB(t)
  617. app := newActivityTestApp(t, db)
  618. // 报名行存在(status=1),但活动行**不存在**(模拟活动被物理删除的历史脏数据)
  619. seedSignedRow(t, db, 71, 701, "U300", model.ActivitySignedSigned, "100.00", nil)
  620. if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/701/cancel-sign",
  621. map[string]any{"userId": "U300"}); code != 3121 {
  622. t.Fatalf("取不到 type 期望 3121(§3.8-R8),实得 %d(%s)——"+
  623. "若为 3001 说明把\"报名行存在但活动行缺失\"当成了\"报名不存在\"", code, msg)
  624. }
  625. var signed model.CoursesActivitySigned
  626. if err := db.Where("id = ?", 71).Take(&signed).Error; err != nil {
  627. t.Fatalf("读报名行失败: %v", err)
  628. }
  629. if signed.Status != model.ActivitySignedSigned {
  630. t.Fatalf("3121 被拒后 status 必须仍为 1,实得 %d", signed.Status)
  631. }
  632. }
  633. // ---------------------------------------------------------------- ⑩ 老带新结算
  634. // TestRecommendSettleNoopDoesNotWrite 老带新结算:无 type=4 报名 ⇒ `settled=false` 且**零写入**。
  635. //
  636. // 会变红的条件:
  637. // - handler 把 `settled` 恒写成 true ⇒ 断言 `false` 失败 → 红;
  638. // - 服务层漏掉"游标无行 → LEAVE"⇒ used_log 出现 1 行 → 跨表断言红。
  639. func TestRecommendSettleNoopDoesNotWrite(t *testing.T) {
  640. db := newHandlerTestDB(t)
  641. app := newActivityTestApp(t, db)
  642. seedActivityRow(t, db, model.CoursesActivity{
  643. ID: 801, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  644. Money: "50.00", Status: 1,
  645. })
  646. code, msg, data := doJSON(t, app, "POST", "/admin/activities/801/recommend-settle",
  647. map[string]any{"userId": "U400", "orderNo": "1001"})
  648. if code != 200 {
  649. t.Fatalf("老带新结算期望 200,实得 %d(%s)", code, msg)
  650. }
  651. res := decodeData[struct {
  652. Settled bool `json:"settled"`
  653. ActivityID int64 `json:"activityId"`
  654. }](t, data)
  655. if res.Settled {
  656. t.Fatalf("无 type=4 报名时 settled 必须为 false(dump LEAVE 分支),实得 true")
  657. }
  658. if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ?", 801); n != 0 {
  659. t.Fatalf("settled=false 必须零写入,实得 used_log %d 行", n)
  660. }
  661. }
  662. // ---------------------------------------------------------------- ⑪ 取消充值活动
  663. // TestCancelRechargeUnknownCampusIs3104 校区不存在/未归属 ⇒ **3104**(端点 30)。
  664. //
  665. // 会变红的条件:删掉校区端口调用(或吞掉 3104)⇒ 变 200/5002 → 红。
  666. func TestCancelRechargeUnknownCampusIs3104(t *testing.T) {
  667. db := newHandlerTestDB(t)
  668. app := newActivityTestApp(t, db)
  669. seedActivityRow(t, db, model.CoursesActivity{
  670. ID: 901, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  671. Money: "100.00", Status: 1,
  672. })
  673. code, msg, _ := doJSON(t, app, "POST", "/admin/activities/901/cancel-recharge",
  674. map[string]any{"userId": "U500", "campusId": 999, "money": "10.00"})
  675. if code != 3104 {
  676. t.Fatalf("未知校区期望 3104,实得 %d(%s)", code, msg)
  677. }
  678. }
  679. // ---------------------------------------------------------------- ⑫ 可用赠送
  680. // TestUsableBreakdownScopeAndShape 可用赠送:跨机构 1004 / 平台视角角色 1003 / items 构成与总量一致。
  681. //
  682. // 会变红的条件:
  683. // - 删掉 `institutionId != sc.InstitutionID → 1004` ⇒ 能读到别的机构的池(200)→ 红;
  684. // - 删掉平台视角角色白名单 ⇒ academic 角色拿全平台池(200)→ 红;
  685. // - `usable` 不等于 Σ items 或 items 没按 activityId 升序 ⇒ 红;
  686. // - 加上 `memo` 键(无列支撑)⇒ `"memo"` 不得出现的断言红。
  687. func TestUsableBreakdownScopeAndShape(t *testing.T) {
  688. db := newHandlerTestDB(t)
  689. app := newActivityTestApp(t, db)
  690. // 同机构两块池(活动 1001 余 60.00、活动 1002 余 30.00)+ 别家机构一块(活动 1003 余 999.00)
  691. seedDiscountPool(t, db, 1, 1001, "U600", "100.00", "40.00")
  692. seedDiscountPool(t, db, 2, 1002, "U600", "30.00", "0.00")
  693. seedDiscountPool(t, db, 3, 1003, "U600", "999.00", "0.00")
  694. if err := db.Model(&model.CoursesActivityUserDiscount{}).Where("id = ?", 3).
  695. Update("institution_id", 2).Error; err != nil {
  696. t.Fatalf("改池机构失败: %v", err)
  697. }
  698. code, msg, data := doJSON(t, app, "GET",
  699. "/admin/activities/discounts/usable?userId=U600&institutionId=1&campusId=10", nil)
  700. if code != 200 {
  701. t.Fatalf("可用赠送期望 200,实得 %d(%s)", code, msg)
  702. }
  703. res := decodeData[struct {
  704. UserID string `json:"userId"`
  705. CampusID int64 `json:"campusId"`
  706. Usable string `json:"usable"`
  707. Items []struct {
  708. ActivityID int64 `json:"activityId"`
  709. Amount string `json:"amount"`
  710. } `json:"items"`
  711. }](t, data)
  712. if res.Usable != "90.00" {
  713. t.Fatalf("usable 应为 90.00(60+30,不含别家机构),实得 %q", res.Usable)
  714. }
  715. if len(res.Items) != 2 {
  716. t.Fatalf("items 应有 2 项(按活动),实得 %d", len(res.Items))
  717. }
  718. if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 {
  719. t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items)
  720. }
  721. sum := money.Amount(0)
  722. for _, item := range res.Items {
  723. amount, err := money.Parse(item.Amount)
  724. if err != nil {
  725. t.Fatalf("items[].amount 不是合法金额:%q", item.Amount)
  726. }
  727. sum = sum.Add(amount)
  728. }
  729. usable, _ := money.Parse(res.Usable)
  730. if sum != usable {
  731. t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable, sum.String())
  732. }
  733. if strings.Contains(string(data), `"memo"`) {
  734. t.Fatalf("items 不得下发 memo(赠送池无 memo 列,契约缺口待裁决):%s", string(data))
  735. }
  736. // 跨机构 → 1004
  737. if code, _, _ := doJSON(t, app, "GET",
  738. "/admin/activities/discounts/usable?userId=U600&institutionId=2&campusId=20", nil); code != 1004 {
  739. t.Fatalf("跨机构查询期望 1004,实得 %d", code)
  740. }
  741. // 平台视角(institutionId=0):finance 放行;academic → 1003
  742. if code, _, _ := doJSON(t, app, "GET",
  743. "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0", nil); code != 200 {
  744. t.Fatalf("平台视角(finance)应放行,实得 %d", code)
  745. }
  746. if code, _, _ := doJSON(t, app, "GET",
  747. "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0",
  748. nil, "X-Test-Scope", "academic"); code != 1003 {
  749. t.Fatalf("平台视角 academic 角色期望 1003(设计 §3.2-A7),实得 %d", code)
  750. }
  751. }
  752. // ---------------------------------------------------------------- ⑬ 赠送使用
  753. // TestDiscountUseNoopAndInsufficient 端点 32:金额 0 → `used=false` 且零写入;无池 → **3109**。
  754. //
  755. // 会变红的条件:
  756. // - 把 `amount<=0` 改成报错/或照样扣 ⇒ used/库值断言红(§3.8-Z1 要求"跳过");
  757. // - 删掉"可用不足 → 3109"⇒ 变 200 且池被扣成负 → 红。
  758. func TestDiscountUseNoopAndInsufficient(t *testing.T) {
  759. db := newHandlerTestDB(t)
  760. app := newActivityTestApp(t, db)
  761. seedActivityRow(t, db, model.CoursesActivity{
  762. ID: 1101, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
  763. Money: "100.00", Status: 1,
  764. })
  765. seedDiscountPool(t, db, 11, 1101, "U700", "100.00", "0.00")
  766. // ① 金额 0 → no-op(不是错误)
  767. code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/use",
  768. map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "0.00"})
  769. if code != 200 {
  770. t.Fatalf("金额 0 期望 200(dump no-op),实得 %d(%s)", code, msg)
  771. }
  772. if decodeData[struct {
  773. Used bool `json:"used"`
  774. }](t, data).Used {
  775. t.Fatalf("金额 0 时 used 必须为 false")
  776. }
  777. if n := countRows(t, db, &model.CoursesActivityUserDiscountLog{}, "activity_user_discount_id = ?", 11); n != 0 {
  778. t.Fatalf("金额 0 必须零写入,实得流水 %d 行", n)
  779. }
  780. // ② 超过可用 → 3109(Go 增强:dump 静默 LEAVE)
  781. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/discounts/use",
  782. map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "200.00"}); code != 3109 {
  783. t.Fatalf("可用不足期望 3109,实得 %d", code)
  784. }
  785. // ③ 正常使用 → used=true + 池已用额 + 负值流水(跨表)
  786. //
  787. // ⚠ 活动 type=3(代金券)的口径(§3.2 #15,dump 763229):**实际占用额 = 券面额**,
  788. // 不是请求额("一次用完一张")。故请求 10.00、池却记 100.00 —— 这正是 dump 的既有行为,
  789. // 若有人"顺手修正"成请求额,本断言立刻红(这是**有意锁定**的偏差点,不是测试笔误)。
  790. code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/use",
  791. map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "10.00"})
  792. if code != 200 {
  793. t.Fatalf("正常使用期望 200,实得 %d(%s)", code, msg)
  794. }
  795. if !decodeData[struct {
  796. Used bool `json:"used"`
  797. }](t, data).Used {
  798. t.Fatalf("正常使用 used 应为 true")
  799. }
  800. var pool model.CoursesActivityUserDiscount
  801. if err := db.Where("id = ?", 11).Take(&pool).Error; err != nil {
  802. t.Fatalf("读池失败: %v", err)
  803. }
  804. if !sameMoney(pool.DiscountAmountUsed, "100.00") {
  805. t.Fatalf("代金券(type=3) 已用额应为券面额 100.00(dump 763229),实得 %q", pool.DiscountAmountUsed)
  806. }
  807. var logRow model.CoursesActivityUserDiscountLog
  808. if err := db.Where("activity_user_discount_id = ?", 11).Take(&logRow).Error; err != nil {
  809. t.Fatalf("读流水失败: %v", err)
  810. }
  811. if !sameMoney(logRow.DiscountAmountUsed, "-100.00") {
  812. t.Fatalf("流水必须是负值(消费方向)且等于面额 -100.00,实得 %q", logRow.DiscountAmountUsed)
  813. }
  814. }
  815. // ---------------------------------------------------------------- ⑭ 充值赠送使用
  816. // TestRechargeGiftUseRequiresAttendedStatus 端点 35:报名行必须 `status=2`(dump 游标 763262)。
  817. //
  818. // 会变红的条件:
  819. // - 删掉 `status=2` 前置 ⇒ status=1 也扣(used=true)→ 红;
  820. // - 扣减后 `activity_money_used` 未更新 ⇒ 跨表断言红。
  821. func TestRechargeGiftUseRequiresAttendedStatus(t *testing.T) {
  822. db := newHandlerTestDB(t)
  823. app := newActivityTestApp(t, db)
  824. seedActivityRow(t, db, model.CoursesActivity{
  825. ID: 1201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  826. Money: "100.00", Status: 1,
  827. })
  828. seedSignedRow(t, db, 121, 1201, "U800", model.ActivitySignedSigned, "100.00", nil) // status=1(仅报名)
  829. code, msg, data := doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
  830. map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
  831. if code != 200 {
  832. t.Fatalf("期望 200(no-op),实得 %d(%s)", code, msg)
  833. }
  834. if decodeData[struct {
  835. Used bool `json:"used"`
  836. }](t, data).Used {
  837. t.Fatalf("status=1(未参加)时 used 必须为 false(dump 763262 前置)")
  838. }
  839. // 置为已参加 → 正常扣减
  840. if err := db.Model(&model.CoursesActivitySigned{}).Where("id = ?", 121).
  841. Update("status", model.ActivitySignedAttended).Error; err != nil {
  842. t.Fatalf("改报名状态失败: %v", err)
  843. }
  844. code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
  845. map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
  846. if code != 200 {
  847. t.Fatalf("期望 200,实得 %d(%s)", code, msg)
  848. }
  849. if !decodeData[struct {
  850. Used bool `json:"used"`
  851. }](t, data).Used {
  852. t.Fatalf("status=2 时 used 应为 true")
  853. }
  854. var signed model.CoursesActivitySigned
  855. if err := db.Where("id = ?", 121).Take(&signed).Error; err != nil {
  856. t.Fatalf("读报名行失败: %v", err)
  857. }
  858. if !sameMoney(signed.ActivityMoneyUsed, "30.00") {
  859. t.Fatalf("activity_money_used 应为 30.00,实得 %q", signed.ActivityMoneyUsed)
  860. }
  861. if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ? AND user_id = ?", 1201, "U800"); n != 1 {
  862. t.Fatalf("used_log 应恰 1 行,实得 %d", n)
  863. }
  864. }
  865. // ---------------------------------------------------------------- ⑮ 三条退回
  866. // TestRefundEndpointsReportRefundedFlag 端点 33/34/36:无可用流水 ⇒ `refunded=false`(不是恒 true)。
  867. //
  868. // 会变红的条件:把响应的 `refunded` 写成常量 true("操作成功即 true")⇒ 三条断言全红。
  869. // 此外 33 的正常路径做**跨表**断言(流水回冲 + 池已用额下降)。
  870. func TestRefundEndpointsReportRefundedFlag(t *testing.T) {
  871. db := newHandlerTestDB(t)
  872. app := newActivityTestApp(t, db)
  873. // 端点 33:无流水 → false
  874. code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/refund",
  875. map[string]any{"coursesStudentId": 4242, "amount": "10.00"})
  876. if code != 200 {
  877. t.Fatalf("赠送退回期望 200,实得 %d(%s)", code, msg)
  878. }
  879. if decodeData[struct {
  880. Refunded bool `json:"refunded"`
  881. }](t, data).Refunded {
  882. t.Fatalf("无可用流水时 refunded 必须为 false")
  883. }
  884. // 端点 34:无 log → false
  885. seedActivityRow(t, db, model.CoursesActivity{
  886. ID: 1301, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
  887. Money: "100.00", Status: 1,
  888. })
  889. code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/refund-by-activity",
  890. map[string]any{"userId": "U900", "activityId": 1301, "amount": "10.00"})
  891. if code != 200 {
  892. t.Fatalf("按活动退回期望 200,实得 %d(%s)", code, msg)
  893. }
  894. if decodeData[struct {
  895. Refunded bool `json:"refunded"`
  896. }](t, data).Refunded {
  897. t.Fatalf("无 log 时 refunded 必须为 false(dump no-op)")
  898. }
  899. // 端点 36:无 used_log → false
  900. code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/refund",
  901. map[string]any{"coursesStudentId": 5555, "amount": "10.00"})
  902. if code != 200 {
  903. t.Fatalf("充值赠送退回期望 200,实得 %d(%s)", code, msg)
  904. }
  905. if decodeData[struct {
  906. Refunded bool `json:"refunded"`
  907. }](t, data).Refunded {
  908. t.Fatalf("无 used_log 时 refunded 必须为 false")
  909. }
  910. }
  911. // ---------------------------------------------------------------- ⑯ 回访
  912. // TestVisitRejectsCrossActivitySigned 回访:报名行不属于路径活动 ⇒ **3001**;正常 ⇒ 落库 1 行。
  913. //
  914. // 会变红的条件:
  915. // - 删掉 `signed.ActivityID != in.ActivityID` 判断 ⇒ 跨活动回访 200 且写出一行(无法事后筛出)→ 红;
  916. // - `visited` 恒 false 或漏写库 ⇒ 跨表断言红。
  917. func TestVisitRejectsCrossActivitySigned(t *testing.T) {
  918. db := newHandlerTestDB(t)
  919. app := newActivityTestApp(t, db)
  920. seedActivityRow(t, db, model.CoursesActivity{
  921. ID: 1401, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  922. Money: "0.00", Status: 1,
  923. })
  924. seedActivityRow(t, db, model.CoursesActivity{
  925. ID: 1402, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  926. Money: "0.00", Status: 1,
  927. })
  928. seedSignedRow(t, db, 141, 1402, "U950", model.ActivitySignedSigned, "0.00", nil)
  929. // 报名行 141 属于活动 1402,却提交到活动 1401 的回访端点 → 3001
  930. code, msg, _ := doJSON(t, app, "POST", "/admin/activities/1401/visits",
  931. map[string]any{"activitySignedId": 141, "memo": "电话回访"})
  932. if code != 3001 {
  933. t.Fatalf("跨活动回访期望 3001,实得 %d(%s)", code, msg)
  934. }
  935. if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 0 {
  936. t.Fatalf("被拒的跨活动回访不得写库,实得 %d 行", n)
  937. }
  938. // 正确活动 → 200 + 1 行 visit
  939. code, msg, data := doJSON(t, app, "POST", "/admin/activities/1402/visits",
  940. map[string]any{"activitySignedId": 141, "memo": "电话回访"})
  941. if code != 200 {
  942. t.Fatalf("回访登记期望 200,实得 %d(%s)", code, msg)
  943. }
  944. res := decodeData[struct {
  945. Visited bool `json:"visited"`
  946. ActivityID int64 `json:"activityId"`
  947. }](t, data)
  948. if !res.Visited || res.ActivityID != 1402 {
  949. t.Fatalf("回访响应错:%+v", res)
  950. }
  951. if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 1 {
  952. t.Fatalf("回访应落库 1 行,实得 %d", n)
  953. }
  954. }
  955. // sameMoney 比较两个元字符串的**金额值**(不比较字面量)。
  956. //
  957. // 为什么不能直接比字符串:sqlite 把 `DECIMAL(12,2)` 存成 NUMERIC,读回时 `"0.00"` 会变成 `"0"`、
  958. // `"100.00"` 变成 `"100"`(MySQL 的 DECIMAL 会原样返回两位)。断言必须比"值",否则同一用例
  959. // 在 sqlite 上红、在 MySQL 上绿 —— 那是"测试环境差异",不是实现差异。
  960. // 生产语义(对外 JSON)由 `money.Amount.MarshalJSON` 保证恒两位小数,不在本助手范围。
  961. func sameMoney(got, want string) bool {
  962. gotAmount, gerr := money.Parse(got)
  963. wantAmount, werr := money.Parse(want)
  964. if gerr != nil || werr != nil {
  965. return false
  966. }
  967. return gotAmount == wantAmount
  968. }
  969. // withFields 复制 base 并覆盖若干 k/v(k,v 交替),避免用例间互相污染 body。
  970. func withFields(base map[string]any, kv ...any) map[string]any {
  971. out := make(map[string]any, len(base)+len(kv)/2)
  972. for k, v := range base {
  973. out[k] = v
  974. }
  975. for i := 0; i+1 < len(kv); i += 2 {
  976. key, _ := kv[i].(string)
  977. out[key] = kv[i+1]
  978. }
  979. return out
  980. }