| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- // BalanceRequestRepo 单测(AC-03):biz_no 幂等 / 审批 CAS / FOR UPDATE / 分页 / scoping。
- //
- // 测试栈:glebarez/sqlite 文件库(**只迁 t_user_balance_request 单表**——sqlite 索引名是库级全局,
- // `uk_biz_no` 与其它单号表重名,混迁会撞名,原因见 expense_repo_test.go 注释)。
- package repository
- import (
- "context"
- "errors"
- "path/filepath"
- "strings"
- "testing"
- "time"
- gormsqlite "github.com/glebarez/sqlite"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // newBalanceRequestRepoDB 工单单表文件库。
- func newBalanceRequestRepoDB(t *testing.T) *gorm.DB {
- t.Helper()
- dsn := filepath.Join(t.TempDir(), "academic_balance_request_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()
- }
- })
- if err := db.AutoMigrate(&model.UserBalanceRequest{}); err != nil {
- t.Fatalf("AutoMigrate(t_user_balance_request) 失败: %v", err)
- }
- return db
- }
- // TestBalanceRequestInsertAndDuplicate biz_no 幂等:撞 uk_biz_no 识别为重复单号(3003),不抛 5002。
- func TestBalanceRequestInsertAndDuplicate(t *testing.T) {
- db := newBalanceRequestRepoDB(t)
- repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- runner := NewTxRunner(db)
- ctx := context.Background()
- row := &model.UserBalanceRequest{
- BizNo: "BIZ-0001", Type: model.BalanceRequestTypeRecharge,
- UserID: "01HUSER000000000000000201", Money: "100.00",
- RequestUserID: "01HUSER000000000000000201",
- }
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, row) }); err != nil {
- t.Fatalf("创建工单失败: %v", err)
- }
- if row.InstitutionID != 1 || row.CampusID != 2 {
- t.Fatalf("作用域列未补齐:inst=%d campus=%d", row.InstitutionID, row.CampusID)
- }
- if row.Approved != model.BalanceRequestApprovedPending {
- t.Fatalf("新建工单 approved = %d,期望 0(未处理)", row.Approved)
- }
- dup := &model.UserBalanceRequest{
- BizNo: "BIZ-0001", Type: model.BalanceRequestTypeRecharge,
- UserID: "01HUSER000000000000000201", Money: "100.00",
- RequestUserID: "01HUSER000000000000000201",
- }
- err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, dup) })
- if !errors.Is(err, ErrDuplicateBizNo) {
- t.Fatalf("重复单号错误 = %v,期望 ErrDuplicateBizNo", err)
- }
- var appErr *infraerrors.AppError
- if !errors.As(err, &appErr) || appErr.Code != infraerrors.CodeUnique {
- t.Fatalf("重复单号错误码不符:%v", err)
- }
- }
- // TestBalanceRequestUpdateApprovedCAS 审批 CAS:0→终态只成功一次,重复调用 affected=0。
- func TestBalanceRequestUpdateApprovedCAS(t *testing.T) {
- db := newBalanceRequestRepoDB(t)
- repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- runner := NewTxRunner(db)
- ctx := context.Background()
- row := &model.UserBalanceRequest{
- BizNo: "BIZ-0002", Type: model.BalanceRequestTypeRefund,
- UserID: "01HUSER000000000000000202", Money: "50.00",
- RequestUserID: "01HUSER000000000000000202",
- }
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.Insert(ctx, tx, row) }); err != nil {
- t.Fatalf("创建工单失败: %v", err)
- }
- approver := "01HUSER000000000000000299"
- now := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- affected, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPassed, &approver, &now)
- if e != nil {
- return e
- }
- if affected != 1 {
- t.Fatalf("首次审批 affected = %d,期望 1", affected)
- }
- return nil
- }); err != nil {
- t.Fatalf("首次审批失败: %v", err)
- }
- // 重复审批(第二次点"通过"):affected=0(幂等,不得二次入账)
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- affected, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPassed, &approver, &now)
- if e != nil {
- return e
- }
- if affected != 0 {
- t.Fatalf("重复审批 affected = %d,期望 0(CAS 防重复入账)", affected)
- }
- return nil
- }); err != nil {
- t.Fatalf("重复审批失败: %v", err)
- }
- var stored model.UserBalanceRequest
- if err := db.Where("id = ?", row.ID).Take(&stored).Error; err != nil {
- t.Fatalf("回读工单失败: %v", err)
- }
- if stored.Approved != model.BalanceRequestApprovedPassed {
- t.Fatalf("approved = %d,期望 1", stored.Approved)
- }
- if stored.ResponseUserID == nil || *stored.ResponseUserID != approver {
- t.Fatalf("审批人未落库:%v", stored.ResponseUserID)
- }
- // 非法目标状态 → ErrBadParam
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := repo.UpdateApproved(ctx, tx, row.ID, model.BalanceRequestApprovedPending, nil, nil)
- if !errors.Is(e, infraerrors.ErrBadParam) {
- t.Fatalf("非法目标状态错误 = %v,期望 ErrBadParam", e)
- }
- return nil
- }); err != nil {
- t.Fatalf("非法目标状态用例失败: %v", err)
- }
- }
- // TestBalanceRequestForUpdateSQL MySQL 方言:FOR UPDATE + 机构作用域同时出现在审批加锁读里。
- func TestBalanceRequestForUpdateSQL(t *testing.T) {
- mdb, capture := newMySQLDryRunDB(t)
- repo := NewBalanceRequestRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- ctx := context.Background()
- capture.Reset()
- _, _ = repo.GetForUpdate(ctx, mdb, 7) // DryRun 必然 ErrRecordNotFound,只断言 SQL 形状
- sql := capture.Last()
- if !strings.Contains(sql, "FOR UPDATE") {
- t.Fatalf("缺少 FOR UPDATE:%q", sql)
- }
- if !strings.Contains(sql, "institution_id") {
- t.Fatalf("缺少机构作用域:%q", sql)
- }
- }
- // TestBalanceRequestScoping 作用域:跨机构读不到(ErrNotFound)、超管可见。
- func TestBalanceRequestScoping(t *testing.T) {
- db := newBalanceRequestRepoDB(t)
- runner := NewTxRunner(db)
- ctx := context.Background()
- owner := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- row := &model.UserBalanceRequest{
- BizNo: "BIZ-0003", Type: model.BalanceRequestTypeBadDebt,
- UserID: "01HUSER000000000000000203", Money: "10.00",
- RequestUserID: "01HUSER000000000000000203",
- }
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return owner.Insert(ctx, tx, row) }); err != nil {
- t.Fatalf("创建工单失败: %v", err)
- }
- other := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 5})
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := other.GetForUpdate(ctx, tx, row.ID)
- if !errors.Is(e, infraerrors.ErrNotFound) {
- t.Fatalf("跨机构读取错误 = %v,期望 ErrNotFound", e)
- }
- return nil
- }); err != nil {
- t.Fatalf("跨机构用例失败: %v", err)
- }
- if list, err := other.ListByUser(ctx, row.UserID, PageQuery{}); err != nil || len(list) != 0 {
- t.Fatalf("跨机构列表应查不到:len=%d err=%v", len(list), err)
- }
- admin := NewBalanceRequestRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := admin.GetForUpdate(ctx, tx, row.ID)
- return e
- }); err != nil {
- t.Fatalf("超管读取失败: %v", err)
- }
- if list, err := admin.ListByUser(ctx, row.UserID, PageQuery{}); err != nil || len(list) != 1 {
- t.Fatalf("超管列表应命中 1 条:len=%d err=%v", len(list), err)
- }
- }
- // TestBalanceRequestListByUserPaging 分页:空结果非 nil、倒序、pageSize 钳制。
- func TestBalanceRequestListByUserPaging(t *testing.T) {
- db := newBalanceRequestRepoDB(t)
- repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- ctx := context.Background()
- const user = "01HUSER000000000000000204"
- empty, err := repo.ListByUser(ctx, user, PageQuery{})
- if err != nil {
- t.Fatalf("空结果查询失败: %v", err)
- }
- if empty == nil || len(empty) != 0 {
- t.Fatalf("空结果必须是非 nil 空切片:%v", empty)
- }
- for i := 0; i < 3; i++ {
- row := &model.UserBalanceRequest{
- BizNo: "BIZ-PAGE-" + string(rune('A'+i)), Type: model.BalanceRequestTypeRecharge,
- UserID: user, Money: "1.00", RequestUserID: user,
- InstitutionID: 1, CampusID: 2,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 工单失败: %v", err)
- }
- }
- page, err := repo.ListByUser(ctx, user, PageQuery{Page: 1, PageSize: 2})
- if err != nil {
- t.Fatalf("分页查询失败: %v", err)
- }
- if len(page) != 2 {
- t.Fatalf("第 1 页长度 = %d,期望 2", len(page))
- }
- if page[0].ID < page[1].ID {
- t.Fatalf("未按 id 倒序:%d / %d", page[0].ID, page[1].ID)
- }
- if got := (PageQuery{PageSize: 5000}).Limit(); got != MaxPageSize {
- t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
- }
- }
|