activity_service_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. // ActivityService 单测(AC-07):报名 / 取消报名。
  2. //
  3. // 断言纪律(本文件的核心价值,对照 §5.1 五条纪律):
  4. // 1. **3105 与 3106 不得合并**(纪律 4「横切优先」):
  5. // `TestSignCancelResignMatrix` 一条用例同时证明两件事——
  6. // 取消报名后**可以重报同一活动**(3105 判**报名行** status)、
  7. // 但**不能再参加另一个老带新活动**(3106 判**活动** status、不看报名行 status)。
  8. // 两个分支里任意一个被"按直觉"改成另一个,这条用例必红。
  9. // 2. **守卫命中零副作用**:3107/3108 被拒后 `number_sign` 与三列金额**一字不动**。
  10. // 3. **数值边界**:`number_sign` 不得减成负数(下限 0);`activity_money = max(money,0)`。
  11. // 4. **人数上限 3141 是 Go 增强**:用例名与注释显式标注"dump 无此校验"。
  12. package service
  13. import (
  14. "context"
  15. "errors"
  16. "path/filepath"
  17. "testing"
  18. gormsqlite "github.com/glebarez/sqlite"
  19. "gorm.io/gorm"
  20. "yuansiwei/unified-backend/internal/academic/apperr"
  21. "yuansiwei/unified-backend/internal/academic/model"
  22. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  23. appscope "yuansiwei/unified-backend/internal/infra/scope"
  24. )
  25. // ---------------------------------------------------------------- 测试基础设施
  26. // newActivityDomainDB 活动域真实库(活动/报名/赠送池/两条流水 + 资金账户 + 报课/排班 + t_campus)。
  27. //
  28. // 复用 `migrateFundTables`(与 fund_service_test.go 同一 package):逐表 AutoMigrate 后删除
  29. // 该表索引——SQLite 的索引名是库级唯一,academic model 多表共用 `idx_inst_campus` 等名字,
  30. // 一次性迁移会报 "index already exists"。
  31. func newActivityDomainDB(t *testing.T) *gorm.DB {
  32. t.Helper()
  33. dsn := filepath.Join(t.TempDir(), "academic_activity_test.db")
  34. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  35. if err != nil {
  36. t.Fatalf("gorm.Open(活动域文件库) 失败: %v", err)
  37. }
  38. sqlDB, err := db.DB()
  39. if err != nil {
  40. t.Fatalf("取 sql.DB 失败: %v", err)
  41. }
  42. // 单连接:让"并发"用例在 sqlite 上退化为串行(生产 MySQL 用行锁串行,语义等价)
  43. sqlDB.SetMaxOpenConns(1)
  44. t.Cleanup(func() { _ = sqlDB.Close() })
  45. migrateFundTables(t, db,
  46. &model.CoursesActivity{}, &model.CoursesActivitySigned{},
  47. &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
  48. &model.CoursesActivityUsedLog{},
  49. &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
  50. &model.CoursesStudent{}, &model.CoursesClass{},
  51. )
  52. // 跨域只读表(org 域 t_campus 的最小列集;self_support 1=自营 / 0=加盟)
  53. if err := db.Exec(`CREATE TABLE t_campus (
  54. campus_id INTEGER PRIMARY KEY,
  55. institution_id INTEGER NOT NULL DEFAULT 0,
  56. self_support BOOLEAN NOT NULL DEFAULT 1,
  57. status VARCHAR(32) NOT NULL DEFAULT 'active'
  58. )`).Error; err != nil {
  59. t.Fatalf("建 t_campus 失败: %v", err)
  60. }
  61. return db
  62. }
  63. // activityScopeFor 指定机构/校区的作用域(普通角色,非超管)。
  64. func activityScopeFor(institutionID, campusID int64) *appscope.Scope {
  65. return &appscope.Scope{
  66. InstitutionID: institutionID, CampusID: campusID,
  67. UserID: "01HOPERATOR0000000000001", Role: "academic",
  68. }
  69. }
  70. // seedActivityFull 落一行活动(可指定类型/金额/人数上限/状态)。
  71. func seedActivityFull(t *testing.T, db *gorm.DB, id int64, typ int8, money string,
  72. institutionID, campusID int64, numberMax int32, status int8) {
  73. t.Helper()
  74. row := &model.CoursesActivity{
  75. ID: id, Type: typ, InstitutionID: institutionID, CampusID: campusID,
  76. Money: money, NumberMax: numberMax, Status: status,
  77. NumberSign: 0,
  78. }
  79. if err := db.Create(row).Error; err != nil {
  80. t.Fatalf("seed 活动(%d) 失败: %v", id, err)
  81. }
  82. }
  83. // readActivityRow 读活动行(断言 number_sign)。
  84. func readActivityRow(t *testing.T, db *gorm.DB, id int64) model.CoursesActivity {
  85. t.Helper()
  86. var row model.CoursesActivity
  87. if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
  88. t.Fatalf("读活动(%d) 失败: %v", id, err)
  89. }
  90. return row
  91. }
  92. // readSignedRow 读报名行(不存在 → 返回 nil)。
  93. func readSignedRow(t *testing.T, db *gorm.DB, userID string, activityID int64) *model.CoursesActivitySigned {
  94. t.Helper()
  95. var rows []model.CoursesActivitySigned
  96. if err := db.Where("user_id = ? AND activity_id = ?", userID, activityID).Find(&rows).Error; err != nil {
  97. t.Fatalf("读报名行(user=%s,activity=%d) 失败: %v", userID, activityID, err)
  98. }
  99. if len(rows) == 0 {
  100. return nil
  101. }
  102. if len(rows) > 1 {
  103. t.Fatalf("报名行不唯一(%d 行)——UK(user_id,activity_id) 失效", len(rows))
  104. }
  105. return &rows[0]
  106. }
  107. // countSignedRows 统计报名行条数(幂等断言:重复报名不得产生第二行)。
  108. func countSignedRows(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 {
  109. t.Helper()
  110. var n int64
  111. if err := db.Model(&model.CoursesActivitySigned{}).
  112. Where("user_id = ? AND activity_id = ?", userID, activityID).Count(&n).Error; err != nil {
  113. t.Fatalf("统计报名行失败: %v", err)
  114. }
  115. return n
  116. }
  117. // seedUsedLogRow 落一行 used_log(`activity_money_used` 按方向传:使用/结算为负、退回为正)。
  118. //
  119. // orderNo 是 dump 的 `orderid`(报课单号),退回游标(SP13)按它定位;不需要单号时传 ""。
  120. func seedUsedLogRow(t *testing.T, db *gorm.DB, activityID int64, userID, orderNo, amount string) {
  121. t.Helper()
  122. row := &model.CoursesActivityUsedLog{
  123. ActivityID: activityID, UserID: userID, ActivityMoneyUsed: amount,
  124. }
  125. if orderNo != "" {
  126. no := orderNo
  127. row.OrderNo = &no
  128. }
  129. if err := db.Create(row).Error; err != nil {
  130. t.Fatalf("seed used_log 失败: %v", err)
  131. }
  132. }
  133. // wantAppErr 断言错误码(不匹配 → 直接失败并打印实际值)。
  134. func wantAppErr(t *testing.T, err error, code int, what string) {
  135. t.Helper()
  136. if err == nil {
  137. t.Fatalf("%s:期望错误码 %d,实得 nil(本应被拒)", what, code)
  138. }
  139. var appErr *infraerrors.AppError
  140. if !errors.As(err, &appErr) {
  141. t.Fatalf("%s:期望 *AppError(%d),实得 %T(%v)", what, code, err, err)
  142. }
  143. if appErr.Code != code {
  144. t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg)
  145. }
  146. }
  147. // ---------------------------------------------------------------- 报名
  148. // TestSignCreatesSignedAndIncrementsNumber 正常报名:写报名行 + 人数 +1 + 金额快照。
  149. func TestSignCreatesSignedAndIncrementsNumber(t *testing.T) {
  150. db := newActivityDomainDB(t)
  151. svc := NewActivityService(db, fundSuperScope())
  152. ctx := context.Background()
  153. user := fundUserID(1)
  154. seedActivityFull(t, db, 100, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  155. res, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 100, Status: 0})
  156. if err != nil {
  157. t.Fatalf("Sign 失败: %v", err)
  158. }
  159. if res.Status != model.ActivitySignedSigned || res.SignedID <= 0 {
  160. t.Fatalf("报名状态应归一为 1 且回填 signedId,实得 status=%d signedId=%d", res.Status, res.SignedID)
  161. }
  162. if res.ActivityMoney.Cents() != 10000 {
  163. t.Fatalf("报名行金额应 10000 分,实得 %d", res.ActivityMoney.Cents())
  164. }
  165. row := readSignedRow(t, db, user, 100)
  166. if row == nil {
  167. t.Fatalf("报名行未落库")
  168. }
  169. // 金额列在 sqlite 上按 NUMERIC 亲和回读("100" 而非 "100.00")→ 一律用 cents() 按分值比较
  170. if row.Status != model.ActivitySignedSigned || cents(t, row.ActivityMoney) != 10000 || cents(t, row.ActivityMoneyUsed) != 0 {
  171. t.Fatalf("报名行字段不符:status=%d money=%s used=%s", row.Status, row.ActivityMoney, row.ActivityMoneyUsed)
  172. }
  173. // 冗余作用域列必须是**活动上的**快照(campus_id=0 有"全机构自营校区"语义)
  174. if row.InstitutionID != 1 || row.CampusID != 10 {
  175. t.Fatalf("报名行作用域列应取活动值(1/10),实得 %d/%d", row.InstitutionID, row.CampusID)
  176. }
  177. if act := readActivityRow(t, db, 100); act.NumberSign != 1 {
  178. t.Fatalf("报名人数应 +1 到 1,实得 %d", act.NumberSign)
  179. }
  180. }
  181. // TestSignClampsNegativeActivityMoney 活动金额为负 → 报名行金额归 0(dump 762955)。
  182. func TestSignClampsNegativeActivityMoney(t *testing.T) {
  183. db := newActivityDomainDB(t)
  184. svc := NewActivityService(db, fundSuperScope())
  185. user := fundUserID(2)
  186. seedActivityFull(t, db, 101, model.ActivityTypeRecharge, "-5.00", 1, 10, 0, model.ActivityStatusEnabled)
  187. res, err := svc.Sign(context.Background(), SignInput{UserID: user, ActivityID: 101})
  188. if err != nil {
  189. t.Fatalf("Sign 失败: %v", err)
  190. }
  191. if res.ActivityMoney.Cents() != 0 {
  192. t.Fatalf("负金额应归 0,实得 %d 分", res.ActivityMoney.Cents())
  193. }
  194. if row := readSignedRow(t, db, user, 101); row == nil || cents(t, row.ActivityMoney) != 0 {
  195. t.Fatalf("报名行金额应为 0,实得 %+v", row)
  196. }
  197. }
  198. // TestSignRejectsDuplicateWith3105 重复报名 → 3105,且**不产生第二行、人数不再 +1**。
  199. func TestSignRejectsDuplicateWith3105(t *testing.T) {
  200. db := newActivityDomainDB(t)
  201. svc := NewActivityService(db, fundSuperScope())
  202. ctx := context.Background()
  203. user := fundUserID(3)
  204. seedActivityFull(t, db, 102, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  205. if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 102}); err != nil {
  206. t.Fatalf("首次报名失败: %v", err)
  207. }
  208. _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 102})
  209. wantAppErr(t, err, apperr.CodeActivityAlreadySigned, "重复报名")
  210. if n := countSignedRows(t, db, user, 102); n != 1 {
  211. t.Fatalf("重复报名不得产生第二行,实得 %d 行", n)
  212. }
  213. if act := readActivityRow(t, db, 102); act.NumberSign != 1 {
  214. t.Fatalf("被 3105 拒绝后人数不得再 +1,实得 %d", act.NumberSign)
  215. }
  216. }
  217. // TestSignRejectsMissingActivityWith3001 活动不存在 → 3001(dump 此处静默 success=0,Go 显式化)。
  218. func TestSignRejectsMissingActivityWith3001(t *testing.T) {
  219. db := newActivityDomainDB(t)
  220. svc := NewActivityService(db, fundSuperScope())
  221. _, err := svc.Sign(context.Background(), SignInput{UserID: fundUserID(4), ActivityID: 999})
  222. wantAppErr(t, err, 3001, "报名不存在活动")
  223. if n := countSignedRows(t, db, fundUserID(4), 999); n != 0 {
  224. t.Fatalf("活动不存在时不得写报名行,实得 %d 行", n)
  225. }
  226. }
  227. // TestSignRespectsNumberMax 人数上限 → 3141(**Go 增强:dump 无此校验**,见方法注释)。
  228. func TestSignRespectsNumberMax(t *testing.T) {
  229. db := newActivityDomainDB(t)
  230. svc := NewActivityService(db, fundSuperScope())
  231. ctx := context.Background()
  232. seedActivityFull(t, db, 103, model.ActivityTypeRecharge, "100.00", 1, 10, 1, model.ActivityStatusEnabled)
  233. if _, err := svc.Sign(ctx, SignInput{UserID: fundUserID(5), ActivityID: 103}); err != nil {
  234. t.Fatalf("第 1 个报名应成功: %v", err)
  235. }
  236. _, err := svc.Sign(ctx, SignInput{UserID: fundUserID(6), ActivityID: 103})
  237. wantAppErr(t, err, apperr.CodeActivityFull, "超人数上限报名")
  238. if n := countSignedRows(t, db, fundUserID(6), 103); n != 0 {
  239. t.Fatalf("超上限被拒后不得写报名行,实得 %d 行", n)
  240. }
  241. if act := readActivityRow(t, db, 103); act.NumberSign != 1 {
  242. t.Fatalf("被 3141 拒绝后人数应保持 1,实得 %d", act.NumberSign)
  243. }
  244. }
  245. // TestSignCancelResignMatrix **横切断言(纪律 4)**:3105 与 3106 是两个不能合并的分支。
  246. //
  247. // 步骤与判据:
  248. //
  249. // a) 报名**充值赠送** A(type=2)→ 成功;取消 A → 三列归零、人数回到 0
  250. // b) **重报 A → 必须成功**(3105 判**报名行** status,status=0 不算已报名)
  251. // c) 用户已有一条**已取消**的老带新报名(type=4,status=0)→ 报名**另一个**老带新 B
  252. // → **必须被 3106 拒**(3106 判**活动** status、不看报名行 status ⇒ 取消也永久消耗次数)
  253. //
  254. // 若把两条判据互换口径:
  255. // - 3105 改成"判活动" → b) 会被误拒(A 的活动 status != 0);
  256. // - 3106 改成"判报名行 status" → c) 会被误放行(那条老带新报名 status=0)⇒ 可反复领奖励。
  257. func TestSignCancelResignMatrix(t *testing.T) {
  258. db := newActivityDomainDB(t)
  259. svc := NewActivityService(db, fundSuperScope())
  260. ctx := context.Background()
  261. user := fundUserID(7)
  262. seedActivityFull(t, db, 200, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  263. seedActivityFull(t, db, 201, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  264. seedActivityFull(t, db, 202, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  265. // a) 报名 + 取消(type=2,未审核 → 可取消)
  266. if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 200}); err != nil {
  267. t.Fatalf("报名 A 失败: %v", err)
  268. }
  269. if _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 200}); err != nil {
  270. t.Fatalf("取消报名失败: %v", err)
  271. }
  272. row := readSignedRow(t, db, user, 200)
  273. if row == nil || row.Status != model.ActivitySignedCancelled {
  274. t.Fatalf("取消后报名行应保留且 status=0,实得 %+v", row)
  275. }
  276. if cents(t, row.ActivityMoney) != 0 || cents(t, row.ActivityMoneyUsed) != 0 {
  277. t.Fatalf("取消后两列金额应同置 0,实得 money=%s used=%s", row.ActivityMoney, row.ActivityMoneyUsed)
  278. }
  279. if act := readActivityRow(t, db, 200); act.NumberSign != 0 {
  280. t.Fatalf("取消后人数应回到 0(下限保护),实得 %d", act.NumberSign)
  281. }
  282. // b) 重报 A —— 3105 放行(判报名行 status)
  283. if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 200}); err != nil {
  284. t.Fatalf("取消后可重报同一活动(3105 判报名行 status),实得错误: %v", err)
  285. }
  286. if n := countSignedRows(t, db, user, 200); n != 1 {
  287. t.Fatalf("重报后仍应只有 1 行(DELETE+INSERT 幂等),实得 %d", n)
  288. }
  289. // c) 已取消的老带新报名(status=0)→ 报名另一个老带新 B 必须被 3106 拒
  290. if err := db.Create(&model.CoursesActivitySigned{
  291. ActivityID: 201, UserID: user, InstitutionID: 1, CampusID: 10,
  292. Status: model.ActivitySignedCancelled, ActivityMoney: "0", ActivityMoneyUsed: "0",
  293. }).Error; err != nil {
  294. t.Fatalf("seed 已取消的老带新报名失败: %v", err)
  295. }
  296. _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 202})
  297. wantAppErr(t, err, apperr.CodeRecommendLimited, "老带新限一次(已取消也算参加过)")
  298. if n := countSignedRows(t, db, user, 202); n != 0 {
  299. t.Fatalf("被 3106 拒绝后不得写 B 的报名行,实得 %d 行", n)
  300. }
  301. }
  302. // ---------------------------------------------------------------- 取消报名
  303. // TestCancelSignRejectsAuditedWith3107 type∈{2,3} 且已审核 → 3107,且**零副作用**。
  304. func TestCancelSignRejectsAuditedWith3107(t *testing.T) {
  305. db := newActivityDomainDB(t)
  306. svc := NewActivityService(db, fundSuperScope())
  307. ctx := context.Background()
  308. user := fundUserID(8)
  309. seedActivityFull(t, db, 300, model.ActivityTypeVoucher, "50.00", 1, 10, 0, model.ActivityStatusEnabled)
  310. auditor := "01HAUDITOR000000000000001"
  311. if err := db.Create(&model.CoursesActivitySigned{
  312. ActivityID: 300, UserID: user, InstitutionID: 1, CampusID: 10,
  313. Status: model.ActivitySignedSigned, ActivityMoney: "50.00", ResponseUserID: &auditor,
  314. }).Error; err != nil {
  315. t.Fatalf("seed 已审核报名行失败: %v", err)
  316. }
  317. if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 300).
  318. UpdateColumn("number_sign", 1).Error; err != nil {
  319. t.Fatalf("seed 人数失败: %v", err)
  320. }
  321. _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 300})
  322. wantAppErr(t, err, apperr.CodeActivitySignedAudited, "取消已审核报名")
  323. row := readSignedRow(t, db, user, 300)
  324. if row.Status != model.ActivitySignedSigned || cents(t, row.ActivityMoney) != 5000 {
  325. t.Fatalf("被 3107 拒绝后报名行必须一字不动,实得 status=%d money=%s", row.Status, row.ActivityMoney)
  326. }
  327. if act := readActivityRow(t, db, 300); act.NumberSign != 1 {
  328. t.Fatalf("被 3107 拒绝后人数不得减少,实得 %d", act.NumberSign)
  329. }
  330. }
  331. // TestCancelSignRejectsRecommendUsedWith3108 type=4 且 used_log 存在 → 3108,且**零副作用**。
  332. func TestCancelSignRejectsRecommendUsedWith3108(t *testing.T) {
  333. db := newActivityDomainDB(t)
  334. svc := NewActivityService(db, fundSuperScope())
  335. ctx := context.Background()
  336. user := fundUserID(9)
  337. seedActivityFull(t, db, 301, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  338. if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 301}); err != nil {
  339. t.Fatalf("报名失败: %v", err)
  340. }
  341. seedUsedLogRow(t, db, 301, user, "", "-100.00") // 已有结算记录(奖励已发出)
  342. _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 301})
  343. wantAppErr(t, err, apperr.CodeRecommendUsed, "取消已消费的老带新报名")
  344. row := readSignedRow(t, db, user, 301)
  345. if row.Status != model.ActivitySignedSigned {
  346. t.Fatalf("被 3108 拒绝后报名状态不得变,实得 %d", row.Status)
  347. }
  348. if act := readActivityRow(t, db, 301); act.NumberSign != 1 {
  349. t.Fatalf("被 3108 拒绝后人数不得减少,实得 %d", act.NumberSign)
  350. }
  351. }
  352. // TestCancelSignNumberSignFloorAtZero 人数已是 0 时取消 → 保持 0(不下穿)。
  353. func TestCancelSignNumberSignFloorAtZero(t *testing.T) {
  354. db := newActivityDomainDB(t)
  355. svc := NewActivityService(db, fundSuperScope())
  356. ctx := context.Background()
  357. user := fundUserID(10)
  358. // type=1(试听):既不走 3107 也不走 3108
  359. seedActivityFull(t, db, 302, model.ActivityTypeTrial, "0.00", 1, 10, 0, model.ActivityStatusEnabled)
  360. if err := db.Create(&model.CoursesActivitySigned{
  361. ActivityID: 302, UserID: user, InstitutionID: 1, CampusID: 10,
  362. Status: model.ActivitySignedSigned, ActivityMoney: "0.00",
  363. }).Error; err != nil {
  364. t.Fatalf("seed 报名行失败: %v", err)
  365. }
  366. // number_sign 故意留 0(脏数据/并发取消后的状态)
  367. res, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 302})
  368. if err != nil {
  369. t.Fatalf("取消报名失败: %v", err)
  370. }
  371. if res.NumberSign != 0 {
  372. t.Fatalf("人数下限应为 0,实得 %d", res.NumberSign)
  373. }
  374. if act := readActivityRow(t, db, 302); act.NumberSign != 0 {
  375. t.Fatalf("库中人数不得为负,实得 %d", act.NumberSign)
  376. }
  377. }
  378. // TestCancelSignMissingSignedWith3001 无有效报名行 → 3001(dump:游标空 → success=0)。
  379. func TestCancelSignMissingSignedWith3001(t *testing.T) {
  380. db := newActivityDomainDB(t)
  381. svc := NewActivityService(db, fundSuperScope())
  382. seedActivityFull(t, db, 303, model.ActivityTypeRecharge, "10.00", 1, 10, 0, model.ActivityStatusEnabled)
  383. _, err := svc.CancelSign(context.Background(), CancelSignInput{UserID: fundUserID(11), ActivityID: 303})
  384. wantAppErr(t, err, 3001, "取消不存在的报名")
  385. if act := readActivityRow(t, db, 303); act.NumberSign != 0 {
  386. t.Fatalf("无报名行时人数不得变化,实得 %d", act.NumberSign)
  387. }
  388. }