|
@@ -0,0 +1,1082 @@
|
|
|
|
|
+// 活动域 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
|
|
|
|
|
+}
|