// ActivityAdminService 单测(AC-13b):活动管理面(端点 22~25 / 28 / 37)的**服务级**判据。 // // 与 handler 测试的分工: // // handler 测试 证明"端点通、码对、跨表效果对"(请求 → 响应 → 库) // 本文件 证明"服务内部规则"(`signable` 的时间窗/名额判定、跨机构校区拒绝、 // `RowsAffected==0 → 3001`、可用额构成的分组与排序、3121 的 R8 分界) // // 每条用例的第一段注释都写清"改哪一行会让它变红"——只写"期望值"的用例不算判据 // (它只能证明实现与断言一致,不能证明实现正确)。 package service import ( "context" "testing" "time" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/port" ) // TestSignableMatrix `signable` 的四条子判据(端点 22 的展示位)。 // // 会变红的条件(逐条对应一个分支): // - 去掉 `status != Enabled` ⇒ 停用活动 signable=true → 红; // - 去掉名额判定 ⇒ 满员活动 signable=true → 红; // - 去掉 `now < start` 判定 ⇒ 未开始活动 signable=true → 红; // - 去掉 `now > end` 判定 ⇒ 已结束活动 signable=true → 红。 // // ⚠ `signable` **只喂前端展示**:它变 true 不代表后端会收报名(dump 的 signed_add 无可用性校验, // §10.1-3110 已收窄)。本用例只锁"展示位算法",**不得**据此在写路径加校验。 func TestSignableMatrix(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityAdminService(db, activityScopeFor(1, 10)) fixed := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC) svc.WithClock(func() time.Time { return fixed }) start := fixed.Add(-24 * time.Hour) end := fixed.Add(24 * time.Hour) before := fixed.Add(24 * time.Hour) after := fixed.Add(-24 * time.Hour) cases := []struct { name string act model.CoursesActivity want bool }{ {"开启+在窗口内+有名额", model.CoursesActivity{ Status: model.ActivityStatusEnabled, NumberMax: 5, NumberSign: 4, StartTime: &start, EndTime: &end, }, true}, {"开启+不限名额+无窗口", model.CoursesActivity{Status: model.ActivityStatusEnabled}, true}, {"停用", model.CoursesActivity{Status: model.ActivityStatusDisabled, StartTime: &start, EndTime: &end}, false}, {"满员", model.CoursesActivity{ Status: model.ActivityStatusEnabled, NumberMax: 3, NumberSign: 3, StartTime: &start, EndTime: &end, }, false}, {"未开始", model.CoursesActivity{Status: model.ActivityStatusEnabled, StartTime: &before}, false}, {"已结束", model.CoursesActivity{Status: model.ActivityStatusEnabled, EndTime: &after}, false}, } for _, tc := range cases { act := tc.act if got := svc.signable(&act); got != tc.want { t.Errorf("%s:signable 期望 %v,实得 %v", tc.name, tc.want, got) } } } // TestCreateActivityRejectsCrossCampus 新建的校区归属校验:跨机构 → **1004**;校区不存在 → **3104**。 // // 会变红的条件:把 `assertCampusInScope` 的 `schoolID != institutionID` 判断删掉 ⇒ 跨机构写入成功 → 红。 func TestCreateActivityRejectsCrossCampus(t *testing.T) { db := newActivityDomainDB(t) // 复用 fund_service_test.go 的 seedCampus(self_support=true 自营) seedCampus(t, db, 10, 1, true) seedCampus(t, db, 20, 2, true) svc := NewActivityAdminService(db, activityScopeFor(1, 10)).WithCampusReader(port.NewOrgCampusReader(db)) ctx := context.Background() base := CreateActivityInput{ InstitutionID: 1, Type: model.ActivityTypeRecharge, Title: "跨机构测试", Money: money.FromCents(10000), Status: model.ActivityStatusEnabled, } cross := base cross.CampusID = 20 if _, err := svc.CreateActivity(ctx, cross); err == nil { t.Fatalf("校区 20 属于机构 2,写入应被拒(1004),实得 nil") } else { wantAppErr(t, err, 1004, "跨机构校区写入") } missing := base missing.CampusID = 999 if _, err := svc.CreateActivity(ctx, missing); err == nil { t.Fatalf("校区 999 不存在,应 3104,实得 nil") } else { wantAppErr(t, err, 3104, "校区不存在") } ok := base ok.CampusID = 10 res, err := svc.CreateActivity(ctx, ok) if err != nil { t.Fatalf("本机构校区写入应成功,实得 %v", err) } var row model.CoursesActivity if e := db.Where("id = ?", res.ID).Take(&row).Error; e != nil { t.Fatalf("读新建活动失败: %v", e) } if row.NumberSign != 0 { t.Fatalf("number_sign 必须为 0,实得 %d", row.NumberSign) } if row.InstitutionID != 1 { t.Fatalf("institution_id 应为 1,实得 %d", row.InstitutionID) } } // TestUpdateActivityMissingRowIs3001 更新不存在/越权活动 → **3001**(`RowsAffected==0` 守卫)。 // // 会变红的条件:删掉 `if affected == 0 { return ErrNotFound }` ⇒ 该用例实得 nil(假成功)→ 红。 // 这正是 §10.3 红线「写路径必须校验影响行数」在活动域的落点。 func TestUpdateActivityMissingRowIs3001(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityAdminService(db, activityScopeFor(1, 10)) _, err := svc.UpdateActivity(context.Background(), UpdateActivityInput{ ID: 123456, InstitutionID: 1, Type: model.ActivityTypeRecharge, Title: "改名", Money: money.FromCents(1), Status: model.ActivityStatusEnabled, }) wantAppErr(t, err, 3001, "更新不存在的活动") } // TestUsableBreakdownGroupsByActivity 可用赠送按活动分组:总额 = Σ 构成、按 activityId 升序、不可用行不计。 // // 会变红的条件: // - 把 `usable` 改成"再查一次 SQL 汇总"(而不是 Σ items)⇒ 两者在并发/脏行下会不一致 → 红; // - 去掉 `amount <= 0` 跳过 ⇒ 出现 0 元 item → 红; // - 去掉排序 ⇒ map 遍历顺序随机,断言 flaky(本用例固定期望 [1001,1002])。 func TestUsableBreakdownGroupsByActivity(t *testing.T) { db := newActivityDomainDB(t) userID := "01HSTUDENT000000000000001" pools := []model.CoursesActivityUserDiscount{ {ID: 1, ActivityID: 1001, InstitutionID: 1, CampusID: 10, UserID: userID, Income: "0.00", DiscountAmount: "100.00", DiscountAmountUsed: "40.00"}, {ID: 2, ActivityID: 1002, InstitutionID: 1, CampusID: 10, UserID: userID, Income: "0.00", DiscountAmount: "30.00", DiscountAmountUsed: "0.00"}, // 不可用行(已用 >= 面额)→ 两个方法都不计 {ID: 3, ActivityID: 1003, InstitutionID: 1, CampusID: 10, UserID: userID, Income: "0.00", DiscountAmount: "10.00", DiscountAmountUsed: "10.00"}, } for i := range pools { if err := db.Create(&pools[i]).Error; err != nil { t.Fatalf("seed 池失败: %v", err) } } svc := NewActivityDiscountService(db, activityScopeFor(1, 10)) res, err := svc.UsableBreakdown(context.Background(), UsableAmountInput{ UserID: userID, InstitutionID: 1, CampusID: 10, }) if err != nil { t.Fatalf("UsableBreakdown 失败: %v", err) } if len(res.Items) != 2 { t.Fatalf("items 应为 2 项(1003 不可用被排除),实得 %d: %+v", len(res.Items), res.Items) } if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 { t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items) } if res.Usable != money.FromCents(9000) { t.Fatalf("usable 应为 90.00 元(9000 分),实得 %s", res.Usable.String()) } sum := money.Amount(0) for _, item := range res.Items { sum = sum.Add(item.Amount) } if sum != res.Usable { t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable.String(), sum.String()) } } // TestCancelSignUnresolvableTypeIs3121 服务层 R8:报名行在、活动行缺失 ⇒ 3121(不是 3001)。 // // 会变红的条件:删掉 `ExistsAnySigned` 分支 ⇒ 落回 3001 → 红。 // 本用例与 handler 的 `TestCancelSignUnresolvableTypeIs3121` 成对:一个锁服务分界, // 一个锁 HTTP 码不被 handler 改写(handler 是纯透传,两层都得有判据)。 func TestCancelSignUnresolvableTypeIs3121(t *testing.T) { db := newActivityDomainDB(t) userID := "01HSTUDENT000000000000002" // 只有报名行,没有活动行(模拟活动被物理删除后的历史脏数据) if err := db.Create(&model.CoursesActivitySigned{ ID: 91, ActivityID: 9101, UserID: userID, InstitutionID: 1, CampusID: 10, Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00", }).Error; err != nil { t.Fatalf("seed 报名行失败: %v", err) } svc := NewActivityService(db, activityScopeFor(1, 10)) _, err := svc.CancelSign(context.Background(), CancelSignInput{UserID: userID, ActivityID: 9101}) wantAppErr(t, err, 3121, "取消报名取不到活动类型") // 反向确认:完全没有报名行时仍是 3001(§3.8-R7),两者不得合并 _, err2 := svc.CancelSign(context.Background(), CancelSignInput{UserID: userID, ActivityID: 9199}) wantAppErr(t, err2, 3001, "无报名行") }