| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- // BillService 单测(AC-10):账单汇总 / 明细分页 / 三种比较语义并存 / 赠送额回退口径。
- //
- // 断言纪律(对照 §5.1 纪律 4「横切优先」):
- // 1. **账单恒等式**(跨字段):`EndBalance − PrevBalance == FlowExclusiveEnd`,
- // 且 `EndBalance` 必须与 **`t_user_expense.balance` 账户表**一致(跨表)——
- // 只断言"字段非空"或"条数对了"的实现,在"漏算某个 type / 符号写反"时依然全绿。
- // 2. **两种时间口径并存**(dump 的既有不对称,不得统一):
- // `FlowInclusiveEnd − FlowExclusiveEnd == 恰好落在 t = To 的那一笔`,
- // 且该笔**计入**「当期发生额」(BETWEEN 含右端)而**不计入**「期末余额」(`< To`)。
- // 3. **期初/期末不随分页变化**:翻页只影响明细。
- // 4. **赠送额三段链**:`GiveUsed = Gift + GivePrevBalance − GiveEndBalance`(dump 764194)。
- package service
- import (
- "context"
- "path/filepath"
- "reflect"
- "testing"
- "time"
- gormsqlite "github.com/glebarez/sqlite"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- )
- // ---------------------------------------------------------------- 辅助
- // newBillServiceDB 账单域测试库(流水 4 表 + 全局钱包)。
- func newBillServiceDB(t *testing.T) *gorm.DB {
- t.Helper()
- dsn := filepath.Join(t.TempDir(), "academic_bill_service_test.db")
- db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
- if err != nil {
- t.Fatalf("gorm.Open(账单域文件库) 失败: %v", err)
- }
- sqlDB, err := db.DB()
- if err != nil {
- t.Fatalf("取 sql.DB 失败: %v", err)
- }
- sqlDB.SetMaxOpenConns(1)
- t.Cleanup(func() { _ = sqlDB.Close() })
- migrateFundTables(t, db,
- &model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{},
- &model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{},
- )
- return db
- }
- // seedBillLog 落一行资金流水(logID 为 nil 表示旧库 `logid IS NULL`)。
- func seedBillLog(t *testing.T, db *gorm.DB, userID string, logType int32,
- amount string, createTime time.Time, logID *string) {
- t.Helper()
- row := &model.UserExpenseLog{
- UserID: userID, Type: logType, Money: amount, CreateTime: createTime, LogID: logID,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 流水(type=%d) 失败: %v", logType, err)
- }
- }
- // seedBillAccount 落一行全局钱包(**余额必须与流水累计一致**,否则跨表恒等式用例失去意义)。
- func seedBillAccount(t *testing.T, db *gorm.DB, userID, balance string) {
- t.Helper()
- row := &model.UserExpense{UserID: userID, Balance: balance}
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 全局钱包 失败: %v", err)
- }
- }
- // seedBillRechargeRequest 落一行已通过的普通充值工单(赠送额链路的 join 起点)。
- func seedBillRechargeRequest(t *testing.T, db *gorm.DB, bizNo, userID string, responseTime time.Time) {
- t.Helper()
- approved := model.BalanceRequestApprovedPassed
- respAt := responseTime
- row := &model.UserBalanceRequest{
- BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
- Approved: approved, ResponseTime: &respAt,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 充值工单 失败: %v", err)
- }
- }
- // seedBillSigned 落一行活动报名(赠送额来源)。
- func seedBillSigned(t *testing.T, db *gorm.DB, activityID int64, userID, activityMoney, requestNo string) {
- t.Helper()
- no := requestNo
- row := &model.CoursesActivitySigned{
- ActivityID: activityID, UserID: userID, ActivityMoney: activityMoney, RequestNo: &no,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 活动报名 失败: %v", err)
- }
- }
- // seedBillUsed 落一行老带新获得/使用记录。
- func seedBillUsed(t *testing.T, db *gorm.DB, activityID int64, userID, used string, createTime time.Time) {
- t.Helper()
- row := &model.CoursesActivityUsedLog{
- ActivityID: activityID, UserID: userID, ActivityMoneyUsed: used, CreateTime: createTime,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed used_log 失败: %v", err)
- }
- }
- // billFixture 标准账单种子(返回值即"按 dump 手算"的期望,用例逐字段比对)。
- //
- // 时间轴(window: from=2026-03-01T00:00, to=2026-04-01T00:00):
- //
- // 02-20 +500.00 type=100 log=REQ-A ← 只看得到"期初"
- // 03-05 -100.00 type=101 ← 当期消费
- // 03-10 used_log(9001) -20.00 ← 赠送额使用
- // 03-31 +30.00 type=107 ← 当期推荐额
- // 04-01 +200.00 type=100 log=REQ-B ← **恰好落在 to**:进"当期发生额"、不进"期末"
- //
- // 请求工单:REQ-A response_time=03-02(区间内,但**没有区间内的 type=100 流水指向它**)→ 不参与赠送;
- //
- // REQ-B response_time=04-01(BETWEEN 含右端,命中)→ signed(9001, 50.00) 计入当期赠送。
- func billFixture(t *testing.T) (*gorm.DB, *BillService, string, time.Time, time.Time) {
- t.Helper()
- db := newBillServiceDB(t)
- userID := fundUserID(900)
- reqA, reqB := "REQ-A", "REQ-B"
- seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "500.00",
- time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), &reqA)
- seedBillLog(t, db, userID, model.ExpenseLogTypeCourseCampus, "-100.00",
- time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC), nil)
- seedBillLog(t, db, userID, model.ExpenseLogTypeRecommend, "30.00",
- time.Date(2026, 3, 31, 18, 0, 0, 0, time.UTC), nil)
- seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "200.00",
- time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), &reqB)
- // 账户余额 = Σ全部流水 = 500 − 100 + 30 + 200
- seedBillAccount(t, db, userID, "630.00")
- seedBillRechargeRequest(t, db, "REQ-A", userID, time.Date(2026, 3, 2, 8, 0, 0, 0, time.UTC))
- seedBillRechargeRequest(t, db, "REQ-B", userID, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))
- seedBillSigned(t, db, 9001, userID, "50.00", "REQ-B")
- seedBillUsed(t, db, 9001, userID, "-20.00", time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC))
- svc := NewBillService(db, fundSuperScope())
- from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
- to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
- return db, svc, userID, from, to
- }
- // ---------------------------------------------------------------- 汇总口径(dump 764168-764194)
- // TestGetBillSummaryMatchesDump 主用例:9 个汇总列逐个对齐 dump 的 SELECT 顺序与口径。
- func TestGetBillSummaryMatchesDump(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- s := res.Summary
- // 764176 期初 / 764174 期末(均 `< 边界`)
- if got, want := s.PrevBalance.String(), "500.00"; got != want {
- t.Errorf("期初余额 = %s,期望 %s", got, want)
- }
- if got, want := s.EndBalance.String(), "430.00"; got != want {
- t.Errorf("期末余额 = %s,期望 %s(04-01 那笔因 `< to` 不计入)", got, want)
- }
- // 764170 当期推荐额 / 764172 当期消费 / 充值(764168 游标)
- if got, want := s.RecommendAmount.String(), "30.00"; got != want {
- t.Errorf("当期推荐额 = %s,期望 %s", got, want)
- }
- if got, want := s.Spend.String(), "-100.00"; got != want {
- t.Errorf("当期消费 = %s,期望 %s", got, want)
- }
- if got, want := s.Recharge.String(), "200.00"; got != want {
- t.Errorf("当期充值 = %s,期望 %s(BETWEEN 含右端,04-01 计入)", got, want)
- }
- // 赠送额三段链(764178-764194)
- if got, want := s.Gift.String(), "50.00"; got != want {
- t.Errorf("当期赠送额 = %s,期望 %s", got, want)
- }
- if got, want := s.GivePrevBalance.String(), "0.00"; got != want {
- t.Errorf("期初赠送结余 = %s,期望 %s(RIGHT JOIN 使'无 used_log 的活动'不进结余)", got, want)
- }
- if got, want := s.GiveEndBalance.String(), "30.00"; got != want {
- t.Errorf("期末赠送结余 = %s,期望 %s(signed 50 + used −20)", got, want)
- }
- if got, want := s.GiveUsed.String(), "20.00"; got != want {
- t.Errorf("当期使用赠送 = %s,期望 %s(= 赠送 50 + 上期 0 − 期末 30)", got, want)
- }
- }
- // TestGetBillIdentityAcrossAccountTable 横切断言 ①:期末余额 == 账户表余额(跨表自证)。
- //
- // 取 to 覆盖**全部流水**的窗口:此时"期末余额"就是账户快照。
- // 若实现漏算某个 type、或把某类流水符号写反,本用例立刻红(dump 的账户余额正是 Σ全部流水)。
- func TestGetBillIdentityAcrossAccountTable(t *testing.T) {
- db, svc, userID, from, _ := billFixture(t)
- var account model.UserExpense
- if err := db.Where("user_id = ?", userID).Take(&account).Error; err != nil {
- t.Fatalf("读全局钱包失败: %v", err)
- }
- to := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) // 覆盖 04-01 那笔
- res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- if res.Summary.EndBalance != mustAmount(t, account.Balance) {
- t.Fatalf("期末余额 %s != 账户表余额 %s", res.Summary.EndBalance.String(), account.Balance)
- }
- // 恒等式:期末 − 期初 == 窗口内(左闭右开)全类型流水合计
- delta := res.Summary.EndBalance.Sub(res.Summary.PrevBalance)
- if delta != res.Summary.FlowExclusiveEnd {
- t.Fatalf("恒等式破:期末−期初 = %s,窗口内流水 = %s",
- delta.String(), res.Summary.FlowExclusiveEnd.String())
- }
- if got, want := res.Summary.Gift.String(), "50.00"; got != want {
- t.Errorf("当期赠送额 = %s,期望 %s", got, want)
- }
- }
- // TestGetBillTimeBoundaryDiffersByExactlyOneFlow 横切断言 ②:两种比较语义并在 `t = To` 处差一笔。
- //
- // 断言三件事(缺任何一条,实现都可能"统一成一种语义"而全绿):
- // 1. `FlowInclusiveEnd − FlowExclusiveEnd` == 恰好那一笔(+200.00);
- // 2. `EndBalance − PrevBalance` == `FlowExclusiveEnd`(**不含**该笔);
- // 3. 该笔**计入**「当期充值」(BETWEEN),因此 Recharge 与 (期末−期初) 天然对不上。
- func TestGetBillTimeBoundaryDiffersByExactlyOneFlow(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- s := res.Summary
- if got, want := s.FlowExclusiveEnd.String(), "-70.00"; got != want {
- t.Errorf("窗口内(左闭右开)流水 = %s,期望 %s", got, want)
- }
- if got, want := s.FlowInclusiveEnd.String(), "130.00"; got != want {
- t.Errorf("窗口内(两端闭)流水 = %s,期望 %s", got, want)
- }
- diff := s.FlowInclusiveEnd.Sub(s.FlowExclusiveEnd)
- if got, want := diff.String(), "200.00"; got != want {
- t.Fatalf("两种口径的差额 = %s,期望 %s(必须恰好等于落在 t=to 的那笔)", got, want)
- }
- if delta := s.EndBalance.Sub(s.PrevBalance); delta != s.FlowExclusiveEnd {
- t.Fatalf("期末−期初(%s) != FlowExclusiveEnd(%s)", delta.String(), s.FlowExclusiveEnd.String())
- }
- }
- // TestGetBillSummaryInvariantAcrossPagination 横切断言 ③:翻页只影响明细(汇总逐字段不变)。
- //
- // v1.4 起明细**按窗口过滤**(`from <= create_time < to`),故 fixture 窗口内只有 2 笔
- // (03-05 消费、03-31 推荐);pageSize=2 时第 1 页满、第 2 页空——这正好同时验证
- // "翻页不改变汇总"与"total 与 list 同口径"(total=2 而非历史 4 笔)。
- func TestGetBillSummaryInvariantAcrossPagination(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- ctx := context.Background()
- page1, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 1, PageSize: 2})
- if err != nil {
- t.Fatalf("第 1 页失败: %v", err)
- }
- page2, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 2, PageSize: 2})
- if err != nil {
- t.Fatalf("第 2 页失败: %v", err)
- }
- if !reflect.DeepEqual(page1.Summary, page2.Summary) {
- t.Fatalf("汇总随分页变化(必须不随分页变化):\n第1页 %+v\n第2页 %+v", page1.Summary, page2.Summary)
- }
- if page1.Total != 2 || page2.Total != 2 {
- t.Fatalf("明细总数 = %d/%d,期望 2/2(**窗口内**流水,不是历史 4 笔)", page1.Total, page2.Total)
- }
- if len(page1.Details) != 2 || len(page2.Details) != 0 {
- t.Fatalf("分页失效:len=%d/%d,期望第 1 页满 2 条、第 2 页 0 条", len(page1.Details), len(page2.Details))
- }
- // 明细按 `create_time DESC, id DESC`:窗口内倒序应是 03-31(107) → 03-05(101)
- if page1.Details[0].Type != model.ExpenseLogTypeRecommend || page1.Details[0].Money.String() != "30.00" {
- t.Fatalf("第 1 页首行应为 03-31 的 107/+30.00,实得 %+v", page1.Details[0])
- }
- if page1.Details[1].Type != model.ExpenseLogTypeCourseCampus {
- t.Fatalf("第 1 页次行应为 03-05 的 101,实得 %+v", page1.Details[1])
- }
- }
- // TestGetBillDetailsRespectWindow 明细必须受查询窗口约束(v1.4):右开,且与 FlowExclusiveEnd 同口径。
- //
- // 三笔流水分别在窗口前(02-20)、窗口内(03-05/03-31)、窗口右端点上(04-01 = `to`):
- // - 窗口前那笔**不得**出现在明细(否则前端选"3 月"会看到 2 月流水);
- // - 落在 `t = to` 的那笔**不得**出现在明细(右开),但它**计入** `FlowInclusiveEnd`
- // —— 这就是 summary 与明细之间唯一的、且被显式下发的口径差。
- func TestGetBillDetailsRespectWindow(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- res, err := svc.GetBill(context.Background(), GetBillInput{
- UserID: userID, From: from, To: to, Page: 1, PageSize: 10,
- })
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- if len(res.Details) != 2 {
- t.Fatalf("窗口内明细应为 2 条,实得 %d 条:%+v", len(res.Details), res.Details)
- }
- for _, d := range res.Details {
- if d.CreateTime.Before(from) {
- t.Fatalf("明细含**窗口前**流水(%s < from %s)", d.CreateTime, from)
- }
- if !d.CreateTime.Before(to) {
- t.Fatalf("明细含**窗口右端点**流水(%s >= to %s):右开窗口必须排除", d.CreateTime, to)
- }
- if d.LogID == "REQ-A" || d.LogID == "REQ-B" {
- t.Fatalf("明细含窗口外工单流水 %s(REQ-A 在窗口前、REQ-B 恰在 to)", d.LogID)
- }
- }
- // 口径差显式可见:右端点那笔进 inclusive、不进明细
- diff := res.Summary.FlowInclusiveEnd.Sub(res.Summary.FlowExclusiveEnd)
- if got, want := diff.String(), "200.00"; got != want {
- t.Fatalf("inclusive−exclusive = %s,期望 %s(= 落 to 的那笔,它不进明细)", got, want)
- }
- }
- // TestGetBillCampusIDsIsParsedOnlyNotFiltered dump 第二入参是死参数:只解析回显、不过滤。
- //
- // 用 "1,2,3" 与 "" 两次查询比对汇总**完全一致**——若有人"顺手实现"了校区过滤,
- // 本用例立刻红(这会静默改变账单口径,见 bill_service.go 文件头)。
- func TestGetBillCampusIDsIsParsedOnlyNotFiltered(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- ctx := context.Background()
- plain, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to})
- if err != nil {
- t.Fatalf("无 campusIds 查询失败: %v", err)
- }
- withCampus, err := svc.GetBill(ctx, GetBillInput{
- UserID: userID, CampusIDs: "10, 20", From: from, To: to,
- })
- if err != nil {
- t.Fatalf("带 campusIds 查询失败: %v", err)
- }
- if !reflect.DeepEqual(withCampus.Summary.CampusIDs, []int64{10, 20}) {
- t.Fatalf("campusIds 回显 = %v,期望 [10 20]", withCampus.Summary.CampusIDs)
- }
- withCampus.Summary.CampusIDs = plain.Summary.CampusIDs
- if !reflect.DeepEqual(plain.Summary, withCampus.Summary) {
- t.Fatalf("campusIds 影响了汇总(dump 里它是死参数,不得过滤):\n无 %+v\n有 %+v",
- plain.Summary, withCampus.Summary)
- }
- if plain.Total != withCampus.Total {
- t.Fatalf("campusIds 影响了明细条数:%d vs %d", plain.Total, withCampus.Total)
- }
- }
- // TestGetBillEmptyAccount 无流水的学生:全部汇总为 0、明细为空切片(不是 null)。
- func TestGetBillEmptyAccount(t *testing.T) {
- db := newBillServiceDB(t)
- svc := NewBillService(db, fundSuperScope())
- res, err := svc.GetBill(context.Background(), GetBillInput{
- UserID: fundUserID(901),
- From: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
- To: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
- })
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- if res.Details == nil {
- t.Fatal("明细为 nil,应为长度 0 的切片")
- }
- if len(res.Details) != 0 || res.Total != 0 {
- t.Fatalf("无流水却返回明细 %d 条 / total=%d", len(res.Details), res.Total)
- }
- if !res.Summary.EndBalance.IsZero() || !res.Summary.PrevBalance.IsZero() ||
- !res.Summary.Gift.IsZero() || !res.Summary.GiveUsed.IsZero() {
- t.Fatalf("无流水时汇总应全 0,实得 %+v", res.Summary)
- }
- if res.Page != 1 || res.PageSize != 10 {
- t.Fatalf("分页默认值应为 (1,10),实得 (%d,%d)", res.Page, res.PageSize)
- }
- }
- // TestGetBillRejectsBadParams 入参守卫(2001);非法 campusIds 必须报错而不是"当没传"。
- func TestGetBillRejectsBadParams(t *testing.T) {
- db := newBillServiceDB(t)
- svc := NewBillService(db, fundSuperScope())
- ctx := context.Background()
- from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
- to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
- cases := []struct {
- name string
- in GetBillInput
- }{
- {"缺 user_id", GetBillInput{From: from, To: to}},
- {"缺 from", GetBillInput{UserID: fundUserID(1), To: to}},
- {"缺 to", GetBillInput{UserID: fundUserID(1), From: from}},
- {"from 晚于 to", GetBillInput{UserID: fundUserID(1), From: to, To: from}},
- {"campusIds 非法片段", GetBillInput{UserID: fundUserID(1), CampusIDs: "1,a", From: from, To: to}},
- {"campusIds 非正数", GetBillInput{UserID: fundUserID(1), CampusIDs: "0", From: from, To: to}},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- _, err := svc.GetBill(ctx, tc.in)
- wantAppErr(t, err, infraerrors.CodeBadParam, tc.name)
- })
- }
- }
- // TestGetBillDetailTitleAndMoney 明细展示字段:两位小数与类型名(金额不得输出浮点)。
- func TestGetBillDetailTitleAndMoney(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to, PageSize: 10})
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- wantTitles := map[int32]string{
- model.ExpenseLogTypeRecharge: "充值",
- model.ExpenseLogTypeCourseCampus: "课程消费(校区)",
- model.ExpenseLogTypeRecommend: "老带新推荐",
- }
- if len(res.Details) == 0 {
- t.Fatal("窗口内应有明细,实得 0 条")
- }
- for _, d := range res.Details {
- if want, ok := wantTitles[d.Type]; ok && d.TypeTitle != want {
- t.Errorf("type=%d 标题 = %q,期望 %q", d.Type, d.TypeTitle, want)
- }
- if d.Money.String() != "200.00" && d.Money.String() != "-100.00" &&
- d.Money.String() != "30.00" && d.Money.String() != "500.00" {
- t.Errorf("明细金额格式异常: %s(应两位小数)", d.Money.String())
- }
- }
- // 窗口内倒序首行 = 03-31 的 107(logId 为空),末行 = 03-05 的 101
- if res.Details[0].Type != model.ExpenseLogTypeRecommend {
- t.Errorf("首行 type = %d,期望 107(窗口内倒序首行)", res.Details[0].Type)
- }
- if res.Details[len(res.Details)-1].Type != model.ExpenseLogTypeCourseCampus {
- t.Errorf("末行 type = %d,期望 101", res.Details[len(res.Details)-1].Type)
- }
- }
- // TestBillServiceErrorCodeContract DB 未装配时必须 5001(不能把配置错误伪装成业务错误)。
- func TestBillServiceErrorCodeContract(t *testing.T) {
- svc := NewBillService(nil, fundSuperScope())
- _, err := svc.GetBill(context.Background(), GetBillInput{
- UserID: fundUserID(1),
- From: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
- To: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
- })
- wantAppErr(t, err, infraerrors.CodeInternal, "DB 未装配")
- }
- // TestBillPagesBoundaries 分页总页数的两个边界(`pages` 字段,契约 v1.4 rev.2)。
- //
- // 契约要求:`PageSize <= 0` → 0(防除零);其余 `ceil(Total/PageSize)`。
- // 两个最容易写错的点,逐个钉住:
- // - `Total = 0` → **0** 页(不是 1 页;空结果集不该显示"第 1/1 页");
- // - `Total = 25, PageSize = 10` → **3** 页(向上取整;写成整除会得 2)。
- func TestBillPagesBoundaries(t *testing.T) {
- cases := []struct {
- total int64
- pageSize int
- want int
- }{
- {0, 10, 0}, // 空结果集 → 0 页
- {25, 10, 3}, // 向上取整(整除会得 2)
- {10, 10, 1}, // 正好整页
- {11, 10, 2}, // 多一条即多一页
- {1, 200, 1}, // 单条
- {5, 0, 0}, // 防除零
- {-1, 10, 0}, // 脏数据不产生负页数
- }
- for _, tc := range cases {
- if got := billPages(tc.total, tc.pageSize); got != tc.want {
- t.Errorf("billPages(total=%d, pageSize=%d) = %d,期望 %d", tc.total, tc.pageSize, got, tc.want)
- }
- }
- }
- // TestGetBillPagesFollowsWindowedTotal `pages` 必须由**窗口内** total 与**钳制后** pageSize 算出。
- //
- // 若 pages 用历史全量 total(4)算,会得 1 页(pageSize=2 → 2 页);窗口内 total=2、pageSize=2 → 1 页。
- // 本用例把两种错法都排除:断言 Pages == 1(窗口内 2 条 / 每页 2 条),
- // 并且 Total 明确等于窗口内条数而非历史总数。
- func TestGetBillPagesFollowsWindowedTotal(t *testing.T) {
- _, svc, userID, from, to := billFixture(t)
- res, err := svc.GetBill(context.Background(), GetBillInput{
- UserID: userID, From: from, To: to, Page: 1, PageSize: 2,
- })
- if err != nil {
- t.Fatalf("GetBill 失败: %v", err)
- }
- if res.Total != 2 || res.PageSize != 2 || res.Pages != 1 {
- t.Fatalf("total/pageSize/pages = %d/%d/%d,期望 2/2/1(pages 用窗口内 total + 钳制后 pageSize)",
- res.Total, res.PageSize, res.Pages)
- }
- // 超大 pageSize 被钳制到 200 后,pages 也必须按 200 算(而不是按请求值)
- big, err := svc.GetBill(context.Background(), GetBillInput{
- UserID: userID, From: from, To: to, Page: 1, PageSize: 5000,
- })
- if err != nil {
- t.Fatalf("GetBill(pageSize=5000) 失败: %v", err)
- }
- if big.PageSize != 200 || big.Pages != 1 {
- t.Fatalf("pageSize/pages = %d/%d,期望 200/1(按钳制后的 200 算,不是请求的 5000)", big.PageSize, big.Pages)
- }
- }
|