| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082 |
- // 活动域 Handler 单测(AC-13b:端点 22~37,16 个)。
- //
- // 断言纪律(与 AC-12 同源,只断 body 契约:HTTP 恒 200 + body.code):
- //
- // 每条用例都写成"**实现改错就变红**"的形状,判据写在每个 `Test` 的注释第一段。
- // "用例全绿"不算验收——全绿只说明断言与实现同时存在,必须能指出"哪一行改掉它就会红"。
- //
- // 覆盖结构:
- //
- // ① 端点覆盖 + 路由顺序 → TestActivityRouteCoverage
- // ② 列表:作用域/派生字段/枚举 → TestListActivitiesScopesAndDerives
- // ③ 新建:2002/3104/1004/成功 → TestCreateActivityValidationAndCrossCampus
- // ④ 详情:3001/字段映射/空数组 → TestActivityDetailScopeAndShape
- // ⑤ 报名列表:2002/空列表 → TestListSignedsInvalidStatusIs2002
- // ⑥ 更新:2002(numberSign)/3001 → TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001
- // ⑦ 报名/取消:3105/跨表三列归零 → TestSignThenCancelCrossTableEffects
- // ⑧ 取消:3107 审核态零副作用 → TestCancelSignAuditedIs3107WithZeroSideEffects
- // ⑨ 取消:3121 type 读不到 → TestCancelSignUnresolvableTypeIs3121
- // ⑩ 老带新:settled=false 零写入 → TestRecommendSettleNoopDoesNotWrite
- // ⑪ 取消充值:3104 → TestCancelRechargeUnknownCampusIs3104
- // ⑫ 可用赠送:1004/1003/items → TestUsableBreakdownScopeAndShape
- // ⑬ 赠送使用:no-op 与 3109 → TestDiscountUseNoopAndInsufficient
- // ⑭ 充值赠送使用:status=2 前置 → TestRechargeGiftUseRequiresAttendedStatus
- // ⑮ 三条退回:refunded 语义 → TestRefundEndpointsReportRefundedFlag
- // ⑯ 回访:跨活动拒绝 + 落库 → TestVisitRejectsCrossActivitySigned
- package handler
- import (
- "encoding/json"
- "strings"
- "testing"
- "github.com/gofiber/fiber/v2"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // ---------------------------------------------------------------- 测试基础设施
- // newActivityTestApp 构造只挂活动域 16 条端点的 app(含作用域注入中间件)。
- //
- // 中间件按 `X-Test-Scope` 注入不同作用域(缺省 finance,与 `testScope` 一致):
- //
- // none / super / academic / student
- //
- // 路由注册顺序**有意**与 `activity_handler.go` 文件头的"静态优先"一致:
- // 若把 `PathActivityDetail` 注册在 `PathActivityDiscountsUsable` 之前,
- // `GET /admin/activities/discounts/usable` 会被 `:id="discounts"` 吃掉(见 `TestActivityRouteCoverage`)。
- func newActivityTestApp(t *testing.T, db *gorm.DB) *fiber.App {
- t.Helper()
- h, err := New(Config{DB: db})
- if err != nil {
- t.Fatalf("handler.New 失败: %v", err)
- }
- app := fiber.New(fiber.Config{DisableStartupMessage: true})
- app.Use(func(c *fiber.Ctx) error {
- switch c.Get("X-Test-Scope") {
- case "none":
- // 不写作用域 → 未登录语义(1001)
- case "super":
- appscope.Set(c, testSuperScope())
- case "academic":
- appscope.Set(c, &appscope.Scope{
- InstitutionID: 1, CampusID: 10,
- UserID: "01HACADEMIC0000000000001", Role: "academic",
- })
- default:
- appscope.Set(c, testScope())
- }
- return c.Next()
- })
- // ① 静态段(顺序敏感,必须全在 `:id` 之前)
- app.Get(PathActivities, h.ListActivities)
- app.Post(PathActivities, h.CreateActivity)
- app.Get(PathActivityDiscountsUsable, h.GetActivityUsableDiscount)
- app.Post(PathActivityDiscountsUse, h.UseActivityDiscount)
- app.Post(PathActivityDiscountsRefund, h.RefundActivityDiscount)
- app.Post(PathActivityDiscountsRefundByActivity, h.RefundDiscountByActivity)
- app.Post(PathActivityRechargeGiftsUse, h.UseRechargeGift)
- app.Post(PathActivityRechargeGiftsRefund, h.RefundRechargeGift)
- // ② 带 :id 的段
- app.Get(PathActivityDetail, h.GetActivity)
- app.Put(PathActivityDetail, h.UpdateActivity)
- app.Post(PathActivitySign, h.SignActivity)
- app.Post(PathActivityCancelSign, h.CancelActivitySign)
- app.Get(PathActivitySigneds, h.ListActivitySigneds)
- app.Post(PathActivityRecommendSettle, h.SettleActivityRecommend)
- app.Post(PathActivityCancelRecharge, h.CancelActivityRecharge)
- app.Post(PathActivityVisits, h.CreateActivityVisit)
- return app
- }
- // seedActivityRow 落一行活动(显式 ID,便于断言语义)。
- func seedActivityRow(t *testing.T, db *gorm.DB, row model.CoursesActivity) {
- t.Helper()
- if row.NumberSign == 0 && row.NumberMax == 0 {
- row.NumberMax = 0
- }
- if row.Title == nil {
- title := "活动"
- row.Title = &title
- }
- if err := db.Create(&row).Error; err != nil {
- t.Fatalf("seed 活动(%d) 失败: %v", row.ID, err)
- }
- }
- // seedSignedRow 落一行报名(status 显式传;responseUserID 非空表示已审核 → 3107 判据)。
- func seedSignedRow(
- t *testing.T, db *gorm.DB, id, activityID int64, userID string, status int32,
- moneyStr string, responseUserID *string,
- ) {
- t.Helper()
- row := &model.CoursesActivitySigned{
- ID: id, ActivityID: activityID, UserID: userID,
- InstitutionID: 1, CampusID: 10,
- Status: status, ActivityMoney: moneyStr, ActivityMoneyUsed: "0.00",
- ResponseUserID: responseUserID,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报名(id=%d) 失败: %v", id, err)
- }
- }
- // seedDiscountPool 落一行赠送池(amount/used 为元字符串)。
- func seedDiscountPool(
- t *testing.T, db *gorm.DB, id, activityID int64, userID string, amount, used string,
- ) {
- t.Helper()
- row := &model.CoursesActivityUserDiscount{
- ID: id, ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID,
- Income: "0.00", DiscountAmount: amount, DiscountAmountUsed: used,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 赠送池(id=%d) 失败: %v", id, err)
- }
- }
- // readActivityByID 读活动行(断言库值用;不存在 → 失败)。
- func readActivityByID(t *testing.T, db *gorm.DB, id int64) model.CoursesActivity {
- t.Helper()
- var row model.CoursesActivity
- if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
- t.Fatalf("读活动(%d) 失败: %v", id, err)
- }
- return row
- }
- // countRows 统计表内满足条件的行数(**跨表断言**用:响应体之外的唯一事实)。
- func countRows(t *testing.T, db *gorm.DB, entity any, query string, args ...any) int64 {
- t.Helper()
- var n int64
- if err := db.Model(entity).Where(query, args...).Count(&n).Error; err != nil {
- t.Fatalf("统计行数失败(%s): %v", query, err)
- }
- return n
- }
- // decodeData 把响应 data 解成目标结构(解不出 → 失败)。
- func decodeData[T any](t *testing.T, raw json.RawMessage) T {
- t.Helper()
- var out T
- if err := json.Unmarshal(raw, &out); err != nil {
- t.Fatalf("data 解码失败(%s): %v", string(raw), err)
- }
- return out
- }
- // ---------------------------------------------------------------- ① 端点覆盖 + 路由顺序
- // TestActivityRouteCoverage 16 条端点全注册、路径不含 `/api`、且**静态段优先**。
- //
- // 会变红的条件:
- // - 任何一条 `PathActivity*` 改了名字/漏注册 → 对应 `registered[...]` 断言失败;
- // - 把 `GET /admin/activities/:id` 注册在 `discounts/usable` 之前 →
- // `/admin/activities/discounts/usable?userId=X` 会命中详情 handler 并因 id="discounts" 解析成 0
- // ⇒ 返回 **2002**,本用例断言它必须是 **200**(真实业务码)→ 红。
- func TestActivityRouteCoverage(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- expected := []struct{ method, path, name string }{
- {"GET", PathActivities, "端点22 活动列表"},
- {"POST", PathActivities, "端点23 新建"},
- {"GET", PathActivityDetail, "端点24 详情"},
- {"PUT", PathActivityDetail, "端点25 更新"},
- {"POST", PathActivitySign, "端点26 报名"},
- {"POST", PathActivityCancelSign, "端点27 取消报名"},
- {"GET", PathActivitySigneds, "端点28 报名列表"},
- {"POST", PathActivityRecommendSettle, "端点29 老带新结算"},
- {"POST", PathActivityCancelRecharge, "端点30 取消充值"},
- {"GET", PathActivityDiscountsUsable, "端点31 可用赠送"},
- {"POST", PathActivityDiscountsUse, "端点32 使用赠送"},
- {"POST", PathActivityDiscountsRefund, "端点33 赠送退回"},
- {"POST", PathActivityDiscountsRefundByActivity, "端点34 按活动退回"},
- {"POST", PathActivityRechargeGiftsUse, "端点35 充值赠送使用"},
- {"POST", PathActivityRechargeGiftsRefund, "端点36 充值赠送退回"},
- {"POST", PathActivityVisits, "端点37 回访"},
- }
- registered := map[string]bool{}
- for _, r := range app.GetRoutes() {
- registered[r.Method+" "+r.Path] = true
- if strings.Contains(r.Path, "/api/") {
- t.Errorf("后端路由不得含 /api 前缀(§1.6):%s %s", r.Method, r.Path)
- }
- }
- for _, want := range expected {
- if !registered[want.method+" "+want.path] {
- t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path)
- }
- }
- if len(expected) != 16 {
- t.Fatalf("活动端点数应为 16,实为 %d(契约 §2.2)", len(expected))
- }
- // 路由顺序的**行为断言**(比检查注册表更硬):静态路径必须走到自己的 handler。
- code, msg, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U1", nil)
- if code != 200 {
- t.Fatalf("GET discounts/usable 期望 200,实得 %d(%s)——"+
- "若为 2002 说明 :id 路由先注册吃掉了 discounts", code, msg)
- }
- }
- // ---------------------------------------------------------------- ② 列表
- // TestListActivitiesScopesAndDerives 列表作用域 + `signed`/`numberSign`/`signable` 派生字段。
- //
- // 会变红的条件(逐条):
- // - 去掉 `scopedCampusRows` ⇒ 列表里出现 institutionId=2 的活动 → 红;
- // - `CountSignedByActivityIDs` 少写 `status <> 0` ⇒ `signed` 变 3(期望 2)→ 红;
- // - `signable` 恒 true ⇒ status=0 的活动也 true → 红;
- // - `name`/`total` 没有从 `title`/`number_max` 映射 ⇒ 断言失败 → 红。
- func TestListActivitiesScopesAndDerives(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- title1 := "充值赠送 A"
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 101, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Title: &title1, Money: "100.00", NumberMax: 5, NumberSign: 2, Status: 1,
- })
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 102, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
- Money: "0.00", NumberMax: 0, Status: 0, // 停用 → signable=false
- })
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 103, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
- Money: "1.00", NumberMax: 0, Status: 1, // 别的机构 → 不得出现
- })
- seedSignedRow(t, db, 1, 101, "U1", model.ActivitySignedSigned, "100.00", nil)
- seedSignedRow(t, db, 2, 101, "U2", model.ActivitySignedAttended, "100.00", nil)
- seedSignedRow(t, db, 3, 101, "U3", model.ActivitySignedCancelled, "0.00", nil) // 取消 → 不计
- code, msg, data := doJSON(t, app, "GET", "/admin/activities?page=1&pageSize=10", nil)
- if code != 200 {
- t.Fatalf("列表期望 200,实得 %d(%s)", code, msg)
- }
- page := decodeData[struct {
- List []ActivityViewForTest `json:"list"`
- Total int64 `json:"total"`
- Page int `json:"page"`
- PageSize int `json:"pageSize"`
- Pages int64 `json:"pages"`
- }](t, data)
- if page.Total != 2 {
- t.Fatalf("列表总数应为 2(本机构 2 个),实得 %d(含跨机构活动则说明作用域失效)", page.Total)
- }
- if page.Pages != 1 {
- t.Fatalf("pages 应为 ceil(2/10)=1,实得 %d", page.Pages)
- }
- byID := map[int64]ActivityViewForTest{}
- for _, row := range page.List {
- byID[row.ID] = row
- }
- if _, ok := byID[103]; ok {
- t.Fatalf("跨机构活动 103 出现在列表里(作用域过滤失效,1004 类越权)")
- }
- first, ok := byID[101]
- if !ok {
- t.Fatalf("本机构活动 101 不在列表里")
- }
- if first.Name != "充值赠送 A" {
- t.Fatalf("name 应映射 title(\"充值赠送 A\"),实得 %q", first.Name)
- }
- if first.Total != 5 {
- t.Fatalf("total 应映射 number_max=5,实得 %d", first.Total)
- }
- if first.NumberSign != 2 {
- t.Fatalf("numberSign 应为库值 2,实得 %d", first.NumberSign)
- }
- if first.Signed != 2 {
- t.Fatalf("signed 应为 2(status<>0),实得 %d——"+
- "若为 3 说明把 status=0(已取消)也统计进来了", first.Signed)
- }
- // 活动级 money(v1.6 契约补入):来自 `t_courses_activity.money`,两位小数字符串。
- // 会变红的条件:① DTO 漏掉 Money 字段 ⇒ 得 "";② 用 float 输出 ⇒ 声明为 string 此处 unmarshal 失败;
- // ③ 拿 signeds[].money 顶上 ⇒ 值可能仍是 100.00,故另用活动 102(money=0.00、无报名)区分层级。
- if first.Money != "100.00" {
- t.Fatalf("活动级 money 应为 \"100.00\"(← t_courses_activity.money),实得 %q", first.Money)
- }
- if stopped, ok := byID[102]; ok {
- if stopped.Money != "0.00" {
- t.Fatalf("无报名的停用活动 money 应为 \"0.00\"(活动级、与报名无关),实得 %q——"+
- "若非 0.00 说明把 signeds[].money 当成了活动金额", stopped.Money)
- }
- }
- if !first.Signable {
- t.Fatalf("活动 101 应为 signable(status=1、未满员、无时间窗约束)")
- }
- disabled := byID[102]
- if disabled.Signable {
- t.Fatalf("活动 102 status=0(停用)应 signable=false,实得 true")
- }
- // 空数组而非 null:`list` 必须在 data 顶层且为数组
- if !strings.Contains(string(data), `"list":[`) {
- t.Fatalf("data.list 必须是数组(不得为 null/缺键):%s", string(data))
- }
- }
- // ActivityViewForTest 列表行(只声明本用例要断言的字段,避免抄一份 DTO 造成"两处定义")。
- type ActivityViewForTest struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Total int32 `json:"total"`
- NumberSign int32 `json:"numberSign"`
- Signed int32 `json:"signed"`
- // Money 声明为 **string**:若后端下发数值(number)会在此处 unmarshal 失败 ⇒
- // "金额必须是两位小数字符串"这条不用额外断言也守住了。
- Money string `json:"money"`
- Signable bool `json:"signable"`
- Type int8 `json:"type"`
- }
- // TestListActivitiesInvalidEnumsAre2002 type/status 过滤枚举越界 → **2002**(不是 2001、不是静默忽略)。
- //
- // 会变红的条件:删掉 `parseEnumQuery` 的白名单判断 ⇒ 变成 200 且返回全量列表 → 红。
- func TestListActivitiesInvalidEnumsAre2002(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- for _, q := range []string{"type=1", "type=9", "status=3", "status=abc"} {
- code, _, _ := doJSON(t, app, "GET", "/admin/activities?"+q, nil)
- if code != 2002 {
- t.Errorf("查询 %s 期望 2002(枚举/格式非法),实得 %d", q, code)
- }
- }
- }
- // ---------------------------------------------------------------- ③ 新建
- // TestCreateActivityValidationAndCrossCampus 新建活动的四类拒绝 + 成功路径的库值断言。
- //
- // 会变红的条件:
- // - 删除 `validateActivityFields` 的 type 白名单 ⇒ type=1 变 200 → 红;
- // - 删除时间窗倒挂判断 ⇒ start>end 变 200 → 红;
- // - 删除 `assertCampusInScope` ⇒ 传别家机构校区(20)变 200 → 红(**这是 1004 越权判据**);
- // - 删除校区端口调用 ⇒ 不存在的校区(777)变 5002/200 而不是 3104 → 红;
- // - 成功路径:`institution_id` 必须取作用域(1),且 `number_sign` 必须为 0。
- func TestCreateActivityValidationAndCrossCampus(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- // 造一个"属于机构 2"的校区(跨机构写入判据;测试库默认只有 10 → 机构 1)
- if err := db.Exec(
- `INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(20,2,1,'active')`,
- ).Error; err != nil {
- t.Fatalf("seed t_campus(20) 失败: %v", err)
- }
- base := map[string]any{"type": 2, "title": "新活动", "money": "100.00"}
- cases := []struct {
- name string
- body map[string]any
- want int
- }{
- {"类型 1(试听,旧遗留)", withFields(base, "type", 1), 2002},
- {"类型 9(不存在)", withFields(base, "type", 9), 2002},
- {"时间窗倒挂", withFields(base, "startTime", "2026-12-31", "endTime", "2026-01-01"), 2002},
- {"校区不存在", withFields(base, "campusId", 777), 3104},
- {"校区属别的机构", withFields(base, "campusId", 20), 1004},
- {"机构参数跨作用域", withFields(base, "institutionId", 2), 1004},
- {"金额格式非法", withFields(base, "money", "abc"), 2002},
- }
- for _, tc := range cases {
- code, msg, _ := doJSON(t, app, "POST", "/admin/activities", tc.body)
- if code != tc.want {
- t.Errorf("%s:期望 %d,实得 %d(%s)", tc.name, tc.want, code, msg)
- }
- }
- code, msg, data := doJSON(t, app, "POST", "/admin/activities", base)
- if code != 200 {
- t.Fatalf("新建期望 200,实得 %d(%s)", code, msg)
- }
- created := decodeData[struct {
- ID int64 `json:"id"`
- BizNo string `json:"bizNo"`
- }](t, data)
- if created.ID <= 0 {
- t.Fatalf("新建应回 id>0,实得 %d", created.ID)
- }
- if created.BizNo != "" {
- t.Fatalf("bizNo 不应下发(t_courses_activity 无 biz_no 列):实得 %q", created.BizNo)
- }
- row := readActivityByID(t, db, created.ID)
- if row.InstitutionID != 1 {
- t.Fatalf("institution_id 应取作用域机构 1(不信任 body),实得 %d", row.InstitutionID)
- }
- if row.NumberSign != 0 {
- t.Fatalf("number_sign 必须为 0(计数列不得由新建写入),实得 %d", row.NumberSign)
- }
- if row.Title == nil || *row.Title != "新活动" {
- t.Fatalf("title 落库错误:%v", row.Title)
- }
- }
- // ---------------------------------------------------------------- ④ 详情
- // TestActivityDetailScopeAndShape 详情的作用域(3001)与字段映射(name/total/signeds[].money)。
- //
- // 会变红的条件:
- // - 去掉 `GetByID` 的作用域 ⇒ 别家机构活动(103)返回 200 → 红;
- // - `SignedView.Money` 用 float/未转 money.Amount ⇒ `"100.00"` 变 `100` → 红;
- // - `signeds` 用 nil 切片 ⇒ JSON 为 null(断言是 `"signeds":[]`)→ 红。
- func TestActivityDetailScopeAndShape(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- title := "详情活动"
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Title: &title, Money: "500.00", NumberMax: 10, Status: 1,
- })
- seedSignedRow(t, db, 11, 201, "U1", model.ActivitySignedSigned, "500.00", nil)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 202, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
- Money: "1.00", Status: 1,
- })
- code, msg, data := doJSON(t, app, "GET", "/admin/activities/201", nil)
- if code != 200 {
- t.Fatalf("详情期望 200,实得 %d(%s)", code, msg)
- }
- detail := decodeData[struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Total int32 `json:"total"`
- Signed int32 `json:"signed"`
- Signeds []struct {
- ID int64 `json:"id"`
- ActivityID int64 `json:"activityId"`
- UserID string `json:"userId"`
- Status int32 `json:"status"`
- Money string `json:"money"`
- CreateTime string `json:"createTime"`
- } `json:"signeds"`
- }](t, data)
- if detail.Name != "详情活动" || detail.Total != 10 || detail.Signed != 1 {
- t.Fatalf("详情字段映射错:%+v", detail)
- }
- if len(detail.Signeds) != 1 {
- t.Fatalf("详情应内嵌 1 条报名行,实得 %d", len(detail.Signeds))
- }
- if detail.Signeds[0].Money != "500.00" {
- t.Fatalf("signeds[].money 必须是两位小数字符串(activity_money=500.00),实得 %q",
- detail.Signeds[0].Money)
- }
- if !strings.Contains(detail.Signeds[0].CreateTime, "T") {
- t.Fatalf("signeds[].createTime 必须是 RFC3339(含 T),实得 %q", detail.Signeds[0].CreateTime)
- }
- // 跨机构 → 3001(不泄露存在性;若变 200 说明作用域被绕过)
- if code, _, _ := doJSON(t, app, "GET", "/admin/activities/202", nil); code != 3001 {
- t.Fatalf("跨机构活动详情期望 3001,实得 %d", code)
- }
- // 空报名列表 → `[]`(不是 null)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 203, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
- Money: "0.00", Status: 1,
- })
- _, _, emptyData := doJSON(t, app, "GET", "/admin/activities/203", nil)
- if !strings.Contains(string(emptyData), `"signeds":[]`) {
- t.Fatalf("无报名时 signeds 必须是 [](不得 null):%s", string(emptyData))
- }
- }
- // ---------------------------------------------------------------- ⑤ 报名列表
- // TestListSignedsInvalidStatusIs2002 报名列表:status 越界 → 2002;合法空集 → `list:[]`。
- //
- // 会变红的条件:删掉 0~4 白名单 ⇒ status=5 返回 200 → 红;
- // 若列表用 nil 切片 ⇒ `"list":[]` 断言失败 → 红。
- func TestListSignedsInvalidStatusIs2002(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 301, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Money: "10.00", Status: 1,
- })
- if code, _, _ := doJSON(t, app, "GET", "/admin/activities/301/signeds?status=5", nil); code != 2002 {
- t.Fatalf("status=5 期望 2002,实得 %d", code)
- }
- code, msg, data := doJSON(t, app, "GET", "/admin/activities/301/signeds", nil)
- if code != 200 {
- t.Fatalf("报名列表期望 200,实得 %d(%s)", code, msg)
- }
- if !strings.Contains(string(data), `"list":[]`) {
- t.Fatalf("无报名时 list 必须是 []:%s", string(data))
- }
- // 活动越权 → 3001(**不是** 空列表:两者语义不同)
- if code, _, _ := doJSON(t, app, "GET", "/admin/activities/999/signeds", nil); code != 3001 {
- t.Fatalf("不存在/越权活动的报名列表期望 3001,实得 %d", code)
- }
- }
- // ---------------------------------------------------------------- ⑥ 更新
- // TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001 更新:`numberSign` 传即 2002;跨机构 3001。
- //
- // 为什么是 2002 不是 3140(v1.6 契约更正):设计 §10.1 限定「3140 只收**参数之间**互相矛盾」;
- // "传了一个不可写字段"属**单一字段取值非法** ⇒ 2002。3140 若被塞这类判断,会退化成垃圾桶。
- //
- // 会变红的条件:
- // - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 2002 变 200,且 DB 里 number_sign 被改写 → 红;
- // - 把错误码改回 3140 ⇒ 实得 3140 ≠ 2002 → 红(守住"契约 v1.6 的这一处更正");
- // - 去掉 `UpdateActivity` 的 `RowsAffected==0` 守卫 ⇒ 更新别家机构活动返回 200 → 红。
- func TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 401, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Money: "10.00", NumberMax: 3, NumberSign: 2, Status: 1,
- })
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 402, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20,
- Money: "10.00", Status: 1,
- })
- body := map[string]any{"type": 2, "title": "改标题", "money": "10.00", "status": 1, "numberSign": 7}
- code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body)
- if code != 2002 {
- t.Fatalf("numberSign 传即 2002(契约 §3.4 B 行 25,v1.6),实得 %d(%s)", code, msg)
- }
- if got := readActivityByID(t, db, 401).NumberSign; got != 2 {
- t.Fatalf("2002 被拒后 number_sign 必须一字不动(2),实得 %d", got)
- }
- // 去掉 numberSign 后正常更新
- delete(body, "numberSign")
- if code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body); code != 200 {
- t.Fatalf("正常更新期望 200,实得 %d(%s)", code, msg)
- }
- updated := readActivityByID(t, db, 401)
- if updated.Title == nil || *updated.Title != "改标题" {
- t.Fatalf("title 未更新:%v", updated.Title)
- }
- if updated.NumberSign != 2 {
- t.Fatalf("更新不得改动 number_sign,实得 %d", updated.NumberSign)
- }
- // 跨机构 → 3001(RowsAffected==0 守卫;删掉守卫会变 200)
- if code, _, _ := doJSON(t, app, "PUT", "/admin/activities/402", body); code != 3001 {
- t.Fatalf("跨机构更新期望 3001,实得 %d", code)
- }
- }
- // ---------------------------------------------------------------- ⑦ 报名 / 取消报名
- // TestSignThenCancelCrossTableEffects 报名 → 取消的**跨表**效果(端点 26/27)。
- //
- // 会变红的条件:
- // - 去掉 3105 判据 ⇒ 第二次报名返回 200 → 红;
- // - 取消时只改 status 不归零金额(少写 activity_money/activity_money_used)⇒ 库值断言红;
- // - 取消时漏 `number_sign -1` ⇒ number_sign 仍为 1 → 红;
- // - 取消后再查 3001(无有效报名行)⇒ 若变 200 说明"已取消"被判成有效 → 红。
- func TestSignThenCancelCrossTableEffects(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 501, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Money: "100.00", NumberMax: 0, Status: 1,
- })
- code, msg, data := doJSON(t, app, "POST", "/admin/activities/501/sign",
- map[string]any{"userId": "U100"})
- if code != 200 {
- t.Fatalf("报名期望 200,实得 %d(%s)", code, msg)
- }
- sign := decodeData[struct {
- SignedID int64 `json:"signedId"`
- }](t, data)
- if sign.SignedID <= 0 {
- t.Fatalf("报名应回 signedId>0,实得 %d", sign.SignedID)
- }
- if n := countRows(t, db, &model.CoursesActivitySigned{}, "activity_id = ? AND user_id = ?", 501, "U100"); n != 1 {
- t.Fatalf("报名行应恰 1 条,实得 %d", n)
- }
- if got := readActivityByID(t, db, 501).NumberSign; got != 1 {
- t.Fatalf("报名后 number_sign 应为 1,实得 %d", got)
- }
- // 重复报名 → 3105(判**报名行** status)
- if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/sign",
- map[string]any{"userId": "U100"}); code != 3105 {
- t.Fatalf("重复报名期望 3105,实得 %d", code)
- }
- // 取消 → 三列归零 + number_sign-1(跨表断言,不看响应体)
- if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
- map[string]any{"userId": "U100"}); code != 200 {
- t.Fatalf("取消报名期望 200,实得 %d(%s)", code, msg)
- }
- var signed model.CoursesActivitySigned
- if err := db.Where("activity_id = ? AND user_id = ?", 501, "U100").Take(&signed).Error; err != nil {
- t.Fatalf("读报名行失败: %v", err)
- }
- if signed.Status != model.ActivitySignedCancelled {
- t.Fatalf("取消后 status 应为 0,实得 %d", signed.Status)
- }
- if !sameMoney(signed.ActivityMoney, "0.00") || !sameMoney(signed.ActivityMoneyUsed, "0.00") {
- t.Fatalf("取消后 activity_money/activity_money_used 必须同置 0.00(dump 763028),实得 %q/%q",
- signed.ActivityMoney, signed.ActivityMoneyUsed)
- }
- if got := readActivityByID(t, db, 501).NumberSign; got != 0 {
- t.Fatalf("取消后 number_sign 应为 0,实得 %d", got)
- }
- // 再次取消 → 3001(已无有效报名行)
- if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign",
- map[string]any{"userId": "U100"}); code != 3001 {
- t.Fatalf("重复取消期望 3001,实得 %d", code)
- }
- }
- // ---------------------------------------------------------------- ⑧ 3107 审核态
- // TestCancelSignAuditedIs3107WithZeroSideEffects 已审核(type=2 且 response_user_id 有值)→ 3107,且**零副作用**。
- //
- // 会变红的条件:
- // - 去掉 3107 判据 ⇒ 返回 200 且 status 变 0 → 红;
- // - 把 `number_sign-1` 挪到 3107 守卫之前 ⇒ number_sign 变 0(期望 1)→ 红("拒绝必须零副作用")。
- func TestCancelSignAuditedIs3107WithZeroSideEffects(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 601, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Money: "100.00", NumberSign: 1, Status: 1,
- })
- auditor := "01HAUDITOR00000000000001"
- seedSignedRow(t, db, 61, 601, "U200", model.ActivitySignedSigned, "100.00", &auditor)
- if code, _, _ := doJSON(t, app, "POST", "/admin/activities/601/cancel-sign",
- map[string]any{"userId": "U200"}); code != 3107 {
- t.Fatalf("已审核报名期望 3107,实得 %d", code)
- }
- var signed model.CoursesActivitySigned
- if err := db.Where("id = ?", 61).Take(&signed).Error; err != nil {
- t.Fatalf("读报名行失败: %v", err)
- }
- if signed.Status != model.ActivitySignedSigned {
- t.Fatalf("3107 被拒后 status 必须仍为 1,实得 %d", signed.Status)
- }
- if !sameMoney(signed.ActivityMoney, "100.00") {
- t.Fatalf("3107 被拒后 activity_money 必须仍为 100.00,实得 %q", signed.ActivityMoney)
- }
- if got := readActivityByID(t, db, 601).NumberSign; got != 1 {
- t.Fatalf("3107 被拒后 number_sign 必须仍为 1(守卫在 UPDATE 之前),实得 %d", got)
- }
- }
- // ---------------------------------------------------------------- ⑨ 3121 type 读不到
- // TestCancelSignUnresolvableTypeIs3121 报名行在、活动行没了 ⇒ `type` 读不到 ⇒ **3121**(§3.8-R8)。
- //
- // 会变红的条件(**本用例是 R8 的唯一判据**):
- // - 把 `ExistsAnySigned` 分支删掉 ⇒ 落回 3001 → 红(那就是"读不到 type 就放行/当成不存在");
- // - 反过来把分界写反(有报名行 → 3001)⇒ 期望 3121 实得 3001 → 红。
- //
- // 为什么这种情况不是 3001:报名行**确实存在**(§3.8-R7 的 3001 是"查不到报名");
- // 缺的是活动行 ⇒ 3107/3108 两条审核态保护无法判定 ⇒ 显式 3121,不得静默取消。
- func TestCancelSignUnresolvableTypeIs3121(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- // 报名行存在(status=1),但活动行**不存在**(模拟活动被物理删除的历史脏数据)
- seedSignedRow(t, db, 71, 701, "U300", model.ActivitySignedSigned, "100.00", nil)
- if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/701/cancel-sign",
- map[string]any{"userId": "U300"}); code != 3121 {
- t.Fatalf("取不到 type 期望 3121(§3.8-R8),实得 %d(%s)——"+
- "若为 3001 说明把\"报名行存在但活动行缺失\"当成了\"报名不存在\"", code, msg)
- }
- var signed model.CoursesActivitySigned
- if err := db.Where("id = ?", 71).Take(&signed).Error; err != nil {
- t.Fatalf("读报名行失败: %v", err)
- }
- if signed.Status != model.ActivitySignedSigned {
- t.Fatalf("3121 被拒后 status 必须仍为 1,实得 %d", signed.Status)
- }
- }
- // ---------------------------------------------------------------- ⑩ 老带新结算
- // TestRecommendSettleNoopDoesNotWrite 老带新结算:无 type=4 报名 ⇒ `settled=false` 且**零写入**。
- //
- // 会变红的条件:
- // - handler 把 `settled` 恒写成 true ⇒ 断言 `false` 失败 → 红;
- // - 服务层漏掉"游标无行 → LEAVE"⇒ used_log 出现 1 行 → 跨表断言红。
- func TestRecommendSettleNoopDoesNotWrite(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 801, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
- Money: "50.00", Status: 1,
- })
- code, msg, data := doJSON(t, app, "POST", "/admin/activities/801/recommend-settle",
- map[string]any{"userId": "U400", "orderNo": "1001"})
- if code != 200 {
- t.Fatalf("老带新结算期望 200,实得 %d(%s)", code, msg)
- }
- res := decodeData[struct {
- Settled bool `json:"settled"`
- ActivityID int64 `json:"activityId"`
- }](t, data)
- if res.Settled {
- t.Fatalf("无 type=4 报名时 settled 必须为 false(dump LEAVE 分支),实得 true")
- }
- if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ?", 801); n != 0 {
- t.Fatalf("settled=false 必须零写入,实得 used_log %d 行", n)
- }
- }
- // ---------------------------------------------------------------- ⑪ 取消充值活动
- // TestCancelRechargeUnknownCampusIs3104 校区不存在/未归属 ⇒ **3104**(端点 30)。
- //
- // 会变红的条件:删掉校区端口调用(或吞掉 3104)⇒ 变 200/5002 → 红。
- func TestCancelRechargeUnknownCampusIs3104(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 901, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Money: "100.00", Status: 1,
- })
- code, msg, _ := doJSON(t, app, "POST", "/admin/activities/901/cancel-recharge",
- map[string]any{"userId": "U500", "campusId": 999, "money": "10.00"})
- if code != 3104 {
- t.Fatalf("未知校区期望 3104,实得 %d(%s)", code, msg)
- }
- }
- // ---------------------------------------------------------------- ⑫ 可用赠送
- // TestUsableBreakdownScopeAndShape 可用赠送:跨机构 1004 / 平台视角角色 1003 / items 构成与总量一致。
- //
- // 会变红的条件:
- // - 删掉 `institutionId != sc.InstitutionID → 1004` ⇒ 能读到别的机构的池(200)→ 红;
- // - 删掉平台视角角色白名单 ⇒ academic 角色拿全平台池(200)→ 红;
- // - `usable` 不等于 Σ items 或 items 没按 activityId 升序 ⇒ 红;
- // - 加上 `memo` 键(无列支撑)⇒ `"memo"` 不得出现的断言红。
- func TestUsableBreakdownScopeAndShape(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- // 同机构两块池(活动 1001 余 60.00、活动 1002 余 30.00)+ 别家机构一块(活动 1003 余 999.00)
- seedDiscountPool(t, db, 1, 1001, "U600", "100.00", "40.00")
- seedDiscountPool(t, db, 2, 1002, "U600", "30.00", "0.00")
- seedDiscountPool(t, db, 3, 1003, "U600", "999.00", "0.00")
- if err := db.Model(&model.CoursesActivityUserDiscount{}).Where("id = ?", 3).
- Update("institution_id", 2).Error; err != nil {
- t.Fatalf("改池机构失败: %v", err)
- }
- code, msg, data := doJSON(t, app, "GET",
- "/admin/activities/discounts/usable?userId=U600&institutionId=1&campusId=10", nil)
- if code != 200 {
- t.Fatalf("可用赠送期望 200,实得 %d(%s)", code, msg)
- }
- res := decodeData[struct {
- UserID string `json:"userId"`
- CampusID int64 `json:"campusId"`
- Usable string `json:"usable"`
- Items []struct {
- ActivityID int64 `json:"activityId"`
- Amount string `json:"amount"`
- } `json:"items"`
- }](t, data)
- if res.Usable != "90.00" {
- t.Fatalf("usable 应为 90.00(60+30,不含别家机构),实得 %q", res.Usable)
- }
- if len(res.Items) != 2 {
- t.Fatalf("items 应有 2 项(按活动),实得 %d", len(res.Items))
- }
- if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 {
- t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items)
- }
- sum := money.Amount(0)
- for _, item := range res.Items {
- amount, err := money.Parse(item.Amount)
- if err != nil {
- t.Fatalf("items[].amount 不是合法金额:%q", item.Amount)
- }
- sum = sum.Add(amount)
- }
- usable, _ := money.Parse(res.Usable)
- if sum != usable {
- t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable, sum.String())
- }
- if strings.Contains(string(data), `"memo"`) {
- t.Fatalf("items 不得下发 memo(赠送池无 memo 列,契约缺口待裁决):%s", string(data))
- }
- // 跨机构 → 1004
- if code, _, _ := doJSON(t, app, "GET",
- "/admin/activities/discounts/usable?userId=U600&institutionId=2&campusId=20", nil); code != 1004 {
- t.Fatalf("跨机构查询期望 1004,实得 %d", code)
- }
- // 平台视角(institutionId=0):finance 放行;academic → 1003
- if code, _, _ := doJSON(t, app, "GET",
- "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0", nil); code != 200 {
- t.Fatalf("平台视角(finance)应放行,实得 %d", code)
- }
- if code, _, _ := doJSON(t, app, "GET",
- "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0",
- nil, "X-Test-Scope", "academic"); code != 1003 {
- t.Fatalf("平台视角 academic 角色期望 1003(设计 §3.2-A7),实得 %d", code)
- }
- }
- // ---------------------------------------------------------------- ⑬ 赠送使用
- // TestDiscountUseNoopAndInsufficient 端点 32:金额 0 → `used=false` 且零写入;无池 → **3109**。
- //
- // 会变红的条件:
- // - 把 `amount<=0` 改成报错/或照样扣 ⇒ used/库值断言红(§3.8-Z1 要求"跳过");
- // - 删掉"可用不足 → 3109"⇒ 变 200 且池被扣成负 → 红。
- func TestDiscountUseNoopAndInsufficient(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 1101, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
- Money: "100.00", Status: 1,
- })
- seedDiscountPool(t, db, 11, 1101, "U700", "100.00", "0.00")
- // ① 金额 0 → no-op(不是错误)
- code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/use",
- map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "0.00"})
- if code != 200 {
- t.Fatalf("金额 0 期望 200(dump no-op),实得 %d(%s)", code, msg)
- }
- if decodeData[struct {
- Used bool `json:"used"`
- }](t, data).Used {
- t.Fatalf("金额 0 时 used 必须为 false")
- }
- if n := countRows(t, db, &model.CoursesActivityUserDiscountLog{}, "activity_user_discount_id = ?", 11); n != 0 {
- t.Fatalf("金额 0 必须零写入,实得流水 %d 行", n)
- }
- // ② 超过可用 → 3109(Go 增强:dump 静默 LEAVE)
- if code, _, _ := doJSON(t, app, "POST", "/admin/activities/discounts/use",
- map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "200.00"}); code != 3109 {
- t.Fatalf("可用不足期望 3109,实得 %d", code)
- }
- // ③ 正常使用 → used=true + 池已用额 + 负值流水(跨表)
- //
- // ⚠ 活动 type=3(代金券)的口径(§3.2 #15,dump 763229):**实际占用额 = 券面额**,
- // 不是请求额("一次用完一张")。故请求 10.00、池却记 100.00 —— 这正是 dump 的既有行为,
- // 若有人"顺手修正"成请求额,本断言立刻红(这是**有意锁定**的偏差点,不是测试笔误)。
- code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/use",
- map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "10.00"})
- if code != 200 {
- t.Fatalf("正常使用期望 200,实得 %d(%s)", code, msg)
- }
- if !decodeData[struct {
- Used bool `json:"used"`
- }](t, data).Used {
- t.Fatalf("正常使用 used 应为 true")
- }
- var pool model.CoursesActivityUserDiscount
- if err := db.Where("id = ?", 11).Take(&pool).Error; err != nil {
- t.Fatalf("读池失败: %v", err)
- }
- if !sameMoney(pool.DiscountAmountUsed, "100.00") {
- t.Fatalf("代金券(type=3) 已用额应为券面额 100.00(dump 763229),实得 %q", pool.DiscountAmountUsed)
- }
- var logRow model.CoursesActivityUserDiscountLog
- if err := db.Where("activity_user_discount_id = ?", 11).Take(&logRow).Error; err != nil {
- t.Fatalf("读流水失败: %v", err)
- }
- if !sameMoney(logRow.DiscountAmountUsed, "-100.00") {
- t.Fatalf("流水必须是负值(消费方向)且等于面额 -100.00,实得 %q", logRow.DiscountAmountUsed)
- }
- }
- // ---------------------------------------------------------------- ⑭ 充值赠送使用
- // TestRechargeGiftUseRequiresAttendedStatus 端点 35:报名行必须 `status=2`(dump 游标 763262)。
- //
- // 会变红的条件:
- // - 删掉 `status=2` 前置 ⇒ status=1 也扣(used=true)→ 红;
- // - 扣减后 `activity_money_used` 未更新 ⇒ 跨表断言红。
- func TestRechargeGiftUseRequiresAttendedStatus(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 1201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
- Money: "100.00", Status: 1,
- })
- seedSignedRow(t, db, 121, 1201, "U800", model.ActivitySignedSigned, "100.00", nil) // status=1(仅报名)
- code, msg, data := doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
- map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
- if code != 200 {
- t.Fatalf("期望 200(no-op),实得 %d(%s)", code, msg)
- }
- if decodeData[struct {
- Used bool `json:"used"`
- }](t, data).Used {
- t.Fatalf("status=1(未参加)时 used 必须为 false(dump 763262 前置)")
- }
- // 置为已参加 → 正常扣减
- if err := db.Model(&model.CoursesActivitySigned{}).Where("id = ?", 121).
- Update("status", model.ActivitySignedAttended).Error; err != nil {
- t.Fatalf("改报名状态失败: %v", err)
- }
- code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use",
- map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"})
- if code != 200 {
- t.Fatalf("期望 200,实得 %d(%s)", code, msg)
- }
- if !decodeData[struct {
- Used bool `json:"used"`
- }](t, data).Used {
- t.Fatalf("status=2 时 used 应为 true")
- }
- var signed model.CoursesActivitySigned
- if err := db.Where("id = ?", 121).Take(&signed).Error; err != nil {
- t.Fatalf("读报名行失败: %v", err)
- }
- if !sameMoney(signed.ActivityMoneyUsed, "30.00") {
- t.Fatalf("activity_money_used 应为 30.00,实得 %q", signed.ActivityMoneyUsed)
- }
- if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ? AND user_id = ?", 1201, "U800"); n != 1 {
- t.Fatalf("used_log 应恰 1 行,实得 %d", n)
- }
- }
- // ---------------------------------------------------------------- ⑮ 三条退回
- // TestRefundEndpointsReportRefundedFlag 端点 33/34/36:无可用流水 ⇒ `refunded=false`(不是恒 true)。
- //
- // 会变红的条件:把响应的 `refunded` 写成常量 true("操作成功即 true")⇒ 三条断言全红。
- // 此外 33 的正常路径做**跨表**断言(流水回冲 + 池已用额下降)。
- func TestRefundEndpointsReportRefundedFlag(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- // 端点 33:无流水 → false
- code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/refund",
- map[string]any{"coursesStudentId": 4242, "amount": "10.00"})
- if code != 200 {
- t.Fatalf("赠送退回期望 200,实得 %d(%s)", code, msg)
- }
- if decodeData[struct {
- Refunded bool `json:"refunded"`
- }](t, data).Refunded {
- t.Fatalf("无可用流水时 refunded 必须为 false")
- }
- // 端点 34:无 log → false
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 1301, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10,
- Money: "100.00", Status: 1,
- })
- code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/refund-by-activity",
- map[string]any{"userId": "U900", "activityId": 1301, "amount": "10.00"})
- if code != 200 {
- t.Fatalf("按活动退回期望 200,实得 %d(%s)", code, msg)
- }
- if decodeData[struct {
- Refunded bool `json:"refunded"`
- }](t, data).Refunded {
- t.Fatalf("无 log 时 refunded 必须为 false(dump no-op)")
- }
- // 端点 36:无 used_log → false
- code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/refund",
- map[string]any{"coursesStudentId": 5555, "amount": "10.00"})
- if code != 200 {
- t.Fatalf("充值赠送退回期望 200,实得 %d(%s)", code, msg)
- }
- if decodeData[struct {
- Refunded bool `json:"refunded"`
- }](t, data).Refunded {
- t.Fatalf("无 used_log 时 refunded 必须为 false")
- }
- }
- // ---------------------------------------------------------------- ⑯ 回访
- // TestVisitRejectsCrossActivitySigned 回访:报名行不属于路径活动 ⇒ **3001**;正常 ⇒ 落库 1 行。
- //
- // 会变红的条件:
- // - 删掉 `signed.ActivityID != in.ActivityID` 判断 ⇒ 跨活动回访 200 且写出一行(无法事后筛出)→ 红;
- // - `visited` 恒 false 或漏写库 ⇒ 跨表断言红。
- func TestVisitRejectsCrossActivitySigned(t *testing.T) {
- db := newHandlerTestDB(t)
- app := newActivityTestApp(t, db)
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 1401, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
- Money: "0.00", Status: 1,
- })
- seedActivityRow(t, db, model.CoursesActivity{
- ID: 1402, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10,
- Money: "0.00", Status: 1,
- })
- seedSignedRow(t, db, 141, 1402, "U950", model.ActivitySignedSigned, "0.00", nil)
- // 报名行 141 属于活动 1402,却提交到活动 1401 的回访端点 → 3001
- code, msg, _ := doJSON(t, app, "POST", "/admin/activities/1401/visits",
- map[string]any{"activitySignedId": 141, "memo": "电话回访"})
- if code != 3001 {
- t.Fatalf("跨活动回访期望 3001,实得 %d(%s)", code, msg)
- }
- if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 0 {
- t.Fatalf("被拒的跨活动回访不得写库,实得 %d 行", n)
- }
- // 正确活动 → 200 + 1 行 visit
- code, msg, data := doJSON(t, app, "POST", "/admin/activities/1402/visits",
- map[string]any{"activitySignedId": 141, "memo": "电话回访"})
- if code != 200 {
- t.Fatalf("回访登记期望 200,实得 %d(%s)", code, msg)
- }
- res := decodeData[struct {
- Visited bool `json:"visited"`
- ActivityID int64 `json:"activityId"`
- }](t, data)
- if !res.Visited || res.ActivityID != 1402 {
- t.Fatalf("回访响应错:%+v", res)
- }
- if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 1 {
- t.Fatalf("回访应落库 1 行,实得 %d", n)
- }
- }
- // sameMoney 比较两个元字符串的**金额值**(不比较字面量)。
- //
- // 为什么不能直接比字符串:sqlite 把 `DECIMAL(12,2)` 存成 NUMERIC,读回时 `"0.00"` 会变成 `"0"`、
- // `"100.00"` 变成 `"100"`(MySQL 的 DECIMAL 会原样返回两位)。断言必须比"值",否则同一用例
- // 在 sqlite 上红、在 MySQL 上绿 —— 那是"测试环境差异",不是实现差异。
- // 生产语义(对外 JSON)由 `money.Amount.MarshalJSON` 保证恒两位小数,不在本助手范围。
- func sameMoney(got, want string) bool {
- gotAmount, gerr := money.Parse(got)
- wantAmount, werr := money.Parse(want)
- if gerr != nil || werr != nil {
- return false
- }
- return gotAmount == wantAmount
- }
- // withFields 复制 base 并覆盖若干 k/v(k,v 交替),避免用例间互相污染 body。
- func withFields(base map[string]any, kv ...any) map[string]any {
- out := make(map[string]any, len(base)+len(kv)/2)
- for k, v := range base {
- out[k] = v
- }
- for i := 0; i+1 < len(kv); i += 2 {
- key, _ := kv[i].(string)
- out[key] = kv[i+1]
- }
- return out
- }
|