| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- // ExpenseRepo 新增原语单测(AC-04/AC-05 依赖):多列原子增减 / 精确机构+校区定位 /
- // 幂等键流水查询。
- //
- // 测试栈(与 AC-03 既有 expense_repo_test.go 同一套手法,助手直接复用):
- // - **SQL 形状(UPDATE)**:真实 sqlite 库 + SQL 抓取器 —— `UpdateColumns` 在 DryRun 下
- // 仍会走 ConnPool,故 UPDATE 形状必须在**真实可执行的库**上抓(查询类才用 MySQL DryRun);
- // - **SQL 形状(查询)**:MySQL 方言 DryRun(sqlite 不渲染行锁 clause,只有 MySQL 方言能证明
- // `FOR UPDATE` 真进了 SQL);
- // - **行为**:sqlite 文件库(原子表达式结果 / 账户缺失 / 列名白名单 / 幂等查询命中语义)。
- package repository
- import (
- "context"
- "errors"
- "strings"
- "testing"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // TestAddColumnsGlobalThenCampusSQL 多列增减必须**每表一条** UPDATE,且全局在前、校区带机构过滤。
- func TestAddColumnsGlobalThenCampusSQL(t *testing.T) {
- db, capture := newExpenseRepoDBWithCapture(t)
- repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
- ctx := context.Background()
- user := "01HUSER000000000000000901"
- // 先建户(否则原子语句返回 0 行 → ErrAccountNotFound)
- if err := db.Create(&model.UserExpense{UserID: user}).Error; err != nil {
- t.Fatalf("seed 全局失败: %v", err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2}).Error; err != nil {
- t.Fatalf("seed 校区失败: %v", err)
- }
- capture.Reset()
- if err := db.Transaction(func(tx *gorm.DB) error {
- _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "TestSQL", user, 1, 2,
- ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(-1050)},
- ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(-1050)},
- )
- return e
- }); err != nil {
- t.Fatalf("多列增减失败: %v", err)
- }
- stmts := capture.All()
- if len(stmts) != 2 {
- t.Fatalf("应只产生 2 条 UPDATE(全局 + 校区),实得 %d 条:%v", len(stmts), stmts)
- }
- first := compactSQL(stmts[0])
- second := compactSQL(stmts[1])
- if !strings.Contains(first, "UPDATE`t_user_expense`SET") {
- t.Fatalf("第 1 条必须是全局钱包 UPDATE(顺序=死锁防线),实得:%s", first)
- }
- if strings.Contains(first, "SELECT") || strings.Contains(second, "SELECT") {
- t.Fatalf("多列增减不得退化为读-改-写(禁止 SELECT):%v", stmts)
- }
- // 两列必须合并在同一条 UPDATE(拆开会产生"余额已减、总充值未减"的中间态)
- // 断言 `=balance+` / `=total_income+`(跳过方言的标识符引号差异:sqlite 用反引号、MySQL 也是)
- if !strings.Contains(first, "=balance+") || !strings.Contains(first, "=total_income+") {
- t.Fatalf("全局 UPDATE 必须用原子表达式并合并两列:%s", first)
- }
- if !strings.Contains(second, "UPDATE`t_user_expense_school`SET") {
- t.Fatalf("第 2 条必须是校区账户 UPDATE,实得:%s", second)
- }
- if !strings.Contains(second, "institution_id=1") || !strings.Contains(second, "campus_id=2") {
- t.Fatalf("校区 UPDATE 必须按机构+校区精确定位(BUG#2 同类缺陷):%s", second)
- }
- }
- // TestAddCampusColumnsNeverTouchesGlobal 转结专用原语:只动校区表,全局表不出现。
- func TestAddCampusColumnsNeverTouchesGlobal(t *testing.T) {
- db, capture := newExpenseRepoDBWithCapture(t)
- repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
- user := "01HUSER000000000000000902"
- if err := db.Create(&model.UserExpense{UserID: user}).Error; err != nil {
- t.Fatalf("seed 全局失败: %v", err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2}).Error; err != nil {
- t.Fatalf("seed 校区失败: %v", err)
- }
- capture.Reset()
- if err := db.Transaction(func(tx *gorm.DB) error {
- _, e := repo.AddCampusColumns(context.Background(), tx, "TestCampusOnly", user, 1, 2,
- ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(500)})
- return e
- }); err != nil {
- t.Fatalf("校区单表增减失败: %v", err)
- }
- stmts := capture.All()
- if len(stmts) != 1 {
- t.Fatalf("应只产生 1 条 UPDATE,实得 %d 条:%v", len(stmts), stmts)
- }
- if strings.Contains(compactSQL(stmts[0]), "UPDATE`t_user_expense`SET") {
- t.Fatalf("转结原语不得更新全局钱包(dump:余额不动、全局不参与):%s", stmts[0])
- }
- }
- // TestGetCampusForUpdateExactSQL MySQL 方言必须渲染 FOR UPDATE,且**不叠** Sc.CampusID 等值过滤。
- func TestGetCampusForUpdateExactSQL(t *testing.T) {
- mdb, capture := newMySQLDryRunDB(t)
- // 作用域校区=99(故意与入参校区=2 不同):证明跨校区操作不被 Sc.CampusID 卡死
- repo := NewExpenseRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 99})
- capture.Reset()
- _, _ = repo.GetCampusForUpdateExact(context.Background(), mdb, "01HUSER000000000000000903", 1, 2)
- raw := capture.Last()
- sql := compactSQL(raw)
- if !strings.Contains(sql, "FORUPDATE") {
- t.Fatalf("精确校区加锁必须渲染 FOR UPDATE:%q", sql)
- }
- if !strings.Contains(sql, "institution_id=1") || !strings.Contains(sql, "campus_id=2") {
- t.Fatalf("必须按机构 + 入参校区精确定位:%q", sql)
- }
- if strings.Contains(sql, "campus_id=99") {
- t.Fatalf("不得叠加作用域校区等值过滤(跨校区转结会被卡死):%q", sql)
- }
- }
- // TestAddColumnsGlobalThenCampusBehaviour sqlite 行为:两表同步增减 + 账户缺失报错 + 全零 no-op + 列名白名单。
- func TestAddColumnsGlobalThenCampusBehaviour(t *testing.T) {
- db := newExpenseRepoDB(t)
- repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
- ctx := context.Background()
- user := "01HUSER000000000000000904"
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "100.00", TotalIncome: "100.00"}).Error; err != nil {
- t.Fatalf("seed 全局失败: %v", err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2,
- Balance: "100.00", TotalIncome: "100.00"}).Error; err != nil {
- t.Fatalf("seed 校区失败: %v", err)
- }
- if err := db.Transaction(func(tx *gorm.DB) error {
- _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "Behaviour", user, 1, 2,
- ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(-2500)},
- ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(-2500)},
- ColumnDelta{Column: ExpenseColumnSpend, Delta: 0}, // 零增量列必须被跳过
- )
- return e
- }); err != nil {
- t.Fatalf("多列增减失败: %v", err)
- }
- global, err := repo.GetGlobalAccount(ctx, nil, user)
- if err != nil {
- t.Fatalf("读全局账户失败: %v", err)
- }
- if parseAmount(t, global.Balance) != 7500 || parseAmount(t, global.TotalIncome) != 7500 {
- t.Fatalf("全局两列应各 7500 分,实得 %s/%s", global.Balance, global.TotalIncome)
- }
- school, err := repo.GetCampusAccount(ctx, nil, user, 1, 2)
- if err != nil {
- t.Fatalf("读校区账户失败: %v", err)
- }
- if parseAmount(t, school.Balance) != 7500 {
- t.Fatalf("校区 balance 应 7500 分,实得 %s", school.Balance)
- }
- // 校区账户缺失 → ErrAccountNotFound(不静默跳过,否则"全局扣了、校区没扣")
- if err := db.Transaction(func(tx *gorm.DB) error {
- _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "MissingCampus", user, 1, 77,
- ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(100)})
- return e
- }); !errors.Is(err, ErrAccountNotFound) {
- t.Fatalf("校区账户缺失应返回 ErrAccountNotFound,实得: %v", err)
- }
- // 全部增量为 0 → no-op(不报错、不产生 SQL)
- err = db.Transaction(func(tx *gorm.DB) error {
- rows, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "Noop", user, 1, 2,
- ColumnDelta{Column: ExpenseColumnBalance, Delta: 0})
- if e != nil {
- return e
- }
- if rows != 0 {
- t.Fatalf("全零增量应 no-op,实得 rows=%d", rows)
- }
- return nil
- })
- if err != nil {
- t.Fatalf("全零增量不应报错,实得: %v", err)
- }
- // 非法列名 → ErrInternal(列名白名单,防注入)
- if err := db.Transaction(func(tx *gorm.DB) error {
- _, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "BadColumn", user, 1, 2,
- ColumnDelta{Column: "balance); DROP TABLE t_user_expense; --", Delta: money.Amount(1)})
- return e
- }); !errors.Is(err, infraerrors.ErrInternal) {
- t.Fatalf("非法列名应返回 ErrInternal,实得: %v", err)
- }
- }
- // TestFindLogByRequest 幂等键查询:命中 / type 不同不命中 / 空 requestID 不享幂等。
- func TestFindLogByRequest(t *testing.T) {
- db := newExpenseRepoDB(t)
- repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
- ctx := context.Background()
- user := "01HUSER000000000000000905"
- reqID := "REQ-FIND-1"
- row := &model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
- Money: "10.00", RequestID: &reqID}
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 流水失败: %v", err)
- }
- hit, err := repo.FindLogByRequest(ctx, nil, user, reqID, model.ExpenseLogTypeRecharge)
- if err != nil || hit == nil || hit.ID != row.ID {
- t.Fatalf("命中的流水应为 id=%d,实得 (%+v, %v)", row.ID, hit, err)
- }
- // type 不同 → 不命中(type 参与唯一键)
- if hit, err := repo.FindLogByRequest(ctx, nil, user, reqID, model.ExpenseLogTypeRefund); err != nil || hit != nil {
- t.Fatalf("不同类型不应命中,实得 (%+v, %v)", hit, err)
- }
- // 空 requestID → 恒 (nil,nil):NULL 不享幂等(设计 §1.5 缺省)
- if hit, err := repo.FindLogByRequest(ctx, nil, user, "", model.ExpenseLogTypeRecharge); err != nil || hit != nil {
- t.Fatalf("空 requestID 应返回 (nil,nil),实得 (%+v, %v)", hit, err)
- }
- }
|