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