activity_handler_test.go 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082
  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. // ⑥ 更新:2002(numberSign)/3001 → TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001
  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. // 活动级 money(v1.6 契约补入):来自 `t_courses_activity.money`,两位小数字符串。
  280. // 会变红的条件:① DTO 漏掉 Money 字段 ⇒ 得 "";② 用 float 输出 ⇒ 声明为 string 此处 unmarshal 失败;
  281. // ③ 拿 signeds[].money 顶上 ⇒ 值可能仍是 100.00,故另用活动 102(money=0.00、无报名)区分层级。
  282. if first.Money != "100.00" {
  283. t.Fatalf("活动级 money 应为 \"100.00\"(← t_courses_activity.money),实得 %q", first.Money)
  284. }
  285. if stopped, ok := byID[102]; ok {
  286. if stopped.Money != "0.00" {
  287. t.Fatalf("无报名的停用活动 money 应为 \"0.00\"(活动级、与报名无关),实得 %q——"+
  288. "若非 0.00 说明把 signeds[].money 当成了活动金额", stopped.Money)
  289. }
  290. }
  291. if !first.Signable {
  292. t.Fatalf("活动 101 应为 signable(status=1、未满员、无时间窗约束)")
  293. }
  294. disabled := byID[102]
  295. if disabled.Signable {
  296. t.Fatalf("活动 102 status=0(停用)应 signable=false,实得 true")
  297. }
  298. // 空数组而非 null:`list` 必须在 data 顶层且为数组
  299. if !strings.Contains(string(data), `"list":[`) {
  300. t.Fatalf("data.list 必须是数组(不得为 null/缺键):%s", string(data))
  301. }
  302. }
  303. // ActivityViewForTest 列表行(只声明本用例要断言的字段,避免抄一份 DTO 造成"两处定义")。
  304. type ActivityViewForTest struct {
  305. ID int64 `json:"id"`
  306. Name string `json:"name"`
  307. Total int32 `json:"total"`
  308. NumberSign int32 `json:"numberSign"`
  309. Signed int32 `json:"signed"`
  310. // Money 声明为 **string**:若后端下发数值(number)会在此处 unmarshal 失败 ⇒
  311. // "金额必须是两位小数字符串"这条不用额外断言也守住了。
  312. Money string `json:"money"`
  313. Signable bool `json:"signable"`
  314. Type int8 `json:"type"`
  315. }
  316. // TestListActivitiesInvalidEnumsAre2002 type/status 过滤枚举越界 → **2002**(不是 2001、不是静默忽略)。
  317. //
  318. // 会变红的条件:删掉 `parseEnumQuery` 的白名单判断 ⇒ 变成 200 且返回全量列表 → 红。
  319. func TestListActivitiesInvalidEnumsAre2002(t *testing.T) {
  320. db := newHandlerTestDB(t)
  321. app := newActivityTestApp(t, db)
  322. for _, q := range []string{"type=1", "type=9", "status=3", "status=abc"} {
  323. code, _, _ := doJSON(t, app, "GET", "/admin/activities?"+q, nil)
  324. if code != 2002 {
  325. t.Errorf("查询 %s 期望 2002(枚举/格式非法),实得 %d", q, code)
  326. }
  327. }
  328. }
  329. // ---------------------------------------------------------------- ③ 新建
  330. // TestCreateActivityValidationAndCrossCampus 新建活动的四类拒绝 + 成功路径的库值断言。
  331. //
  332. // 会变红的条件:
  333. // - 删除 `validateActivityFields` 的 type 白名单 ⇒ type=1 变 200 → 红;
  334. // - 删除时间窗倒挂判断 ⇒ start>end 变 200 → 红;
  335. // - 删除 `assertCampusInScope` ⇒ 传别家机构校区(20)变 200 → 红(**这是 1004 越权判据**);
  336. // - 删除校区端口调用 ⇒ 不存在的校区(777)变 5002/200 而不是 3104 → 红;
  337. // - 成功路径:`institution_id` 必须取作用域(1),且 `number_sign` 必须为 0。
  338. func TestCreateActivityValidationAndCrossCampus(t *testing.T) {
  339. db := newHandlerTestDB(t)
  340. app := newActivityTestApp(t, db)
  341. // 造一个"属于机构 2"的校区(跨机构写入判据;测试库默认只有 10 → 机构 1)
  342. if err := db.Exec(
  343. `INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(20,2,1,'active')`,
  344. ).Error; err != nil {
  345. t.Fatalf("seed t_campus(20) 失败: %v", err)
  346. }
  347. base := map[string]any{"type": 2, "title": "新活动", "money": "100.00"}
  348. cases := []struct {
  349. name string
  350. body map[string]any
  351. want int
  352. }{
  353. {"类型 1(试听,旧遗留)", withFields(base, "type", 1), 2002},
  354. {"类型 9(不存在)", withFields(base, "type", 9), 2002},
  355. {"时间窗倒挂", withFields(base, "startTime", "2026-12-31", "endTime", "2026-01-01"), 2002},
  356. {"校区不存在", withFields(base, "campusId", 777), 3104},
  357. {"校区属别的机构", withFields(base, "campusId", 20), 1004},
  358. {"机构参数跨作用域", withFields(base, "institutionId", 2), 1004},
  359. {"金额格式非法", withFields(base, "money", "abc"), 2002},
  360. }
  361. for _, tc := range cases {
  362. code, msg, _ := doJSON(t, app, "POST", "/admin/activities", tc.body)
  363. if code != tc.want {
  364. t.Errorf("%s:期望 %d,实得 %d(%s)", tc.name, tc.want, code, msg)
  365. }
  366. }
  367. code, msg, data := doJSON(t, app, "POST", "/admin/activities", base)
  368. if code != 200 {
  369. t.Fatalf("新建期望 200,实得 %d(%s)", code, msg)
  370. }
  371. created := decodeData[struct {
  372. ID int64 `json:"id"`
  373. BizNo string `json:"bizNo"`
  374. }](t, data)
  375. if created.ID <= 0 {
  376. t.Fatalf("新建应回 id>0,实得 %d", created.ID)
  377. }
  378. if created.BizNo != "" {
  379. t.Fatalf("bizNo 不应下发(t_courses_activity 无 biz_no 列):实得 %q", created.BizNo)
  380. }
  381. row := readActivityByID(t, db, created.ID)
  382. if row.InstitutionID != 1 {
  383. t.Fatalf("institution_id 应取作用域机构 1(不信任 body),实得 %d", row.InstitutionID)
  384. }
  385. if row.NumberSign != 0 {
  386. t.Fatalf("number_sign 必须为 0(计数列不得由新建写入),实得 %d", row.NumberSign)
  387. }
  388. if row.Title == nil || *row.Title != "新活动" {
  389. t.Fatalf("title 落库错误:%v", row.Title)
  390. }
  391. }
  392. // ---------------------------------------------------------------- ④ 详情
  393. // TestActivityDetailScopeAndShape 详情的作用域(3001)与字段映射(name/total/signeds[].money)。
  394. //
  395. // 会变红的条件:
  396. // - 去掉 `GetByID` 的作用域 ⇒ 别家机构活动(103)返回 200 → 红;
  397. // - `SignedView.Money` 用 float/未转 money.Amount ⇒ `"100.00"` 变 `100` → 红;
  398. // - `signeds` 用 nil 切片 ⇒ JSON 为 null(断言是 `"signeds":[]`)→ 红。
  399. func TestActivityDetailScopeAndShape(t *testing.T) {
  400. db := newHandlerTestDB(t)
  401. app := newActivityTestApp(t, db)
  402. title := "详情活动"
  403. seedActivityRow(t, db, model.CoursesActivity{
  404. ID: 201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  405. Title: &title, Money: "500.00", NumberMax: 10, Status: 1,
  406. })
  407. seedSignedRow(t, db, 11, 201, "U1", model.ActivitySignedSigned, "500.00", nil)
  408. seedActivityRow(t, db, model.CoursesActivity{
  409. ID: 202, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
  410. Money: "1.00", Status: 1,
  411. })
  412. code, msg, data := doJSON(t, app, "GET", "/admin/activities/201", nil)
  413. if code != 200 {
  414. t.Fatalf("详情期望 200,实得 %d(%s)", code, msg)
  415. }
  416. detail := decodeData[struct {
  417. ID int64 `json:"id"`
  418. Name string `json:"name"`
  419. Total int32 `json:"total"`
  420. Signed int32 `json:"signed"`
  421. Signeds []struct {
  422. ID int64 `json:"id"`
  423. ActivityID int64 `json:"activityId"`
  424. UserID string `json:"userId"`
  425. Status int32 `json:"status"`
  426. Money string `json:"money"`
  427. CreateTime string `json:"createTime"`
  428. } `json:"signeds"`
  429. }](t, data)
  430. if detail.Name != "详情活动" || detail.Total != 10 || detail.Signed != 1 {
  431. t.Fatalf("详情字段映射错:%+v", detail)
  432. }
  433. if len(detail.Signeds) != 1 {
  434. t.Fatalf("详情应内嵌 1 条报名行,实得 %d", len(detail.Signeds))
  435. }
  436. if detail.Signeds[0].Money != "500.00" {
  437. t.Fatalf("signeds[].money 必须是两位小数字符串(activity_money=500.00),实得 %q",
  438. detail.Signeds[0].Money)
  439. }
  440. if !strings.Contains(detail.Signeds[0].CreateTime, "T") {
  441. t.Fatalf("signeds[].createTime 必须是 RFC3339(含 T),实得 %q", detail.Signeds[0].CreateTime)
  442. }
  443. // 跨机构 → 3001(不泄露存在性;若变 200 说明作用域被绕过)
  444. if code, _, _ := doJSON(t, app, "GET", "/admin/activities/202", nil); code != 3001 {
  445. t.Fatalf("跨机构活动详情期望 3001,实得 %d", code)
  446. }
  447. // 空报名列表 → `[]`(不是 null)
  448. seedActivityRow(t, db, model.CoursesActivity{
  449. ID: 203, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  450. Money: "0.00", Status: 1,
  451. })
  452. _, _, emptyData := doJSON(t, app, "GET", "/admin/activities/203", nil)
  453. if !strings.Contains(string(emptyData), `"signeds":[]`) {
  454. t.Fatalf("无报名时 signeds 必须是 [](不得 null):%s", string(emptyData))
  455. }
  456. }
  457. // ---------------------------------------------------------------- ⑤ 报名列表
  458. // TestListSignedsInvalidStatusIs2002 报名列表:status 越界 → 2002;合法空集 → `list:[]`。
  459. //
  460. // 会变红的条件:删掉 0~4 白名单 ⇒ status=5 返回 200 → 红;
  461. // 若列表用 nil 切片 ⇒ `"list":[]` 断言失败 → 红。
  462. func TestListSignedsInvalidStatusIs2002(t *testing.T) {
  463. db := newHandlerTestDB(t)
  464. app := newActivityTestApp(t, db)
  465. seedActivityRow(t, db, model.CoursesActivity{
  466. ID: 301, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  467. Money: "10.00", Status: 1,
  468. })
  469. if code, _, _ := doJSON(t, app, "GET", "/admin/activities/301/signeds?status=5", nil); code != 2002 {
  470. t.Fatalf("status=5 期望 2002,实得 %d", code)
  471. }
  472. code, msg, data := doJSON(t, app, "GET", "/admin/activities/301/signeds", nil)
  473. if code != 200 {
  474. t.Fatalf("报名列表期望 200,实得 %d(%s)", code, msg)
  475. }
  476. if !strings.Contains(string(data), `"list":[]`) {
  477. t.Fatalf("无报名时 list 必须是 []:%s", string(data))
  478. }
  479. // 活动越权 → 3001(**不是** 空列表:两者语义不同)
  480. if code, _, _ := doJSON(t, app, "GET", "/admin/activities/999/signeds", nil); code != 3001 {
  481. t.Fatalf("不存在/越权活动的报名列表期望 3001,实得 %d", code)
  482. }
  483. }
  484. // ---------------------------------------------------------------- ⑥ 更新
  485. // TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001 更新:`numberSign` 传即 2002;跨机构 3001。
  486. //
  487. // 为什么是 2002 不是 3140(v1.6 契约更正):设计 §10.1 限定「3140 只收**参数之间**互相矛盾」;
  488. // "传了一个不可写字段"属**单一字段取值非法** ⇒ 2002。3140 若被塞这类判断,会退化成垃圾桶。
  489. //
  490. // 会变红的条件:
  491. // - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 2002 变 200,且 DB 里 number_sign 被改写 → 红;
  492. // - 把错误码改回 3140 ⇒ 实得 3140 ≠ 2002 → 红(守住"契约 v1.6 的这一处更正");
  493. // - 去掉 `UpdateActivity` 的 `RowsAffected==0` 守卫 ⇒ 更新别家机构活动返回 200 → 红。
  494. func TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001(t *testing.T) {
  495. db := newHandlerTestDB(t)
  496. app := newActivityTestApp(t, db)
  497. seedActivityRow(t, db, model.CoursesActivity{
  498. ID: 401, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  499. Money: "10.00", NumberMax: 3, NumberSign: 2, Status: 1,
  500. })
  501. seedActivityRow(t, db, model.CoursesActivity{
  502. ID: 402, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
  503. Money: "10.00", Status: 1,
  504. })
  505. body := map[string]any{"type": 2, "title": "改标题", "money": "10.00", "status": 1, "numberSign": 7}
  506. code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body)
  507. if code != 2002 {
  508. t.Fatalf("numberSign 传即 2002(契约 §3.4 B 行 25,v1.6),实得 %d(%s)", code, msg)
  509. }
  510. if got := readActivityByID(t, db, 401).NumberSign; got != 2 {
  511. t.Fatalf("2002 被拒后 number_sign 必须一字不动(2),实得 %d", got)
  512. }
  513. // 去掉 numberSign 后正常更新
  514. delete(body, "numberSign")
  515. if code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body); code != 200 {
  516. t.Fatalf("正常更新期望 200,实得 %d(%s)", code, msg)
  517. }
  518. updated := readActivityByID(t, db, 401)
  519. if updated.Title == nil || *updated.Title != "改标题" {
  520. t.Fatalf("title 未更新:%v", updated.Title)
  521. }
  522. if updated.NumberSign != 2 {
  523. t.Fatalf("更新不得改动 number_sign,实得 %d", updated.NumberSign)
  524. }
  525. // 跨机构 → 3001(RowsAffected==0 守卫;删掉守卫会变 200)
  526. if code, _, _ := doJSON(t, app, "PUT", "/admin/activities/402", body); code != 3001 {
  527. t.Fatalf("跨机构更新期望 3001,实得 %d", code)
  528. }
  529. }
  530. // ---------------------------------------------------------------- ⑦ 报名 / 取消报名
  531. // TestSignThenCancelCrossTableEffects 报名 → 取消的**跨表**效果(端点 26/27)。
  532. //
  533. // 会变红的条件:
  534. // - 去掉 3105 判据 ⇒ 第二次报名返回 200 → 红;
  535. // - 取消时只改 status 不归零金额(少写 activity_money/activity_money_used)⇒ 库值断言红;
  536. // - 取消时漏 `number_sign -1` ⇒ number_sign 仍为 1 → 红;
  537. // - 取消后再查 3001(无有效报名行)⇒ 若变 200 说明"已取消"被判成有效 → 红。
  538. func TestSignThenCancelCrossTableEffects(t *testing.T) {
  539. db := newHandlerTestDB(t)
  540. app := newActivityTestApp(t, db)
  541. seedActivityRow(t, db, model.CoursesActivity{
  542. ID: 501, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  543. Money: "100.00", NumberMax: 0, Status: 1,
  544. })
  545. code, msg, data := doJSON(t, app, "POST", "/admin/activities/501/sign",
  546. map[string]any{"userId": "U100"})
  547. if code != 200 {
  548. t.Fatalf("报名期望 200,实得 %d(%s)", code, msg)
  549. }
  550. sign := decodeData[struct {
  551. SignedID int64 `json:"signedId"`
  552. }](t, data)
  553. if sign.SignedID <= 0 {
  554. t.Fatalf("报名应回 signedId>0,实得 %d", sign.SignedID)
  555. }
  556. if n := countRows(t, db, &model.CoursesActivitySigned{}, "activity_id = ? AND user_id = ?", 501, "U100"); n != 1 {
  557. t.Fatalf("报名行应恰 1 条,实得 %d", n)
  558. }
  559. if got := readActivityByID(t, db, 501).NumberSign; got != 1 {
  560. t.Fatalf("报名后 number_sign 应为 1,实得 %d", got)
  561. }
  562. // 重复报名 → 3105(判**报名行** status)
  563. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/sign",
  564. map[string]any{"userId": "U100"}); code != 3105 {
  565. t.Fatalf("重复报名期望 3105,实得 %d", code)
  566. }
  567. // 取消 → 三列归零 + number_sign-1(跨表断言,不看响应体)
  568. if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
  569. map[string]any{"userId": "U100"}); code != 200 {
  570. t.Fatalf("取消报名期望 200,实得 %d(%s)", code, msg)
  571. }
  572. var signed model.CoursesActivitySigned
  573. if err := db.Where("activity_id = ? AND user_id = ?", 501, "U100").Take(&signed).Error; err != nil {
  574. t.Fatalf("读报名行失败: %v", err)
  575. }
  576. if signed.Status != model.ActivitySignedCancelled {
  577. t.Fatalf("取消后 status 应为 0,实得 %d", signed.Status)
  578. }
  579. if !sameMoney(signed.ActivityMoney, "0.00") || !sameMoney(signed.ActivityMoneyUsed, "0.00") {
  580. t.Fatalf("取消后 activity_money/activity_money_used 必须同置 0.00(dump 763028),实得 %q/%q",
  581. signed.ActivityMoney, signed.ActivityMoneyUsed)
  582. }
  583. if got := readActivityByID(t, db, 501).NumberSign; got != 0 {
  584. t.Fatalf("取消后 number_sign 应为 0,实得 %d", got)
  585. }
  586. // 再次取消 → 3001(已无有效报名行)
  587. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
  588. map[string]any{"userId": "U100"}); code != 3001 {
  589. t.Fatalf("重复取消期望 3001,实得 %d", code)
  590. }
  591. }
  592. // ---------------------------------------------------------------- ⑧ 3107 审核态
  593. // TestCancelSignAuditedIs3107WithZeroSideEffects 已审核(type=2 且 response_user_id 有值)→ 3107,且**零副作用**。
  594. //
  595. // 会变红的条件:
  596. // - 去掉 3107 判据 ⇒ 返回 200 且 status 变 0 → 红;
  597. // - 把 `number_sign-1` 挪到 3107 守卫之前 ⇒ number_sign 变 0(期望 1)→ 红("拒绝必须零副作用")。
  598. func TestCancelSignAuditedIs3107WithZeroSideEffects(t *testing.T) {
  599. db := newHandlerTestDB(t)
  600. app := newActivityTestApp(t, db)
  601. seedActivityRow(t, db, model.CoursesActivity{
  602. ID: 601, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  603. Money: "100.00", NumberSign: 1, Status: 1,
  604. })
  605. auditor := "01HAUDITOR00000000000001"
  606. seedSignedRow(t, db, 61, 601, "U200", model.ActivitySignedSigned, "100.00", &auditor)
  607. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/601/cancel-sign",
  608. map[string]any{"userId": "U200"}); code != 3107 {
  609. t.Fatalf("已审核报名期望 3107,实得 %d", code)
  610. }
  611. var signed model.CoursesActivitySigned
  612. if err := db.Where("id = ?", 61).Take(&signed).Error; err != nil {
  613. t.Fatalf("读报名行失败: %v", err)
  614. }
  615. if signed.Status != model.ActivitySignedSigned {
  616. t.Fatalf("3107 被拒后 status 必须仍为 1,实得 %d", signed.Status)
  617. }
  618. if !sameMoney(signed.ActivityMoney, "100.00") {
  619. t.Fatalf("3107 被拒后 activity_money 必须仍为 100.00,实得 %q", signed.ActivityMoney)
  620. }
  621. if got := readActivityByID(t, db, 601).NumberSign; got != 1 {
  622. t.Fatalf("3107 被拒后 number_sign 必须仍为 1(守卫在 UPDATE 之前),实得 %d", got)
  623. }
  624. }
  625. // ---------------------------------------------------------------- ⑨ 3121 type 读不到
  626. // TestCancelSignUnresolvableTypeIs3121 报名行在、活动行没了 ⇒ `type` 读不到 ⇒ **3121**(§3.8-R8)。
  627. //
  628. // 会变红的条件(**本用例是 R8 的唯一判据**):
  629. // - 把 `ExistsAnySigned` 分支删掉 ⇒ 落回 3001 → 红(那就是"读不到 type 就放行/当成不存在");
  630. // - 反过来把分界写反(有报名行 → 3001)⇒ 期望 3121 实得 3001 → 红。
  631. //
  632. // 为什么这种情况不是 3001:报名行**确实存在**(§3.8-R7 的 3001 是"查不到报名");
  633. // 缺的是活动行 ⇒ 3107/3108 两条审核态保护无法判定 ⇒ 显式 3121,不得静默取消。
  634. func TestCancelSignUnresolvableTypeIs3121(t *testing.T) {
  635. db := newHandlerTestDB(t)
  636. app := newActivityTestApp(t, db)
  637. // 报名行存在(status=1),但活动行**不存在**(模拟活动被物理删除的历史脏数据)
  638. seedSignedRow(t, db, 71, 701, "U300", model.ActivitySignedSigned, "100.00", nil)
  639. if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/701/cancel-sign",
  640. map[string]any{"userId": "U300"}); code != 3121 {
  641. t.Fatalf("取不到 type 期望 3121(§3.8-R8),实得 %d(%s)——"+
  642. "若为 3001 说明把\"报名行存在但活动行缺失\"当成了\"报名不存在\"", code, msg)
  643. }
  644. var signed model.CoursesActivitySigned
  645. if err := db.Where("id = ?", 71).Take(&signed).Error; err != nil {
  646. t.Fatalf("读报名行失败: %v", err)
  647. }
  648. if signed.Status != model.ActivitySignedSigned {
  649. t.Fatalf("3121 被拒后 status 必须仍为 1,实得 %d", signed.Status)
  650. }
  651. }
  652. // ---------------------------------------------------------------- ⑩ 老带新结算
  653. // TestRecommendSettleNoopDoesNotWrite 老带新结算:无 type=4 报名 ⇒ `settled=false` 且**零写入**。
  654. //
  655. // 会变红的条件:
  656. // - handler 把 `settled` 恒写成 true ⇒ 断言 `false` 失败 → 红;
  657. // - 服务层漏掉"游标无行 → LEAVE"⇒ used_log 出现 1 行 → 跨表断言红。
  658. func TestRecommendSettleNoopDoesNotWrite(t *testing.T) {
  659. db := newHandlerTestDB(t)
  660. app := newActivityTestApp(t, db)
  661. seedActivityRow(t, db, model.CoursesActivity{
  662. ID: 801, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  663. Money: "50.00", Status: 1,
  664. })
  665. code, msg, data := doJSON(t, app, "POST", "/admin/activities/801/recommend-settle",
  666. map[string]any{"userId": "U400", "orderNo": "1001"})
  667. if code != 200 {
  668. t.Fatalf("老带新结算期望 200,实得 %d(%s)", code, msg)
  669. }
  670. res := decodeData[struct {
  671. Settled bool `json:"settled"`
  672. ActivityID int64 `json:"activityId"`
  673. }](t, data)
  674. if res.Settled {
  675. t.Fatalf("无 type=4 报名时 settled 必须为 false(dump LEAVE 分支),实得 true")
  676. }
  677. if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ?", 801); n != 0 {
  678. t.Fatalf("settled=false 必须零写入,实得 used_log %d 行", n)
  679. }
  680. }
  681. // ---------------------------------------------------------------- ⑪ 取消充值活动
  682. // TestCancelRechargeUnknownCampusIs3104 校区不存在/未归属 ⇒ **3104**(端点 30)。
  683. //
  684. // 会变红的条件:删掉校区端口调用(或吞掉 3104)⇒ 变 200/5002 → 红。
  685. func TestCancelRechargeUnknownCampusIs3104(t *testing.T) {
  686. db := newHandlerTestDB(t)
  687. app := newActivityTestApp(t, db)
  688. seedActivityRow(t, db, model.CoursesActivity{
  689. ID: 901, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  690. Money: "100.00", Status: 1,
  691. })
  692. code, msg, _ := doJSON(t, app, "POST", "/admin/activities/901/cancel-recharge",
  693. map[string]any{"userId": "U500", "campusId": 999, "money": "10.00"})
  694. if code != 3104 {
  695. t.Fatalf("未知校区期望 3104,实得 %d(%s)", code, msg)
  696. }
  697. }
  698. // ---------------------------------------------------------------- ⑫ 可用赠送
  699. // TestUsableBreakdownScopeAndShape 可用赠送:跨机构 1004 / 平台视角角色 1003 / items 构成与总量一致。
  700. //
  701. // 会变红的条件:
  702. // - 删掉 `institutionId != sc.InstitutionID → 1004` ⇒ 能读到别的机构的池(200)→ 红;
  703. // - 删掉平台视角角色白名单 ⇒ academic 角色拿全平台池(200)→ 红;
  704. // - `usable` 不等于 Σ items 或 items 没按 activityId 升序 ⇒ 红;
  705. // - 加上 `memo` 键(无列支撑)⇒ `"memo"` 不得出现的断言红。
  706. func TestUsableBreakdownScopeAndShape(t *testing.T) {
  707. db := newHandlerTestDB(t)
  708. app := newActivityTestApp(t, db)
  709. // 同机构两块池(活动 1001 余 60.00、活动 1002 余 30.00)+ 别家机构一块(活动 1003 余 999.00)
  710. seedDiscountPool(t, db, 1, 1001, "U600", "100.00", "40.00")
  711. seedDiscountPool(t, db, 2, 1002, "U600", "30.00", "0.00")
  712. seedDiscountPool(t, db, 3, 1003, "U600", "999.00", "0.00")
  713. if err := db.Model(&model.CoursesActivityUserDiscount{}).Where("id = ?", 3).
  714. Update("institution_id", 2).Error; err != nil {
  715. t.Fatalf("改池机构失败: %v", err)
  716. }
  717. code, msg, data := doJSON(t, app, "GET",
  718. "/admin/activities/discounts/usable?userId=U600&institutionId=1&campusId=10", nil)
  719. if code != 200 {
  720. t.Fatalf("可用赠送期望 200,实得 %d(%s)", code, msg)
  721. }
  722. res := decodeData[struct {
  723. UserID string `json:"userId"`
  724. CampusID int64 `json:"campusId"`
  725. Usable string `json:"usable"`
  726. Items []struct {
  727. ActivityID int64 `json:"activityId"`
  728. Amount string `json:"amount"`
  729. } `json:"items"`
  730. }](t, data)
  731. if res.Usable != "90.00" {
  732. t.Fatalf("usable 应为 90.00(60+30,不含别家机构),实得 %q", res.Usable)
  733. }
  734. if len(res.Items) != 2 {
  735. t.Fatalf("items 应有 2 项(按活动),实得 %d", len(res.Items))
  736. }
  737. if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 {
  738. t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items)
  739. }
  740. sum := money.Amount(0)
  741. for _, item := range res.Items {
  742. amount, err := money.Parse(item.Amount)
  743. if err != nil {
  744. t.Fatalf("items[].amount 不是合法金额:%q", item.Amount)
  745. }
  746. sum = sum.Add(amount)
  747. }
  748. usable, _ := money.Parse(res.Usable)
  749. if sum != usable {
  750. t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable, sum.String())
  751. }
  752. if strings.Contains(string(data), `"memo"`) {
  753. t.Fatalf("items 不得下发 memo(赠送池无 memo 列,契约缺口待裁决):%s", string(data))
  754. }
  755. // 跨机构 → 1004
  756. if code, _, _ := doJSON(t, app, "GET",
  757. "/admin/activities/discounts/usable?userId=U600&institutionId=2&campusId=20", nil); code != 1004 {
  758. t.Fatalf("跨机构查询期望 1004,实得 %d", code)
  759. }
  760. // 平台视角(institutionId=0):finance 放行;academic → 1003
  761. if code, _, _ := doJSON(t, app, "GET",
  762. "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0", nil); code != 200 {
  763. t.Fatalf("平台视角(finance)应放行,实得 %d", code)
  764. }
  765. if code, _, _ := doJSON(t, app, "GET",
  766. "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0",
  767. nil, "X-Test-Scope", "academic"); code != 1003 {
  768. t.Fatalf("平台视角 academic 角色期望 1003(设计 §3.2-A7),实得 %d", code)
  769. }
  770. }
  771. // ---------------------------------------------------------------- ⑬ 赠送使用
  772. // TestDiscountUseNoopAndInsufficient 端点 32:金额 0 → `used=false` 且零写入;无池 → **3109**。
  773. //
  774. // 会变红的条件:
  775. // - 把 `amount<=0` 改成报错/或照样扣 ⇒ used/库值断言红(§3.8-Z1 要求"跳过");
  776. // - 删掉"可用不足 → 3109"⇒ 变 200 且池被扣成负 → 红。
  777. func TestDiscountUseNoopAndInsufficient(t *testing.T) {
  778. db := newHandlerTestDB(t)
  779. app := newActivityTestApp(t, db)
  780. seedActivityRow(t, db, model.CoursesActivity{
  781. ID: 1101, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
  782. Money: "100.00", Status: 1,
  783. })
  784. seedDiscountPool(t, db, 11, 1101, "U700", "100.00", "0.00")
  785. // ① 金额 0 → no-op(不是错误)
  786. code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/use",
  787. map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "0.00"})
  788. if code != 200 {
  789. t.Fatalf("金额 0 期望 200(dump no-op),实得 %d(%s)", code, msg)
  790. }
  791. if decodeData[struct {
  792. Used bool `json:"used"`
  793. }](t, data).Used {
  794. t.Fatalf("金额 0 时 used 必须为 false")
  795. }
  796. if n := countRows(t, db, &model.CoursesActivityUserDiscountLog{}, "activity_user_discount_id = ?", 11); n != 0 {
  797. t.Fatalf("金额 0 必须零写入,实得流水 %d 行", n)
  798. }
  799. // ② 超过可用 → 3109(Go 增强:dump 静默 LEAVE)
  800. if code, _, _ := doJSON(t, app, "POST", "/admin/activities/discounts/use",
  801. map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "200.00"}); code != 3109 {
  802. t.Fatalf("可用不足期望 3109,实得 %d", code)
  803. }
  804. // ③ 正常使用 → used=true + 池已用额 + 负值流水(跨表)
  805. //
  806. // ⚠ 活动 type=3(代金券)的口径(§3.2 #15,dump 763229):**实际占用额 = 券面额**,
  807. // 不是请求额("一次用完一张")。故请求 10.00、池却记 100.00 —— 这正是 dump 的既有行为,
  808. // 若有人"顺手修正"成请求额,本断言立刻红(这是**有意锁定**的偏差点,不是测试笔误)。
  809. code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/use",
  810. map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "10.00"})
  811. if code != 200 {
  812. t.Fatalf("正常使用期望 200,实得 %d(%s)", code, msg)
  813. }
  814. if !decodeData[struct {
  815. Used bool `json:"used"`
  816. }](t, data).Used {
  817. t.Fatalf("正常使用 used 应为 true")
  818. }
  819. var pool model.CoursesActivityUserDiscount
  820. if err := db.Where("id = ?", 11).Take(&pool).Error; err != nil {
  821. t.Fatalf("读池失败: %v", err)
  822. }
  823. if !sameMoney(pool.DiscountAmountUsed, "100.00") {
  824. t.Fatalf("代金券(type=3) 已用额应为券面额 100.00(dump 763229),实得 %q", pool.DiscountAmountUsed)
  825. }
  826. var logRow model.CoursesActivityUserDiscountLog
  827. if err := db.Where("activity_user_discount_id = ?", 11).Take(&logRow).Error; err != nil {
  828. t.Fatalf("读流水失败: %v", err)
  829. }
  830. if !sameMoney(logRow.DiscountAmountUsed, "-100.00") {
  831. t.Fatalf("流水必须是负值(消费方向)且等于面额 -100.00,实得 %q", logRow.DiscountAmountUsed)
  832. }
  833. }
  834. // ---------------------------------------------------------------- ⑭ 充值赠送使用
  835. // TestRechargeGiftUseRequiresAttendedStatus 端点 35:报名行必须 `status=2`(dump 游标 763262)。
  836. //
  837. // 会变红的条件:
  838. // - 删掉 `status=2` 前置 ⇒ status=1 也扣(used=true)→ 红;
  839. // - 扣减后 `activity_money_used` 未更新 ⇒ 跨表断言红。
  840. func TestRechargeGiftUseRequiresAttendedStatus(t *testing.T) {
  841. db := newHandlerTestDB(t)
  842. app := newActivityTestApp(t, db)
  843. seedActivityRow(t, db, model.CoursesActivity{
  844. ID: 1201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
  845. Money: "100.00", Status: 1,
  846. })
  847. seedSignedRow(t, db, 121, 1201, "U800", model.ActivitySignedSigned, "100.00", nil) // status=1(仅报名)
  848. code, msg, data := doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
  849. map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
  850. if code != 200 {
  851. t.Fatalf("期望 200(no-op),实得 %d(%s)", code, msg)
  852. }
  853. if decodeData[struct {
  854. Used bool `json:"used"`
  855. }](t, data).Used {
  856. t.Fatalf("status=1(未参加)时 used 必须为 false(dump 763262 前置)")
  857. }
  858. // 置为已参加 → 正常扣减
  859. if err := db.Model(&model.CoursesActivitySigned{}).Where("id = ?", 121).
  860. Update("status", model.ActivitySignedAttended).Error; err != nil {
  861. t.Fatalf("改报名状态失败: %v", err)
  862. }
  863. code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
  864. map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
  865. if code != 200 {
  866. t.Fatalf("期望 200,实得 %d(%s)", code, msg)
  867. }
  868. if !decodeData[struct {
  869. Used bool `json:"used"`
  870. }](t, data).Used {
  871. t.Fatalf("status=2 时 used 应为 true")
  872. }
  873. var signed model.CoursesActivitySigned
  874. if err := db.Where("id = ?", 121).Take(&signed).Error; err != nil {
  875. t.Fatalf("读报名行失败: %v", err)
  876. }
  877. if !sameMoney(signed.ActivityMoneyUsed, "30.00") {
  878. t.Fatalf("activity_money_used 应为 30.00,实得 %q", signed.ActivityMoneyUsed)
  879. }
  880. if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ? AND user_id = ?", 1201, "U800"); n != 1 {
  881. t.Fatalf("used_log 应恰 1 行,实得 %d", n)
  882. }
  883. }
  884. // ---------------------------------------------------------------- ⑮ 三条退回
  885. // TestRefundEndpointsReportRefundedFlag 端点 33/34/36:无可用流水 ⇒ `refunded=false`(不是恒 true)。
  886. //
  887. // 会变红的条件:把响应的 `refunded` 写成常量 true("操作成功即 true")⇒ 三条断言全红。
  888. // 此外 33 的正常路径做**跨表**断言(流水回冲 + 池已用额下降)。
  889. func TestRefundEndpointsReportRefundedFlag(t *testing.T) {
  890. db := newHandlerTestDB(t)
  891. app := newActivityTestApp(t, db)
  892. // 端点 33:无流水 → false
  893. code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/refund",
  894. map[string]any{"coursesStudentId": 4242, "amount": "10.00"})
  895. if code != 200 {
  896. t.Fatalf("赠送退回期望 200,实得 %d(%s)", code, msg)
  897. }
  898. if decodeData[struct {
  899. Refunded bool `json:"refunded"`
  900. }](t, data).Refunded {
  901. t.Fatalf("无可用流水时 refunded 必须为 false")
  902. }
  903. // 端点 34:无 log → false
  904. seedActivityRow(t, db, model.CoursesActivity{
  905. ID: 1301, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
  906. Money: "100.00", Status: 1,
  907. })
  908. code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/refund-by-activity",
  909. map[string]any{"userId": "U900", "activityId": 1301, "amount": "10.00"})
  910. if code != 200 {
  911. t.Fatalf("按活动退回期望 200,实得 %d(%s)", code, msg)
  912. }
  913. if decodeData[struct {
  914. Refunded bool `json:"refunded"`
  915. }](t, data).Refunded {
  916. t.Fatalf("无 log 时 refunded 必须为 false(dump no-op)")
  917. }
  918. // 端点 36:无 used_log → false
  919. code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/refund",
  920. map[string]any{"coursesStudentId": 5555, "amount": "10.00"})
  921. if code != 200 {
  922. t.Fatalf("充值赠送退回期望 200,实得 %d(%s)", code, msg)
  923. }
  924. if decodeData[struct {
  925. Refunded bool `json:"refunded"`
  926. }](t, data).Refunded {
  927. t.Fatalf("无 used_log 时 refunded 必须为 false")
  928. }
  929. }
  930. // ---------------------------------------------------------------- ⑯ 回访
  931. // TestVisitRejectsCrossActivitySigned 回访:报名行不属于路径活动 ⇒ **3001**;正常 ⇒ 落库 1 行。
  932. //
  933. // 会变红的条件:
  934. // - 删掉 `signed.ActivityID != in.ActivityID` 判断 ⇒ 跨活动回访 200 且写出一行(无法事后筛出)→ 红;
  935. // - `visited` 恒 false 或漏写库 ⇒ 跨表断言红。
  936. func TestVisitRejectsCrossActivitySigned(t *testing.T) {
  937. db := newHandlerTestDB(t)
  938. app := newActivityTestApp(t, db)
  939. seedActivityRow(t, db, model.CoursesActivity{
  940. ID: 1401, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  941. Money: "0.00", Status: 1,
  942. })
  943. seedActivityRow(t, db, model.CoursesActivity{
  944. ID: 1402, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
  945. Money: "0.00", Status: 1,
  946. })
  947. seedSignedRow(t, db, 141, 1402, "U950", model.ActivitySignedSigned, "0.00", nil)
  948. // 报名行 141 属于活动 1402,却提交到活动 1401 的回访端点 → 3001
  949. code, msg, _ := doJSON(t, app, "POST", "/admin/activities/1401/visits",
  950. map[string]any{"activitySignedId": 141, "memo": "电话回访"})
  951. if code != 3001 {
  952. t.Fatalf("跨活动回访期望 3001,实得 %d(%s)", code, msg)
  953. }
  954. if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 0 {
  955. t.Fatalf("被拒的跨活动回访不得写库,实得 %d 行", n)
  956. }
  957. // 正确活动 → 200 + 1 行 visit
  958. code, msg, data := doJSON(t, app, "POST", "/admin/activities/1402/visits",
  959. map[string]any{"activitySignedId": 141, "memo": "电话回访"})
  960. if code != 200 {
  961. t.Fatalf("回访登记期望 200,实得 %d(%s)", code, msg)
  962. }
  963. res := decodeData[struct {
  964. Visited bool `json:"visited"`
  965. ActivityID int64 `json:"activityId"`
  966. }](t, data)
  967. if !res.Visited || res.ActivityID != 1402 {
  968. t.Fatalf("回访响应错:%+v", res)
  969. }
  970. if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 1 {
  971. t.Fatalf("回访应落库 1 行,实得 %d", n)
  972. }
  973. }
  974. // sameMoney 比较两个元字符串的**金额值**(不比较字面量)。
  975. //
  976. // 为什么不能直接比字符串:sqlite 把 `DECIMAL(12,2)` 存成 NUMERIC,读回时 `"0.00"` 会变成 `"0"`、
  977. // `"100.00"` 变成 `"100"`(MySQL 的 DECIMAL 会原样返回两位)。断言必须比"值",否则同一用例
  978. // 在 sqlite 上红、在 MySQL 上绿 —— 那是"测试环境差异",不是实现差异。
  979. // 生产语义(对外 JSON)由 `money.Amount.MarshalJSON` 保证恒两位小数,不在本助手范围。
  980. func sameMoney(got, want string) bool {
  981. gotAmount, gerr := money.Parse(got)
  982. wantAmount, werr := money.Parse(want)
  983. if gerr != nil || werr != nil {
  984. return false
  985. }
  986. return gotAmount == wantAmount
  987. }
  988. // withFields 复制 base 并覆盖若干 k/v(k,v 交替),避免用例间互相污染 body。
  989. func withFields(base map[string]any, kv ...any) map[string]any {
  990. out := make(map[string]any, len(base)+len(kv)/2)
  991. for k, v := range base {
  992. out[k] = v
  993. }
  994. for i := 0; i+1 < len(kv); i += 2 {
  995. key, _ := kv[i].(string)
  996. out[key] = kv[i+1]
  997. }
  998. return out
  999. }