// BillRepo 单测(AC-10):4 张来源表的取数口径 / `BETWEEN` 含右端 / 分页稳定性 / 参数守卫。 // // 为什么仓储层要单独测"BETWEEN 含右端":这是 dump 里**最容易被"顺手统一"掉**的一处 // (期末余额用 `< to`、充值工单用 `<= to`)。Service 用例只覆盖"有一条恰好落在 to 的流水" // 的单一路径,仓储用例把 `response_time = to` 这一格单独钉住——一旦有人把 `<=` 改成 `<`, // 赠送额会**静默少算**,而账单恒等式(只算 balance 类流水)依然成立,抓不到。 package repository import ( "context" "path/filepath" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // newBillRepoDB 账单 5 表文件库(流水 / 工单 / 报名 / used_log / 全局钱包)。 func newBillRepoDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_bill_repo_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(账单文件库) 失败: %v", err) } t.Cleanup(func() { if sqlDB, e := db.DB(); e == nil { _ = sqlDB.Close() } }) migrateTablesDroppingPlainIndexes(t, db, &model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{}, &model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{}, ) return db } // TestBillRepoLoadRechargeRequestNosBetweenInclusive `response_time BETWEEN` 必须**含右端**, // 且只取 `approved=1 AND type=100`。 func TestBillRepoLoadRechargeRequestNosBetweenInclusive(t *testing.T) { db := newBillRepoDB(t) ctx := context.Background() userID := "01HUSER000000000000000901" from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) at := func(day int, hour int) *time.Time { v := time.Date(2026, 3, day, hour, 0, 0, 0, time.UTC) return &v } rows := []model.UserBalanceRequest{ {BizNo: "IN-FROM", Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(1, 0)}, // 恰在 from(含) {BizNo: "IN-TO", Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(to)}, // 恰在 to(**必须含**) {BizNo: "BEFORE", Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(time.Date(2026, 2, 28, 23, 59, 0, 0, time.UTC))}, {BizNo: "AFTER", Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(to.Add(time.Second))}, {BizNo: "PENDING", Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: model.BalanceRequestApprovedPending, ResponseTime: at(10, 8)}, // 未通过 → 排除 {BizNo: "BADDEBT", Type: model.BalanceRequestTypeBadDebt, UserID: userID, Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(10, 9)}, // 非 100 → 排除 {BizNo: "OTHER-USER", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER000000000000000902", Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(10, 10)}, // 他人 → 排除 {BizNo: "NO-TIME", Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: model.BalanceRequestApprovedPassed, ResponseTime: nil}, // response_time 空 → 排除 } if err := db.Create(&rows).Error; err != nil { t.Fatalf("seed 充值工单失败: %v", err) } repo := NewBillRepo(db) nos, err := repo.LoadRechargeRequestNos(ctx, userID, from, to) if err != nil { t.Fatalf("LoadRechargeRequestNos 失败: %v", err) } got := map[string]bool{} for _, no := range nos { got[no] = true } want := []string{"IN-FROM", "IN-TO"} if len(nos) != len(want) { t.Fatalf("命中工单 = %v,期望 %v(BETWEEN 含两端)", nos, want) } for _, no := range want { if !got[no] { t.Fatalf("缺少工单 %s(命中 %v)", no, nos) } } for _, no := range []string{"BEFORE", "AFTER", "PENDING", "BADDEBT", "OTHER-USER", "NO-TIME"} { if got[no] { t.Fatalf("工单 %s 不应命中(命中 %v)", no, nos) } } } // TestBillRepoLoadLogsAndPageOrdering 全量取数 vs 窗口内分页(两者作用域不同,必须都对)。 // // v1.4 起:`LoadLogs` 仍是**全量**(dump 的 `temp_user_expense_log` 等价物,供汇总用), // 而 `PageLogs` / `CountLogs` **按窗口过滤**(`from <= create_time < to`,右开)。 // 本用例同时钉住这两条,防止"为了明细过滤顺手把全量也裁了"(那会把期初算错)。 func TestBillRepoLoadLogsAndPageOrdering(t *testing.T) { db := newBillRepoDB(t) ctx := context.Background() userID := "01HUSER000000000000000910" // 同一秒两行:分页必须靠 id DESC 兜底(否则翻页会重复/漏行) same := time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC) logs := []model.UserExpenseLog{ {UserID: userID, Type: model.ExpenseLogTypeRecharge, Money: "100.00", CreateTime: same}, {UserID: userID, Type: model.ExpenseLogTypeRecommend, Money: "10.00", CreateTime: same}, {UserID: userID, Type: model.ExpenseLogTypeCourseCampus, Money: "-5.00", CreateTime: time.Date(2026, 3, 6, 9, 0, 0, 0, time.UTC)}, } if err := db.Create(&logs).Error; err != nil { t.Fatalf("seed 流水失败: %v", err) } repo := NewBillRepo(db) all, err := repo.LoadLogs(ctx, userID) if err != nil { t.Fatalf("LoadLogs 失败: %v", err) } if len(all) != 3 { t.Fatalf("全量流水 = %d 条,期望 3(**不带时间/类型过滤**,同 dump 临时表)", len(all)) } from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 3, 6, 9, 0, 0, 0, time.UTC) // 右开:03-06 09:00 那笔**恰好落在 to**,应被排除 total, err := repo.CountLogs(ctx, userID, from, to) if err != nil { t.Fatalf("CountLogs 失败: %v", err) } if total != 2 { t.Fatalf("CountLogs = %d,期望 2(窗口内 2 笔;右端点那笔被 `create_time < to` 排除)", total) } page1, err := repo.PageLogs(ctx, userID, from, to, PageQuery{Page: 1, PageSize: 2}) if err != nil { t.Fatalf("PageLogs(1) 失败: %v", err) } page2, err := repo.PageLogs(ctx, userID, from, to, PageQuery{Page: 2, PageSize: 2}) if err != nil { t.Fatalf("PageLogs(2) 失败: %v", err) } if len(page1) != 2 || len(page2) != 0 { t.Fatalf("分页条数 = %d/%d,期望 2/0(窗口内只有 2 笔;右端点那笔已被排除)", len(page1), len(page2)) } // 同一秒两行按 id DESC:第 1 页首行应是 id 更大的 107,次行是 100 if page1[0].Type != model.ExpenseLogTypeRecommend || page1[1].Type != model.ExpenseLogTypeRecharge { t.Fatalf("同一秒内排序不稳定:page1=%+v", page1) } // 窗口外(恰好等于 to)那笔不得出现在明细 for _, row := range page1 { if !row.CreateTime.Before(to) { t.Fatalf("明细含右端点流水:%+v", row) } } } // TestBillRepoActivityLoaders 报名 / used_log 均**不带时间过滤**(时间窗在 Service 侧做)。 func TestBillRepoActivityLoaders(t *testing.T) { db := newBillRepoDB(t) ctx := context.Background() userID := "01HUSER000000000000000920" no := "REQ-X" if err := db.Create(&model.CoursesActivitySigned{ ActivityID: 91, UserID: userID, ActivityMoney: "40.00", RequestNo: &no, CreateTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }).Error; err != nil { t.Fatalf("seed 报名失败: %v", err) } if err := db.Create(&model.CoursesActivityUsedLog{ ActivityID: 91, UserID: userID, ActivityMoneyUsed: "-15.00", CreateTime: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }).Error; err != nil { t.Fatalf("seed used_log 失败: %v", err) } repo := NewBillRepo(db) signed, err := repo.LoadActivitySigned(ctx, userID) if err != nil { t.Fatalf("LoadActivitySigned 失败: %v", err) } if len(signed) != 1 || !sameRepoAmount(t, signed[0].ActivityMoney, "40.00") { t.Fatalf("报名行 = %+v,期望 1 条 40.00", signed) } if signed[0].RequestNo == nil || *signed[0].RequestNo != no { t.Fatalf("报名行的 request_no 未取出:%+v", signed[0]) } used, err := repo.LoadActivityUsedLogs(ctx, userID) if err != nil { t.Fatalf("LoadActivityUsedLogs 失败: %v", err) } if len(used) != 1 || !sameRepoAmount(t, used[0].ActivityMoneyUsed, "-15.00") { t.Fatalf("used_log = %+v,期望 1 条 -15.00", used) } } // TestBillRepoParamGuards 空 user / from>to 的守卫(禁止"看起来筛了其实没筛"的静默查询)。 func TestBillRepoParamGuards(t *testing.T) { db := newBillRepoDB(t) ctx := context.Background() repo := NewBillRepo(db) from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) _, err := repo.LoadLogs(ctx, "") wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadLogs 空 user") _, err = repo.PageLogs(ctx, "", from, to, PageQuery{}) wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs 空 user") _, err = repo.CountLogs(ctx, "", from, to) wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs 空 user") // from>to:分页与计数都必须拒绝(否则会静默返回空集,掩盖调用方的时间窗写反) _, err = repo.PageLogs(ctx, "01HUSER000000000000000930", to, from, PageQuery{}) wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs from 晚于 to") _, err = repo.CountLogs(ctx, "01HUSER000000000000000930", to, from) wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs from 晚于 to") _, err = repo.LoadActivitySigned(ctx, "") wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivitySigned 空 user") _, err = repo.LoadActivityUsedLogs(ctx, "") wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivityUsedLogs 空 user") _, err = repo.LoadRechargeRequestNos(ctx, "", time.Now(), time.Now()) wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadRechargeRequestNos 空 user") _, err = repo.LoadRechargeRequestNos(ctx, "01HUSER000000000000000930", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) wantRepoErr(t, err, infraerrors.CodeBadParam, "from 晚于 to") } // timePtr 时间指针构造(time.Time 不可寻址,取址需经变量)。 func timePtr(v time.Time) *time.Time { return &v } // sameRepoAmount 金额语义比较(sqlite 的 NUMERIC 亲和性会把 "40.00" 读回 "40", // MySQL 读回 "40.00"——两者定点值相同,故按 money.Amount 比较,不做字符串比)。 func sameRepoAmount(t *testing.T, got, want string) bool { t.Helper() g, gerr := money.Parse(got) w, werr := money.Parse(want) if gerr != nil || werr != nil { t.Fatalf("金额解析失败: got=%q(%v) want=%q(%v)", got, gerr, want, werr) } return g == w }