| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- // 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
- }
|