// ActivityDiscountRepo 单测(AC-06):可用额口径 / dump 3 参汇总 / 原子增减 / 批量单语句 CASE。 package repository import ( "context" "errors" "strings" "testing" "time" "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" ) // seedPool 插入一条赠送金额池(显式 ID)。 func seedPool(t *testing.T, db *gorm.DB, id, inst, campus int64, userID, discount, used string) { t.Helper() row := &model.CoursesActivityUserDiscount{ ID: id, ActivityID: 1, InstitutionID: inst, CampusID: campus, UserID: userID, Income: "0.00", DiscountAmount: discount, DiscountAmountUsed: used, CreateTime: time.Now(), } if err := db.Create(row).Error; err != nil { t.Fatalf("seed pool(%d) 失败: %v", id, err) } } // amountOf 读取池的「已使用金额」(money 归类,规避 sqlite NUMERIC 回读差异)。 func amountOf(t *testing.T, db *gorm.DB, id int64) money.Amount { t.Helper() var row model.CoursesActivityUserDiscount if err := db.Where("id = ?", id).First(&row).Error; err != nil { t.Fatalf("读池(%d) 失败: %v", id, err) } amt, err := money.Parse(row.DiscountAmountUsed) if err != nil { t.Fatalf("解析 used(%d) 失败: %v", id, err) } return amt } // TestListUsableFilterAndOrder 可用口径(discount > used)与游标方向。 func TestListUsableFilterAndOrder(t *testing.T) { db := newActivityRepoDB(t) user := "01HUSER000000000000000011" seedPool(t, db, 1, 1, 2, user, "10.00", "0.00") // 可用 seedPool(t, db, 2, 1, 2, user, "20.00", "20.00") // 用尽:不可用 seedPool(t, db, 3, 1, 2, user, "-5.00", "0.00") // 负赠送:不可用 seedPool(t, db, 4, 1, 2, user, "7.50", "2.50") // 可用 repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) ctx := context.Background() asc, err := repo.ListUsable(ctx, user, OrderAsc) if err != nil { t.Fatalf("ListUsable(asc) 失败: %v", err) } if len(asc) != 2 || asc[0].ID != 1 || asc[1].ID != 4 { t.Fatalf("可用行应为 {1,4}(升序),实得 %+v", asc) } desc, err := repo.ListUsable(ctx, user, OrderDesc) if err != nil { t.Fatalf("ListUsable(desc) 失败: %v", err) } if len(desc) != 2 || desc[0].ID != 4 || desc[1].ID != 1 { t.Fatalf("可用行应为 {4,1}(降序),实得 %+v", desc) } if _, err := repo.ListUsable(ctx, "", OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err) } if _, err := repo.ListUsable(ctx, user, "desc,1;--"); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("非法方向应 ErrBadParam,实得 %v", err) } } // TestSumUsableThreeParamScoping dump usable 3 参口径:机构 + 校区(campus=0 含全机构池)。 func TestSumUsableThreeParamScoping(t *testing.T) { db := newActivityRepoDB(t) user := "01HUSER000000000000000012" seedPool(t, db, 1, 1, 2, user, "10.00", "0.00") // 本校区:可用 10 seedPool(t, db, 2, 1, 0, user, "20.00", "5.00") // 机构所有自营校区:可用 15 seedPool(t, db, 3, 1, 3, user, "100.00", "0.00") // 他校区:本校区视角不计 seedPool(t, db, 4, 2, 2, user, "50.00", "0.00") // 他机构:一律不计 seedPool(t, db, 5, 1, 2, user, "5.00", "5.00") // 用尽:不计 seedPool(t, db, 6, 1, 2, "01HOTHER00000000000001", "999.00", "0.00") // 他人:不计 repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) ctx := context.Background() if got, err := repo.SumUsable(ctx, user, 1, 2); err != nil || got.Cents() != 2500 { t.Fatalf("SumUsable(1,2) 应为 25.00(10+15),实得 %s err=%v", got.String(), err) } if got, err := repo.SumUsable(ctx, user, 1, 0); err != nil || got.Cents() != 12500 { t.Fatalf("SumUsable(1,0) 应为 125.00(机构级口径含他校区 100),实得 %s err=%v", got.String(), err) } // 越权:非超管传入其它机构 → 1004 if _, err := repo.SumUsable(ctx, user, 2, 2); !errors.Is(err, infraerrors.ErrUnscoped) { t.Fatalf("跨机构参数应 ErrUnscoped,实得 %v", err) } // 超管可跨机构(报表视角) admin := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2, SuperAdmin: true}) if got, err := admin.SumUsable(ctx, user, 2, 2); err != nil || got.Cents() != 5000 { t.Fatalf("超管 SumUsable(2,2) 应为 50.00,实得 %s err=%v", got.String(), err) } // 参数防御 if _, err := repo.SumUsable(ctx, user, 0, 2); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("institutionID<=0 应 ErrBadParam,实得 %v", err) } // 无可用行 → 0(不是错误) if got, err := repo.SumUsable(ctx, "01HNOBODY00000000000001", 1, 2); err != nil || !got.IsZero() { t.Fatalf("无可用行应返回 0,实得 %s err=%v", got.String(), err) } } // TestIncUsedAndBatch 原子增减 + 批量单语句 CASE(正负增量都精确到分)。 func TestIncUsedAndBatch(t *testing.T) { db := newActivityRepoDB(t) user := "01HUSER000000000000000013" seedPool(t, db, 1, 1, 2, user, "100.00", "0.00") seedPool(t, db, 2, 1, 2, user, "100.00", "0.00") seedPool(t, db, 3, 1, 2, user, "100.00", "10.00") repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() // 单账户:消费 12.34 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.IncUsed(ctx, tx, 1, money.FromCents(1234)) if e != nil { return e } if n != 1 { t.Fatalf("IncUsed 应影响 1 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("IncUsed 失败: %v", err) } if got := amountOf(t, db, 1); got.Cents() != 1234 { t.Fatalf("id=1 used 应为 12.34,实得 %s", got.String()) } // 批量:id1 +1.00 / id2 +2.50 / id3 -0.01(退回冲正) sqls := captureSQL(t, db) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{ {DiscountID: 1, Delta: money.FromCents(100)}, {DiscountID: 2, Delta: money.FromCents(250)}, {DiscountID: 3, Delta: money.FromCents(-1)}, }) return e }); err != nil { t.Fatalf("IncUsedBatch 失败: %v", err) } // 立即断言(后续读库也会被回调捕获,故此处先判再读) if len(*sqls) != 1 { t.Fatalf("批量增减必须 1 条语句(禁止逐行 UPDATE),实得 %d 条: %v", len(*sqls), *sqls) } if !strings.Contains((*sqls)[0], "CASE id") { t.Fatalf("批量增减应为 CASE WHEN 单语句,SQL=%q", (*sqls)[0]) } if got := amountOf(t, db, 1); got.Cents() != 1334 { t.Fatalf("id=1 used 应为 13.34,实得 %s", got.String()) } if got := amountOf(t, db, 2); got.Cents() != 250 { t.Fatalf("id=2 used 应为 2.50,实得 %s", got.String()) } if got := amountOf(t, db, 3); got.Cents() != 999 { t.Fatalf("id=3 used 应为 9.99(10.00-0.01),实得 %s", got.String()) } } // TestIncUsedBatchEdgeCases 空集合/零增量/非法 id 不发 SQL;同 id 重复出现以后者为准。 func TestIncUsedBatchEdgeCases(t *testing.T) { db := newActivityRepoDB(t) user := "01HUSER000000000000000014" seedPool(t, db, 1, 1, 2, user, "100.00", "0.00") repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() sqls := captureSQL(t, db) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.IncUsedBatch(ctx, tx, nil) if e != nil { return e } if n != 0 { t.Fatalf("空集合应 0 行,实得 %d", n) } _, e = repo.IncUsedBatch(ctx, tx, []UsedDelta{{DiscountID: 0, Delta: money.FromCents(1)}, {DiscountID: 1, Delta: 0}}) return e }); err != nil { t.Fatalf("IncUsedBatch 边界用例失败: %v", err) } if len(*sqls) != 0 { t.Fatalf("无有效条目不应发 SQL,实得 %v", *sqls) } // 同 id 两次:后者覆盖前者 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{ {DiscountID: 1, Delta: money.FromCents(500)}, {DiscountID: 1, Delta: money.FromCents(700)}, }) return e }); err != nil { t.Fatalf("IncUsedBatch(重复 id) 失败: %v", err) } if got := amountOf(t, db, 1); got.Cents() != 700 { t.Fatalf("重复 id 应以后者为准(7.00),实得 %s", got.String()) } // tx 缺失 → ErrInternal if _, err := repo.IncUsed(nil, nil, 1, money.FromCents(1)); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err) } if _, err := repo.IncUsedBatch(ctx, nil, []UsedDelta{{DiscountID: 1, Delta: 1}}); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err) } if _, err := repo.IncUsed(ctx, repo.DB, 0, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err) } } // TestInsertDiscountAndUsedLog 两条流水写入(参数校验 + 落库形态)。 func TestInsertDiscountAndUsedLog(t *testing.T) { db := newActivityRepoDB(t) user := "01HUSER000000000000000015" seedPool(t, db, 1, 1, 2, user, "100.00", "0.00") repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() stuCourse := int64(901) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{ ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-10.00", CreateTime: time.Now(), }) }); err != nil { t.Fatalf("InsertDiscountLog 失败: %v", err) } if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{ ActivityID: 1, UserID: user, OrderNo: ptrString("901"), ActivityMoneyUsed: "-10.00", CreateTime: time.Now(), }) }); err != nil { t.Fatalf("InsertUsedLog 失败: %v", err) } var dlogCount, ulogCount int64 if err := db.Model(&model.CoursesActivityUserDiscountLog{}).Count(&dlogCount).Error; err != nil { t.Fatalf("count discount log 失败: %v", err) } if err := db.Model(&model.CoursesActivityUsedLog{}).Count(&ulogCount).Error; err != nil { t.Fatalf("count used log 失败: %v", err) } if dlogCount != 1 || ulogCount != 1 { t.Fatalf("两条流水应各 1 行,实得 discount=%d used=%d", dlogCount, ulogCount) } // 参数/事务防御 if err := repo.InsertDiscountLog(ctx, nil, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 1}); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("tx nil 应 ErrInternal,实得 %v", err) } if err := repo.InsertDiscountLog(ctx, repo.DB, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 0}); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("缺 discount id 应 ErrBadParam,实得 %v", err) } if err := repo.InsertUsedLog(ctx, repo.DB, &model.CoursesActivityUsedLog{ActivityID: 0, UserID: user}); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("缺 activity_id 应 ErrBadParam,实得 %v", err) } if err := repo.InsertUsedLog(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("nil 行应 ErrBadParam,实得 %v", err) } } // ptrString 字符串指针构造(测试用)。 func ptrString(s string) *string { return &s }