|
@@ -0,0 +1,712 @@
|
|
|
|
|
+// ExpenseRepo 单测(AC-03):UPSERT 双表 / 原子表达式 / FOR UPDATE / 幂等键 / 分页 / scoping。
|
|
|
|
|
+//
|
|
|
|
|
+// 测试栈(与 AC-06 活动仓储同一套手法):
|
|
|
|
|
+// - **行为用例**:glebarez/sqlite 临时文件库(纯 Go,CGO_ENABLED=0 可跑);
|
|
|
|
|
+// - **SQL 形状用例**:给 GORM 挂"抓 SQL"的 logger(`sqlCapture`)——
|
|
|
|
|
+// DryRun/真实库都会经 `Logger.Trace` 暴露 Explain 后的完整 SQL,因此断言的是**仓储真实生成的语句**
|
|
|
|
|
+// (不是测试里手抄一份),能真正校验「原子表达式 / 全局→校区顺序 / 不含 SELECT / FOR UPDATE」;
|
|
|
|
|
+// - **MySQL 方言用例**:`mysql.New(...SkipInitializeWithVersion)` + `DisableAutomaticPing` 的 DryRun 句柄,
|
|
|
|
|
+// 不连库即可拿到 MySQL 方言 SQL(sqlite 不渲染行锁 clause,只有 MySQL 方言能证明 FOR UPDATE 真进了 SQL)。
|
|
|
|
|
+package repository
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "errors"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "sync"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ gormsqlite "github.com/glebarez/sqlite"
|
|
|
|
|
+ "gorm.io/driver/mysql"
|
|
|
|
|
+ "gorm.io/gorm"
|
|
|
|
|
+ "gorm.io/gorm/logger"
|
|
|
|
|
+
|
|
|
|
|
+ "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"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// sqlCapture 抓取 GORM 执行的 SQL(线程安全;`stmts` 为最近一次 Reset 之后的全部语句)。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么这样做:仓储方法内部不暴露 *gorm.DB,测试又要断言"真实生成的 SQL 形状",
|
|
|
|
|
+// 唯一无侵入的通道就是 GORM 的 logger。
|
|
|
|
|
+type sqlCapture struct {
|
|
|
|
|
+ mu sync.Mutex
|
|
|
|
|
+ stmts []string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// LogMode 实现 logger.Interface(测试恒为 Info 级别,不做分级)。
|
|
|
|
|
+func (c *sqlCapture) LogMode(logger.LogLevel) logger.Interface { return c }
|
|
|
|
|
+
|
|
|
|
|
+// Info 实现 logger.Interface(测试不关心日志正文)。
|
|
|
|
|
+func (c *sqlCapture) Info(context.Context, string, ...interface{}) {}
|
|
|
|
|
+
|
|
|
|
|
+// Warn 实现 logger.Interface。
|
|
|
|
|
+func (c *sqlCapture) Warn(context.Context, string, ...interface{}) {}
|
|
|
|
|
+
|
|
|
|
|
+// Error 实现 logger.Interface。
|
|
|
|
|
+func (c *sqlCapture) Error(context.Context, string, ...interface{}) {}
|
|
|
|
|
+
|
|
|
|
|
+// Trace 实现 logger.Interface:记录 Explain 后的 SQL 文本。
|
|
|
|
|
+func (c *sqlCapture) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
|
|
|
|
|
+ sql, _ := fc()
|
|
|
|
|
+ if strings.TrimSpace(sql) == "" {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ c.mu.Lock()
|
|
|
|
|
+ defer c.mu.Unlock()
|
|
|
|
|
+ c.stmts = append(c.stmts, sql)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Reset 清空已捕获语句(每个断言段开始前调用)。
|
|
|
|
|
+func (c *sqlCapture) Reset() {
|
|
|
|
|
+ c.mu.Lock()
|
|
|
|
|
+ defer c.mu.Unlock()
|
|
|
|
|
+ c.stmts = nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// All 返回已捕获语句的快照。
|
|
|
|
|
+func (c *sqlCapture) All() []string {
|
|
|
|
|
+ c.mu.Lock()
|
|
|
|
|
+ defer c.mu.Unlock()
|
|
|
|
|
+ out := make([]string, len(c.stmts))
|
|
|
|
|
+ copy(out, c.stmts)
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Last 返回最后一条语句(无语句时返回空串)。
|
|
|
|
|
+func (c *sqlCapture) Last() string {
|
|
|
|
|
+ c.mu.Lock()
|
|
|
|
|
+ defer c.mu.Unlock()
|
|
|
|
|
+ if len(c.stmts) == 0 {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ return c.stmts[len(c.stmts)-1]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// compactSQL 去掉空白,便于跨方言断言 SQL 片段(sqlite/MySQL 的 Explain 空格风格不同)。
|
|
|
|
|
+func compactSQL(sql string) string {
|
|
|
|
|
+ return strings.Join(strings.Fields(sql), "")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newMySQLDryRunDB MySQL 方言 DryRun 句柄 + SQL 抓取器(**不建立连接**,仅渲染方言级 SQL)。
|
|
|
|
|
+func newMySQLDryRunDB(t *testing.T) (*gorm.DB, *sqlCapture) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ capture := &sqlCapture{}
|
|
|
|
|
+ db, err := gorm.Open(mysql.New(mysql.Config{
|
|
|
|
|
+ DSN: "academic:academic@tcp(127.0.0.1:3306)/academic?charset=utf8mb4&parseTime=True",
|
|
|
|
|
+ SkipInitializeWithVersion: true,
|
|
|
|
|
+ }), &gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: capture})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("gorm.Open(MySQL DryRun) 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return db, capture
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newExpenseRepoDB 余额账户域真实库(文件库;账户/校区账户/流水 **3 表** AutoMigrate)。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 为什么只迁 3 表(探索结论,登记为跨表索引重名技术债):
|
|
|
|
|
+// SQLite 的索引名是**数据库级全局唯一**(MySQL 是表级),而 AC-01 的 27 表里
|
|
|
|
|
+// `uk_biz_no`(7 张单号表)与 `idx_inst_campus`(6 张作用域表)跨表重名 ——
|
|
|
|
|
+// 因此**不能在同一个 sqlite 库**里 AutoMigrate 这些表(会报 `index ... already exists`)。
|
|
|
|
|
+// 本测试的全部用例只需余额 3 表(其余表各自在对应测试文件里单独建库),故按"无冲突子集"建库。
|
|
|
|
|
+// 真库(MySQL)侧不受影响,由 `TestDevDBAutoMigrate` 全量 27 表覆盖。
|
|
|
|
|
+func newExpenseRepoDB(t *testing.T) *gorm.DB {
|
|
|
|
|
+ db, _ := newExpenseRepoDBWithCapture(t)
|
|
|
|
|
+ return db
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newExpenseRepoDBWithCapture 同上,额外返回 SQL 抓取器(AutoMigrate 期间产生的语句会先被 Reset)。
|
|
|
|
|
+func newExpenseRepoDBWithCapture(t *testing.T) (*gorm.DB, *sqlCapture) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ dsn := filepath.Join(t.TempDir(), "academic_expense_repo_test.db")
|
|
|
|
|
+ capture := &sqlCapture{}
|
|
|
|
|
+ db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: capture})
|
|
|
|
|
+ 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.UserExpense{},
|
|
|
|
|
+ &model.UserExpenseSchool{},
|
|
|
|
|
+ &model.UserExpenseLog{},
|
|
|
|
|
+ ); err != nil {
|
|
|
|
|
+ t.Fatalf("AutoMigrate(余额 3 表) 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ return db, capture
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// seedExpenseAccounts 建全局账户 + 两个校区账户(inst=1:campus 2 / campus 3)。
|
|
|
|
|
+func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: userID}); e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if e := repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 2}); e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 3})
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("建账户失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseUpsertNoClobber UPSERT 双表:重复建户不覆盖已有余额/统计列(只建户,不改钱)。
|
|
|
|
|
+func TestExpenseUpsertNoClobber(t *testing.T) {
|
|
|
|
|
+ db := newExpenseRepoDB(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000101"
|
|
|
|
|
+ seedExpenseAccounts(t, db, user)
|
|
|
|
|
+
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "10.50"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("AddBalance 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 再次 UPSERT(模拟并发重复建户 / 重放):余额必须保持不变
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: user, Balance: "999.00"}); e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2, Balance: "999.00"})
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("重复 UPSERT 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 0, "10.50")
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 2, "10.50")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseUpsertSQLShape UPSERT 语句形状:带冲突目标 + DO NOTHING(不覆盖余额列)。
|
|
|
|
|
+func TestExpenseUpsertSQLShape(t *testing.T) {
|
|
|
|
|
+ db, capture := newExpenseRepoDBWithCapture(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000111"
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ if e := repo.UpsertGlobal(ctx, tx, &model.UserExpense{UserID: user}); e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ return repo.UpsertCampus(ctx, tx, &model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("UPSERT 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var globalSQL, campusSQL string
|
|
|
|
|
+ for _, stmt := range capture.All() {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case strings.Contains(stmt, "t_user_expense_school"):
|
|
|
|
|
+ campusSQL = compactSQL(stmt)
|
|
|
|
|
+ case strings.Contains(stmt, "t_user_expense`"):
|
|
|
|
|
+ globalSQL = compactSQL(stmt)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(globalSQL, "ONCONFLICT") || !strings.Contains(globalSQL, "DONOTHING") {
|
|
|
|
|
+ t.Fatalf("全局 UPSERT 缺少 ON CONFLICT DO NOTHING:%s", globalSQL)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(globalSQL, "user_id") {
|
|
|
|
|
+ t.Fatalf("全局 UPSERT 冲突目标缺少 user_id:%s", globalSQL)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(campusSQL, "ONCONFLICT") ||
|
|
|
|
|
+ !strings.Contains(campusSQL, "user_id") ||
|
|
|
|
|
+ !strings.Contains(campusSQL, "institution_id") ||
|
|
|
|
|
+ !strings.Contains(campusSQL, "campus_id") {
|
|
|
|
|
+ t.Fatalf("校区 UPSERT 冲突键不完整(应含 user_id+institution_id+campus_id):%s", campusSQL)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 关键红线:冲突分支不得 SET balance(否则并发充值丢更新)
|
|
|
|
|
+ if strings.Contains(campusSQL, "DOUPDATE") || strings.Contains(globalSQL, "DOUPDATE") {
|
|
|
|
|
+ t.Fatalf("UPSERT 不得覆盖已有余额列:global=%s campus=%s", globalSQL, campusSQL)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseAddBalanceSQLShape 原子表达式形状 + 双写顺序(全局 → 校区)+ 无读-改-写。
|
|
|
|
|
+func TestExpenseAddBalanceSQLShape(t *testing.T) {
|
|
|
|
|
+ db, capture := newExpenseRepoDBWithCapture(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000112"
|
|
|
|
|
+ seedExpenseAccounts(t, db, user)
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "10.50"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("AddBalance 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ stmts := capture.All()
|
|
|
|
|
+ var updates []string
|
|
|
|
|
+ for _, s := range stmts {
|
|
|
|
|
+ if strings.Contains(strings.ToUpper(s), "SELECT") {
|
|
|
|
|
+ t.Fatalf("资金增减路径出现 SELECT(读-改-写反例):%s", s)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(s)), "UPDATE") {
|
|
|
|
|
+ updates = append(updates, compactSQL(s))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(updates) != 2 {
|
|
|
|
|
+ t.Fatalf("期望 2 条 UPDATE(全局+校区),实得 %d:%v", len(updates), stmts)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(updates[0], "`balance`=balance+") || !strings.Contains(updates[0], "t_user_expense`") {
|
|
|
|
|
+ t.Fatalf("第 1 条 UPDATE 应作用于全局账户且为原子表达式:%s", updates[0])
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(updates[1], "`balance`=balance+") || !strings.Contains(updates[1], "t_user_expense_school") {
|
|
|
|
|
+ t.Fatalf("第 2 条 UPDATE 应作用于校区账户:%s", updates[1])
|
|
|
|
|
+ }
|
|
|
|
|
+ // 固定顺序「全局 → 校区」是防死锁红线
|
|
|
|
|
+ if strings.Contains(updates[0], "school") {
|
|
|
|
|
+ t.Fatalf("双写顺序错误:必须先全局后校区,实得第 1 条 = %s", updates[0])
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseAddColumnFamily 四个统计列各自原子累加,且都不动 balance(红线)。
|
|
|
|
|
+func TestExpenseAddColumnFamily(t *testing.T) {
|
|
|
|
|
+ db, capture := newExpenseRepoDBWithCapture(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000113"
|
|
|
|
|
+ seedExpenseAccounts(t, db, user)
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ column string
|
|
|
|
|
+ call func(tx *gorm.DB) error
|
|
|
|
|
+ }{
|
|
|
|
|
+ {"AddSpend", expenseColSpend, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddSpend(ctx, tx, user, 0, mustAmount(t, "8.00"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"AddSpecialExpense", expenseColSpecialExpenses, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddSpecialExpense(ctx, tx, user, 0, mustAmount(t, "2.50"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"AddTotalIncome", expenseColTotalIncome, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddTotalIncome(ctx, tx, user, 0, mustAmount(t, "100.00"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"AddTotalExpenditure", expenseColTotalExpenditure, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddTotalExpenditure(ctx, tx, user, 0, mustAmount(t, "30.00"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, tc := range cases {
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ if err := runner.WithinTx(ctx, tc.call); err != nil {
|
|
|
|
|
+ t.Fatalf("[%s] 失败: %v", tc.name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ sql := compactSQL(capture.Last())
|
|
|
|
|
+ if !strings.Contains(sql, "`"+tc.column+"`="+tc.column+"+") {
|
|
|
|
|
+ t.Fatalf("[%s] 缺少原子表达式 `%s = %s + ?`:%s", tc.name, tc.column, tc.column, sql)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.Contains(sql, "`balance`") {
|
|
|
|
|
+ t.Fatalf("[%s] 统计列累加不得动 balance:%s", tc.name, sql)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var global model.UserExpense
|
|
|
|
|
+ if err := db.Where("user_id = ?", user).Take(&global).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("读取全局账户失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 注:sqlite 的 decimal 走 NUMERIC 亲和性,回读可能是 "8"(省略尾零),故一律按分值比较
|
|
|
|
|
+ wants := map[string]string{
|
|
|
|
|
+ expenseColSpend: "8.00",
|
|
|
|
|
+ expenseColSpecialExpenses: "2.50",
|
|
|
|
|
+ expenseColTotalIncome: "100.00",
|
|
|
|
|
+ expenseColTotalExpenditure: "30.00",
|
|
|
|
|
+ }
|
|
|
|
|
+ got := map[string]string{
|
|
|
|
|
+ expenseColSpend: global.Spend,
|
|
|
|
|
+ expenseColSpecialExpenses: global.SpecialExpenses,
|
|
|
|
|
+ expenseColTotalIncome: global.TotalIncome,
|
|
|
|
|
+ expenseColTotalExpenditure: global.TotalExpenditure,
|
|
|
|
|
+ }
|
|
|
|
|
+ for column, want := range wants {
|
|
|
|
|
+ if parseAmount(t, got[column]) != parseAmount(t, want) {
|
|
|
|
|
+ t.Fatalf("列 %s = %s,期望 %s", column, got[column], want)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if parseAmount(t, global.Balance) != 0 {
|
|
|
|
|
+ t.Fatalf("balance = %s,统计列累加不得动余额(红线)", global.Balance)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseAddBalanceBehavior 双写 / 负数 / 零增量 / 未建户报错。
|
|
|
|
|
+func TestExpenseAddBalanceBehavior(t *testing.T) {
|
|
|
|
|
+ db := newExpenseRepoDB(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000102"
|
|
|
|
|
+ seedExpenseAccounts(t, db, user)
|
|
|
|
|
+
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ affected, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "100.00"))
|
|
|
|
|
+ if e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if affected != 1 {
|
|
|
|
|
+ t.Fatalf("AddBalance 全局受影响行数 = %d,期望 1", affected)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("AddBalance 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 0, "100.00")
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 2, "100.00")
|
|
|
|
|
+
|
|
|
|
|
+ // 消费 -30.25(全局 + 校区同步)
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddBalance(ctx, tx, user, 2, mustAmount(t, "-30.25"))
|
|
|
|
|
+ return e
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("AddBalance(负数) 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 0, "69.75")
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 2, "69.75")
|
|
|
|
|
+
|
|
|
|
|
+ // 零增量:空操作(0 行,不报错)
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ affected, e := repo.AddBalance(ctx, tx, user, 2, money.Amount(0))
|
|
|
|
|
+ if e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if affected != 0 {
|
|
|
|
|
+ t.Fatalf("零增量受影响行数 = %d,期望 0", affected)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("零增量失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 未建户 → ErrAccountNotFound(禁止静默跳过)
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.AddBalance(ctx, tx, "01HUSER000000000000000999", 2, mustAmount(t, "1.00"))
|
|
|
|
|
+ if !errors.Is(e, ErrAccountNotFound) {
|
|
|
|
|
+ t.Fatalf("未建户错误 = %v,期望 ErrAccountNotFound", e)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("未建户用例失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseForUpdateSQL MySQL 方言下必须渲染 `FOR UPDATE`(行锁防超退),校区查询还须叠机构作用域。
|
|
|
|
|
+func TestExpenseForUpdateSQL(t *testing.T) {
|
|
|
|
|
+ mdb, capture := newMySQLDryRunDB(t)
|
|
|
|
|
+ repo := NewExpenseRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ // DryRun 下 Take 必然返回 ErrRecordNotFound;本条用例只断言 SQL 形状,忽略返回值
|
|
|
|
|
+ _, _ = repo.GetForUpdate(ctx, mdb, "01HUSER000000000000000114")
|
|
|
|
|
+ if sql := capture.Last(); !strings.Contains(sql, "FOR UPDATE") {
|
|
|
|
|
+ t.Fatalf("GetForUpdate 全局账户 SQL 缺少 FOR UPDATE:%q", sql)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ _, _ = repo.GetCampusForUpdate(ctx, mdb, "01HUSER000000000000000114", 2)
|
|
|
|
|
+ sql := capture.Last()
|
|
|
|
|
+ if !strings.Contains(sql, "FOR UPDATE") {
|
|
|
|
|
+ t.Fatalf("GetCampusForUpdate SQL 缺少 FOR UPDATE:%q", sql)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(sql, "institution_id") {
|
|
|
|
|
+ t.Fatalf("校区账户查询未叠加机构作用域:%q", sql)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseForUpdateBehavior 行为:命中返回行;跨机构/不存在 → ErrNotFound(不泄露存在性)。
|
|
|
|
|
+func TestExpenseForUpdateBehavior(t *testing.T) {
|
|
|
|
|
+ db := newExpenseRepoDB(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000105"
|
|
|
|
|
+ seedExpenseAccounts(t, db, user)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ row, e := repo.GetForUpdate(ctx, tx, user)
|
|
|
|
|
+ if e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if row.UserID != user {
|
|
|
|
|
+ t.Fatalf("GetForUpdate 返回 user_id = %s", row.UserID)
|
|
|
|
|
+ }
|
|
|
|
|
+ campusRow, e := repo.GetCampusForUpdate(ctx, tx, user, 2)
|
|
|
|
|
+ if e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if campusRow.CampusID != 2 {
|
|
|
|
|
+ t.Fatalf("GetCampusForUpdate 返回 campus_id = %d", campusRow.CampusID)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("GetForUpdate 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 跨机构(scope=2)读 inst=1 的校区账户 → ErrNotFound
|
|
|
|
|
+ other := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 2})
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := other.GetCampusForUpdate(ctx, tx, user, 2)
|
|
|
|
|
+ if !errors.Is(e, infraerrors.ErrNotFound) {
|
|
|
|
|
+ t.Fatalf("跨机构读取错误 = %v,期望 ErrNotFound", e)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("跨机构用例失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 不存在 → ErrNotFound
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := repo.GetForUpdate(ctx, tx, "01HUSER000000000000000998")
|
|
|
|
|
+ if !errors.Is(e, infraerrors.ErrNotFound) {
|
|
|
|
|
+ t.Fatalf("不存在账户错误 = %v,期望 ErrNotFound", e)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("不存在用例失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 超管跨机构可见(作用域放行)
|
|
|
|
|
+ admin := NewExpenseRepo(db, &appscope.Scope{SuperAdmin: true, InstitutionID: 9})
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := admin.GetCampusForUpdate(ctx, tx, user, 2)
|
|
|
|
|
+ return e
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("超管读取失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseCampusScopingWrite 普通角色不得给别的机构账户记账(Scoped 命中 0 行 → 事务回滚)。
|
|
|
|
|
+func TestExpenseCampusScopingWrite(t *testing.T) {
|
|
|
|
|
+ db := newExpenseRepoDB(t)
|
|
|
|
|
+ const user = "01HUSER000000000000000106"
|
|
|
|
|
+ seedExpenseAccounts(t, db, user)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+
|
|
|
|
|
+ other := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 2})
|
|
|
|
|
+ err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ _, e := other.AddBalance(ctx, tx, user, 2, mustAmount(t, "1.00"))
|
|
|
|
|
+ if !errors.Is(e, ErrAccountNotFound) {
|
|
|
|
|
+ t.Fatalf("跨机构记账错误 = %v,期望 ErrAccountNotFound(scoping 拦截)", e)
|
|
|
|
|
+ }
|
|
|
|
|
+ return e
|
|
|
|
|
+ })
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ t.Fatal("跨机构记账本应失败(事务应回滚)")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 事务回滚后余额未变(全局更新被一并撤销)
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 0, "0.00")
|
|
|
|
|
+ assertBalance(t, repo, ctx, user, 2, "0.00")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseInsertLogIdempotent 幂等键 uk_request_type:同一 request_id+type 第二次写入识别为重复。
|
|
|
|
|
+func TestExpenseInsertLogIdempotent(t *testing.T) {
|
|
|
|
|
+ db := newExpenseRepoDB(t)
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ reqID := "REQ-0001"
|
|
|
|
|
+ const user = "01HUSER000000000000000107"
|
|
|
|
|
+
|
|
|
|
|
+ write := func(log *model.UserExpenseLog) error {
|
|
|
|
|
+ return runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.InsertLog(ctx, tx, log) })
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
|
|
|
|
|
+ RequestID: &reqID, Money: "100.00"}); err != nil {
|
|
|
|
|
+ t.Fatalf("首次写流水失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
|
|
|
|
|
+ RequestID: &reqID, Money: "100.00"})
|
|
|
|
|
+ if !errors.Is(err, ErrDuplicateLog) {
|
|
|
|
|
+ t.Fatalf("重复请求错误 = %v,期望 ErrDuplicateLog(幂等命中,非 5002)", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var appErr *infraerrors.AppError
|
|
|
|
|
+ if !errors.As(err, &appErr) || appErr.Code != infraerrors.CodeUnique {
|
|
|
|
|
+ t.Fatalf("重复请求错误码不符:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 不同 type 不算重复(type 参与幂等键)
|
|
|
|
|
+ if err := write(&model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRefund,
|
|
|
|
|
+ RequestID: &reqID, Money: "-100.00"}); err != nil {
|
|
|
|
|
+ t.Fatalf("不同 type 应视为新请求:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseListLogsPagingAndFilters 分页(空列表非 nil、钳制 200、倒序)+ campusIDs 明确拒绝。
|
|
|
|
|
+func TestExpenseListLogsPagingAndFilters(t *testing.T) {
|
|
|
|
|
+ db := newExpenseRepoDB(t)
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ empty, err := repo.ListLogs(ctx, "01HUSER000000000000000108", nil, time.Time{}, time.Time{}, PageQuery{})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("空结果查询失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if empty == nil {
|
|
|
|
|
+ t.Fatal("空结果必须是非 nil 切片")
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(empty) != 0 {
|
|
|
|
|
+ t.Fatalf("空结果长度 = %d", len(empty))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const user = "01HUSER000000000000000109"
|
|
|
|
|
+ base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
+ for i := 0; i < 5; i++ {
|
|
|
|
|
+ reqID := "REQ-PAGE-" + string(rune('A'+i))
|
|
|
|
|
+ row := &model.UserExpenseLog{
|
|
|
|
|
+ UserID: user, Type: model.ExpenseLogTypeRecharge, RequestID: &reqID,
|
|
|
|
|
+ Money: "1.00", CreateTime: base.Add(time.Duration(i) * time.Minute),
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Create(row).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 流水失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ page1, err := repo.ListLogs(ctx, user, nil, time.Time{}, time.Time{}, PageQuery{Page: 1, PageSize: 2})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("分页查询失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(page1) != 2 {
|
|
|
|
|
+ t.Fatalf("第 1 页长度 = %d,期望 2", len(page1))
|
|
|
|
|
+ }
|
|
|
|
|
+ if !page1[0].CreateTime.After(page1[1].CreateTime) {
|
|
|
|
|
+ t.Fatalf("未按 create_time 倒序:%v / %v", page1[0].CreateTime, page1[1].CreateTime)
|
|
|
|
|
+ }
|
|
|
|
|
+ page3, err := repo.ListLogs(ctx, user, nil, time.Time{}, time.Time{}, PageQuery{Page: 3, PageSize: 2})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("第 3 页查询失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(page3) != 1 {
|
|
|
|
|
+ t.Fatalf("第 3 页长度 = %d,期望 1", len(page3))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // pageSize 钳制:999 → 200
|
|
|
|
|
+ if got := (PageQuery{PageSize: 999}).Limit(); got != MaxPageSize {
|
|
|
|
|
+ t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := (PageQuery{}).Limit(); got != DefaultPageSize {
|
|
|
|
|
+ t.Fatalf("默认 pageSize = %d,期望 %d", got, DefaultPageSize)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := (PageQuery{Page: 3, PageSize: 10}).Offset(); got != 20 {
|
|
|
|
|
+ t.Fatalf("offset = %d,期望 20", got)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 时间窗过滤(闭区间)
|
|
|
|
|
+ inWindow, err := repo.ListLogs(ctx, user, nil, base.Add(time.Minute), base.Add(2*time.Minute), PageQuery{PageSize: 10})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("时间窗查询失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(inWindow) != 2 {
|
|
|
|
|
+ t.Fatalf("时间窗命中 %d 条,期望 2", len(inWindow))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if _, err := repo.ListLogs(ctx, user, []int64{2}, time.Time{}, time.Time{}, PageQuery{}); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
|
|
+ t.Fatalf("campusIDs 非空错误 = %v,期望 ErrBadParam", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestExpenseSumLogMoneyGoSide 对账汇总在 Go 侧定点累加(禁 SQL SUM 回 REAL)。
|
|
|
|
|
+func TestExpenseSumLogMoneyGoSide(t *testing.T) {
|
|
|
|
|
+ db, capture := newExpenseRepoDBWithCapture(t)
|
|
|
|
|
+ repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ const user = "01HUSER000000000000000110"
|
|
|
|
|
+
|
|
|
|
|
+ rows := []*model.UserExpenseLog{
|
|
|
|
|
+ {UserID: user, Type: model.ExpenseLogTypeRecharge, Money: "100.00"},
|
|
|
|
|
+ {UserID: user, Type: model.ExpenseLogTypeCourseCampus, Money: "-30.25"},
|
|
|
|
|
+ {UserID: user, Type: model.ExpenseLogTypeRefund, Money: "-69.75"},
|
|
|
|
|
+ {UserID: user, Type: model.ExpenseLogTypeRecharge, Money: "0.01"},
|
|
|
|
|
+ }
|
|
|
|
|
+ for i := range rows {
|
|
|
|
|
+ if err := db.Create(rows[i]).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 流水失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ capture.Reset()
|
|
|
|
|
+ total, err := repo.SumLogMoney(ctx, user, nil, nil)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("汇总失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := total.String(); got != "0.01" {
|
|
|
|
|
+ t.Fatalf("全类型合计 = %s,期望 0.01", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, stmt := range capture.All() {
|
|
|
|
|
+ if strings.Contains(strings.ToUpper(stmt), "SUM(") {
|
|
|
|
|
+ t.Fatalf("汇总不得下推到 SQL(禁 SUM 回 REAL):%s", stmt)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rechargeOnly, err := repo.SumLogMoney(ctx, user, nil, []int{int(model.ExpenseLogTypeRecharge)})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("按类型汇总失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := rechargeOnly.String(); got != "100.01" {
|
|
|
|
|
+ t.Fatalf("充值合计 = %s,期望 100.01", got)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if _, err := repo.SumLogMoney(ctx, user, []int64{2}, nil); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
|
|
+ t.Fatalf("campusIDs 非空错误 = %v,期望 ErrBadParam", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// assertBalance 断言账户余额(campusID=0 查全局)。
|
|
|
|
|
+func assertBalance(t *testing.T, repo *ExpenseRepo, ctx context.Context, user string, campusID int64, want string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ got, err := repo.GetBalance(ctx, user, campusID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("读取余额失败(campus=%d): %v", campusID, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.String() != want {
|
|
|
|
|
+ t.Fatalf("余额(campus=%d) = %s,期望 %s", campusID, got.String(), want)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// mustAmount 断言式解析金额(测试内固定字面量,解析失败直接判红)。
|
|
|
|
|
+func mustAmount(t *testing.T, s string) money.Amount {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ return parseAmount(t, s)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// parseAmount 把 decimal 字符串解析成 money.Amount(空串按 0 处理)。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么不直接比字符串:sqlite 的 decimal 列是 NUMERIC 亲和性,回读可能省略尾零("8" vs "8.00"),
|
|
|
|
|
+// 字符串相等会假红;按"分"比较才是金额语义。
|
|
|
|
|
+func parseAmount(t *testing.T, s string) money.Amount {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ if strings.TrimSpace(s) == "" {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+ a, err := money.Parse(s)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("解析金额 %q 失败: %v", s, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return a
|
|
|
|
|
+}
|