| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588 |
- // ActivityRepo 单测(AC-06):作用域超集语义 / 批量防 N+1 / DELETE+INSERT 幂等 /
- // type=4 跨活动去重 / number_sign 下限 / 游标方向白名单。
- //
- // 测试栈:glebarez/sqlite(纯 Go,CGO_ENABLED=0 可跑)——真实库用例走 t.TempDir 文件库,
- // SQL 形状用例走 DryRun 句柄(与 base_test.go 同一套 helper)。
- package repository
- import (
- "context"
- "errors"
- "path/filepath"
- "strings"
- "testing"
- "time"
- gormsqlite "github.com/glebarez/sqlite"
- "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"
- )
- // newActivityRepoDB 活动域真实库(文件库;含报名/赠送池/流水 5 表 AutoMigrate)。
- func newActivityRepoDB(t *testing.T) *gorm.DB {
- t.Helper()
- dsn := filepath.Join(t.TempDir(), "academic_activity_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.CoursesActivity{},
- &model.CoursesActivitySigned{},
- &model.CoursesActivityUserDiscount{},
- &model.CoursesActivityUserDiscountLog{},
- &model.CoursesActivityUsedLog{},
- ); err != nil {
- t.Fatalf("AutoMigrate(活动 5 表) 失败: %v", err)
- }
- return db
- }
- // seedActivity 插入一条活动(显式 ID,便于断言)。
- func seedActivity(t *testing.T, db *gorm.DB, id, inst, campus int64, typ int8, money string) *model.CoursesActivity {
- t.Helper()
- row := &model.CoursesActivity{
- ID: id,
- Type: typ,
- InstitutionID: inst,
- CampusID: campus,
- Money: money,
- Status: model.ActivityStatusEnabled,
- CreateTime: time.Now(),
- UpdatedAt: time.Now(),
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed activity(%d) 失败: %v", id, err)
- }
- return row
- }
- // seedSigned 插入一条报名行(显式 ID)。
- func seedSigned(t *testing.T, db *gorm.DB, id, activityID, inst, campus int64, userID string, status int32) {
- t.Helper()
- row := &model.CoursesActivitySigned{
- ID: id,
- ActivityID: activityID,
- UserID: userID,
- InstitutionID: inst,
- CampusID: campus,
- Status: status,
- CreateTime: time.Now(),
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed signed(%d) 失败: %v", id, err)
- }
- }
- // captureSQL 注册 Query/Update 回调,收集执行过的 SQL 语句(断言防 N+1 / 单语句 CASE)。
- func captureSQL(t *testing.T, db *gorm.DB) *[]string {
- t.Helper()
- out := make([]string, 0, 8)
- collect := func(d *gorm.DB) {
- if d.Statement != nil && d.Statement.SQL.Len() > 0 {
- out = append(out, d.Statement.SQL.String())
- }
- }
- if err := db.Callback().Query().After("gorm:query").Register("test:capture_query", collect); err != nil {
- t.Fatalf("注册 Query 回调失败: %v", err)
- }
- if err := db.Callback().Update().After("gorm:update").Register("test:capture_update", collect); err != nil {
- t.Fatalf("注册 Update 回调失败: %v", err)
- }
- return &out
- }
- // TestScopedCampusRowsSQL 校区过滤必须是 `IN (campus, 0)` 超集(不是等值),机构过滤强制注入。
- func TestScopedCampusRowsSQL(t *testing.T) {
- db := newDryRunDB(t)
- sc := &appscope.Scope{InstitutionID: 42, CampusID: 7}
- q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "")
- var out []model.CoursesActivity
- if err := q.Where("id = ?", 1).Find(&out).Error; err != nil {
- t.Fatalf("DryRun Find 失败: %v", err)
- }
- sql := q.Statement.SQL.String()
- if !strings.Contains(sql, "institution_id = ?") {
- t.Errorf("应强制注入 institution_id,SQL=%q", sql)
- }
- if !strings.Contains(sql, "campus_id IN") {
- t.Errorf("校区过滤必须是 IN (campus,0) 超集(campus_id=0 语义=全自营校区),SQL=%q", sql)
- }
- if strings.Contains(sql, "campus_id = ?") {
- t.Errorf("不得用等值过滤(会裁掉全机构活动),SQL=%q", sql)
- }
- }
- // TestScopedCampusRowsSuperAdminAndCampusZero 超管不过滤;campus=0 时不加校区条件。
- func TestScopedCampusRowsSuperAdminAndCampusZero(t *testing.T) {
- db := newDryRunDB(t)
- // 超管
- sc := &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}
- q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "")
- var out []model.CoursesActivity
- if err := q.Find(&out).Error; err != nil {
- t.Fatalf("DryRun Find 失败: %v", err)
- }
- if sql := q.Statement.SQL.String(); strings.Contains(sql, "institution_id") || strings.Contains(sql, "campus_id") {
- t.Errorf("超管不应注入作用域过滤,SQL=%q", sql)
- }
- // 机构级视角(campus=0)
- sc2 := &appscope.Scope{InstitutionID: 42, CampusID: 0}
- q2 := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc2, "")
- if err := q2.Find(&out).Error; err != nil {
- t.Fatalf("DryRun Find 失败: %v", err)
- }
- if sql := q2.Statement.SQL.String(); strings.Contains(sql, "campus_id") {
- t.Errorf("CampusID=0 不应注入校区过滤,SQL=%q", sql)
- }
- }
- // TestScopedCampusRowsNilScopePanics 无作用域进入数据层 → panic(fail-fast)。
- func TestScopedCampusRowsNilScopePanics(t *testing.T) {
- db := newDryRunDB(t)
- defer func() {
- rec := recover()
- if rec == nil {
- t.Fatal("Sc 为 nil 时应 panic")
- }
- if !strings.Contains(rec.(string), "missing tenant scope") {
- t.Fatalf("panic 文案不符: %v", rec)
- }
- }()
- _ = scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), nil, "")
- }
- // TestGetByIDScoping 跨机构活动 → ErrNotFound;campus_id=0 活动在本校区视角可见。
- func TestGetByIDScoping(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00")
- seedActivity(t, db, 2, 1, 0, model.ActivityTypeVoucher, "50.00") // 0=机构所有自营校区
- seedActivity(t, db, 3, 2, 2, model.ActivityTypeRecommend, "30.00")
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- if got, err := repo.GetByID(context.Background(), 1); err != nil || got.ID != 1 {
- t.Fatalf("GetByID(1) 应命中,got=%v err=%v", got, err)
- }
- if _, err := repo.GetByID(context.Background(), 2); err != nil {
- t.Fatalf("campus_id=0 的活动应在本校区视角可见(IN (campus,0)),实得 %v", err)
- }
- if _, err := repo.GetByID(context.Background(), 3); !errors.Is(err, infraerrors.ErrNotFound) {
- t.Fatalf("跨机构活动必须 ErrNotFound(不泄露存在性),实得 %v", err)
- }
- if _, err := repo.GetByID(context.Background(), 0); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err)
- }
- }
- // TestGetByIDsNoNPlusOne 批量取数:去重 + 单条 SQL(防 N+1)+ 缺失 id 不在 map 中。
- func TestGetByIDsNoNPlusOne(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00")
- seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "50.00")
- seedActivity(t, db, 3, 9, 2, model.ActivityTypeRecommend, "30.00") // 他机构:应被裁掉
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- sqls := captureSQL(t, db)
- got, err := repo.GetByIDs(context.Background(), []int64{1, 2, 2, 3, 0, -1})
- if err != nil {
- t.Fatalf("GetByIDs 失败: %v", err)
- }
- if len(got) != 2 || got[1] == nil || got[2] == nil {
- t.Fatalf("应返回 {1,2} 两行(去重+作用域裁剪),实得 %v", got)
- }
- if len(*sqls) != 1 {
- t.Fatalf("批量取数必须是 1 条 SQL(防 N+1),实得 %d 条: %v", len(*sqls), *sqls)
- }
- if !strings.Contains((*sqls)[0], "IN") {
- t.Fatalf("应为 IN 批量查询,SQL=%q", (*sqls)[0])
- }
- // 空/全非法 → 空 map 且不发 SQL
- *sqls = (*sqls)[:0]
- empty, err := repo.GetByIDs(context.Background(), []int64{0, -3})
- if err != nil || len(empty) != 0 {
- t.Fatalf("空 ids 应返回空 map 且无错误,实得 %v err=%v", empty, err)
- }
- if len(*sqls) != 0 {
- t.Fatalf("空 ids 不应发 SQL,实得 %v", *sqls)
- }
- }
- // TestUpsertSignedDeleteInsertIdempotent DELETE+INSERT 幂等:同一 (user, activity) 只留一条且全字段重置。
- func TestUpsertSignedDeleteInsertIdempotent(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 10, 1, 2, model.ActivityTypeRecharge, "100.00")
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- runner := NewTxRunner(db)
- ctx := context.Background()
- var firstID int64
- err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- row := &model.CoursesActivitySigned{
- ActivityID: 10,
- UserID: "01HUSER000000000000000001",
- CampusID: 2,
- Status: model.ActivitySignedSigned,
- ActivityMoney: "100.00",
- CreateTime: time.Now(),
- }
- if err := repo.UpsertSigned(ctx, tx, row); err != nil {
- return err
- }
- firstID = row.ID
- return nil
- })
- if err != nil {
- t.Fatalf("首次 UpsertSigned 失败: %v", err)
- }
- // 模拟"先取消(status=0)后重新报名":旧行必须被 DELETE 掉,不能残留两条
- err = runner.WithinTx(ctx, func(tx *gorm.DB) error {
- row := &model.CoursesActivitySigned{
- ActivityID: 10,
- UserID: "01HUSER000000000000000001",
- CampusID: 2,
- Status: model.ActivitySignedAttended,
- ActivityMoney: "88.00",
- CreateTime: time.Now(),
- }
- return repo.UpsertSigned(ctx, tx, row)
- })
- if err != nil {
- t.Fatalf("二次 UpsertSigned 失败: %v", err)
- }
- var rows []model.CoursesActivitySigned
- if err := db.Where("user_id = ? AND activity_id = ?", "01HUSER000000000000000001", 10).Find(&rows).Error; err != nil {
- t.Fatalf("查询报名行失败: %v", err)
- }
- if len(rows) != 1 {
- t.Fatalf("DELETE+INSERT 幂等后应仅 1 行,实得 %d", len(rows))
- }
- if rows[0].Status != model.ActivitySignedAttended {
- t.Fatalf("重新报名应全字段重置,实得 status=%d", rows[0].Status)
- }
- // 金额列在 sqlite 下按 NUMERIC 亲和回读("88.00" → 88),故用 money 归一后比较语义值
- if amt, perr := money.Parse(rows[0].ActivityMoney); perr != nil || amt.Cents() != 8800 {
- t.Fatalf("重新报名应重置 activity_money=88.00,实得 %q (err=%v)", rows[0].ActivityMoney, perr)
- }
- if rows[0].ID == firstID {
- t.Fatalf("重新报名应插入新行(旧行被 DELETE),ID 未变 = %d", rows[0].ID)
- }
- if rows[0].InstitutionID != 1 {
- t.Fatalf("institution_id 应由作用域补齐为 1,实得 %d", rows[0].InstitutionID)
- }
- }
- // TestUpsertSignedGuards 事务/参数防御。
- func TestUpsertSignedGuards(t *testing.T) {
- repo := NewActivityRepo(newActivityRepoDB(t), &appscope.Scope{InstitutionID: 1, CampusID: 2})
- ctx := context.Background()
- if err := repo.UpsertSigned(ctx, nil, &model.CoursesActivitySigned{ActivityID: 1, UserID: "u"}); !errors.Is(err, infraerrors.ErrInternal) {
- t.Fatalf("tx 为 nil 应 ErrInternal(禁止脱离事务写),实得 %v", err)
- }
- if err := repo.UpsertSigned(ctx, repo.DB, &model.CoursesActivitySigned{ActivityID: 0, UserID: "u"}); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("activity_id<=0 应 ErrBadParam,实得 %v", err)
- }
- if err := repo.UpsertSigned(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("nil 行应 ErrBadParam,实得 %v", err)
- }
- }
- // TestCountSignedByUserType4Dedup type=4 跨活动去重计数;status=0 不计。
- func TestCountSignedByUserType4Dedup(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecommend, "10.00")
- seedActivity(t, db, 2, 1, 2, model.ActivityTypeRecommend, "20.00")
- seedActivity(t, db, 3, 1, 2, model.ActivityTypeRecommend, "30.00")
- seedActivity(t, db, 4, 1, 2, model.ActivityTypeRecharge, "40.00")
- user := "01HUSER000000000000000002"
- seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned)
- seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedAttended)
- seedSigned(t, db, 3, 3, 1, 2, user, model.ActivitySignedCancelled) // 已取消:不计
- seedSigned(t, db, 4, 4, 1, 2, user, model.ActivitySignedSigned) // type=2:type4Only 不计
- seedSigned(t, db, 5, 1, 1, 2, "01HUSER000000000000000009", model.ActivitySignedSigned)
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- ctx := context.Background()
- type4, err := repo.CountSignedByUser(ctx, user, true)
- if err != nil {
- t.Fatalf("CountSignedByUser(type4) 失败: %v", err)
- }
- if type4 != 2 {
- t.Fatalf("type=4 有效报名数应为 2(取消行不计),实得 %d", type4)
- }
- all, err := repo.CountSignedByUser(ctx, user, false)
- if err != nil {
- t.Fatalf("CountSignedByUser(all) 失败: %v", err)
- }
- if all != 3 {
- t.Fatalf("全部有效报名数应为 3,实得 %d", all)
- }
- if _, err := repo.CountSignedByUser(ctx, " ", true); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
- }
- }
- // TestIncreaseNumberSignFloorZero 报名人数原子增减且下限 0。
- func TestIncreaseNumberSignFloorZero(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 7, 1, 2, model.ActivityTypeRecharge, "0.00")
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- runner := NewTxRunner(db)
- ctx := context.Background()
- readSign := func() int32 {
- var row model.CoursesActivity
- if err := db.Where("id = ?", 7).First(&row).Error; err != nil {
- t.Fatalf("读活动失败: %v", err)
- }
- return row.NumberSign
- }
- // 取消到负数 → 钳制为 0
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := repo.IncreaseNumberSign(ctx, tx, 7, -5)
- return e
- }); err != nil {
- t.Fatalf("IncreaseNumberSign(-5) 失败: %v", err)
- }
- if got := readSign(); got != 0 {
- t.Fatalf("number_sign 下限应为 0(防取消竞态),实得 %d", got)
- }
- // +2
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := repo.IncreaseNumberSign(ctx, tx, 7, 2)
- return e
- }); err != nil {
- t.Fatalf("IncreaseNumberSign(+2) 失败: %v", err)
- }
- if got := readSign(); got != 2 {
- t.Fatalf("number_sign 应为 2,实得 %d", got)
- }
- // -1
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := repo.IncreaseNumberSign(ctx, tx, 7, -1)
- return e
- }); err != nil {
- t.Fatalf("IncreaseNumberSign(-1) 失败: %v", err)
- }
- if got := readSign(); got != 1 {
- t.Fatalf("number_sign 应为 1,实得 %d", got)
- }
- // delta=0 → 空操作
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- n, e := repo.IncreaseNumberSign(ctx, tx, 7, 0)
- if n != 0 {
- t.Fatalf("delta=0 应无影响行,实得 %d", n)
- }
- return e
- }); err != nil {
- t.Fatalf("IncreaseNumberSign(0) 失败: %v", err)
- }
- // 不存在/越权活动 → 0 行
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- n, e := repo.IncreaseNumberSign(ctx, tx, 999, 1)
- if e != nil {
- return e
- }
- if n != 0 {
- t.Fatalf("活动不存在应 0 行,实得 %d", n)
- }
- return nil
- }); err != nil {
- t.Fatalf("IncreaseNumberSign(999) 失败: %v", err)
- }
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- _, e := repo.IncreaseNumberSign(ctx, nil, 7, 1)
- return e
- }); !errors.Is(err, infraerrors.ErrInternal) {
- t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
- }
- }
- // TestUpdateSignedStatusRows 状态更新返回影响行数(0 → Service 判 3001)。
- func TestUpdateSignedStatusRows(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 5, 1, 2, model.ActivityTypeVoucher, "0.00")
- user := "01HUSER000000000000000003"
- seedSigned(t, db, 1, 5, 1, 2, user, model.ActivitySignedSigned)
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- runner := NewTxRunner(db)
- ctx := context.Background()
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- n, e := repo.UpdateSignedStatus(ctx, tx, user, 5, model.ActivitySignedCancelled)
- if e != nil {
- return e
- }
- if n != 1 {
- t.Fatalf("应更新 1 行,实得 %d", n)
- }
- return nil
- }); err != nil {
- t.Fatalf("UpdateSignedStatus 失败: %v", err)
- }
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- n, e := repo.UpdateSignedStatus(ctx, tx, "01HNOPE000000000000000001", 5, model.ActivitySignedSigned)
- if e != nil {
- return e
- }
- if n != 0 {
- t.Fatalf("不存在的报名行应 0 行,实得 %d", n)
- }
- return nil
- }); err != nil {
- t.Fatalf("UpdateSignedStatus(缺失行) 失败: %v", err)
- }
- var row model.CoursesActivitySigned
- if err := db.Where("id = ?", 1).First(&row).Error; err != nil {
- t.Fatalf("读报名行失败: %v", err)
- }
- if row.Status != model.ActivitySignedCancelled {
- t.Fatalf("状态应更新为 0,实得 %d", row.Status)
- }
- }
- // TestListSignedByStudentCourseCursor 游标方向(ASC/DESC,dump 顺序)与白名单校验。
- func TestListSignedByStudentCourseCursor(t *testing.T) {
- db := newActivityRepoDB(t)
- ctx := context.Background()
- seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00")
- seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "20.00")
- user := "01HUSER000000000000000004"
- seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned)
- seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedDeal)
- // 两个池 + 两条流水(同一报课记录 900)
- pools := []model.CoursesActivityUserDiscount{
- {ID: 1, ActivityID: 1, InstitutionID: 1, CampusID: 2, UserID: user, Income: "100.00", DiscountAmount: "10.00", CreateTime: time.Now()},
- {ID: 2, ActivityID: 2, InstitutionID: 1, CampusID: 2, UserID: user, Income: "200.00", DiscountAmount: "20.00", CreateTime: time.Now()},
- }
- for i := range pools {
- if err := db.Create(&pools[i]).Error; err != nil {
- t.Fatalf("seed pool 失败: %v", err)
- }
- }
- stuCourse := int64(900)
- logs := []model.CoursesActivityUserDiscountLog{
- {ID: 1, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-10.00", CreateTime: time.Now()},
- {ID: 2, ActivityUserDiscountID: 2, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-20.00", CreateTime: time.Now()},
- }
- for i := range logs {
- if err := db.Create(&logs[i]).Error; err != nil {
- t.Fatalf("seed discount log 失败: %v", err)
- }
- }
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- asc, err := repo.ListSignedByStudentCourse(ctx, stuCourse, OrderAsc)
- if err != nil {
- t.Fatalf("ListSignedByStudentCourse(asc) 失败: %v", err)
- }
- if len(asc) != 2 || asc[0].ID >= asc[1].ID {
- t.Fatalf("ASC 应返回按 id 升序的 2 行,实得 %v", asc)
- }
- desc, err := repo.ListSignedByStudentCourse(ctx, stuCourse, OrderDesc)
- if err != nil {
- t.Fatalf("ListSignedByStudentCourse(desc) 失败: %v", err)
- }
- if len(desc) != 2 || desc[0].ID <= desc[1].ID {
- t.Fatalf("DESC 应返回按 id 降序的 2 行,实得 %v", desc)
- }
- // 非法方向必须拒绝(防 ORDER BY 注入)
- if _, err := repo.ListSignedByStudentCourse(ctx, stuCourse, "asc; DROP TABLE x"); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("非法排序方向应 ErrBadParam,实得 %v", err)
- }
- if _, err := repo.ListSignedByStudentCourse(ctx, 0, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("stuCourseID<=0 应 ErrBadParam,实得 %v", err)
- }
- }
- // TestNormalizeOrder 方向归一(大小写/空白容忍,非法拒绝)。
- func TestNormalizeOrder(t *testing.T) {
- for _, in := range []string{"", "asc", "ASC", " Asc "} {
- if got, err := normalizeOrder(in); err != nil || got != OrderAsc {
- t.Fatalf("normalizeOrder(%q) = (%q,%v),期望 (asc,nil)", in, got, err)
- }
- }
- if got, err := normalizeOrder("DESC"); err != nil || got != OrderDesc {
- t.Fatalf("normalizeOrder(DESC) = (%q,%v),期望 (desc,nil)", got, err)
- }
- if _, err := normalizeOrder("id desc, (select 1)"); !errors.Is(err, infraerrors.ErrBadParam) {
- t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
- }
- }
- // TestWriteAuditTimeFallback 零值 create_time 必须被仓储兜底。
- //
- // 背景(实测确认):活动域 model 的审计列名为 `CreateTime`(非 GORM 约定的 `CreatedAt`),
- // 且无 `default:CURRENT_TIMESTAMP(3)` tag → GORM 会把 0001-01-01 显式写库,DB 默认值兜不住。
- // 审计时间是对账依据,故三条写路径(signed / discount_log / used_log)都必须兜底。
- func TestWriteAuditTimeFallback(t *testing.T) {
- db := newActivityRepoDB(t)
- seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "0.00")
- seedPool(t, db, 1, 1, 2, "01HUSER000000000000000099", "10.00", "0.00")
- repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- drepo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
- runner := NewTxRunner(db)
- ctx := context.Background()
- user := "01HUSER000000000000000099"
- stuCourse := int64(902)
- if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
- if e := repo.UpsertSigned(ctx, tx, &model.CoursesActivitySigned{
- ActivityID: 1, UserID: user, CampusID: 2, Status: model.ActivitySignedSigned,
- }); e != nil {
- return e
- }
- if e := drepo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{
- ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-1.00",
- }); e != nil {
- return e
- }
- return drepo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{
- ActivityID: 1, UserID: user, OrderNo: ptrString("902"), ActivityMoneyUsed: "-1.00",
- })
- }); err != nil {
- t.Fatalf("写入失败: %v", err)
- }
- var signed model.CoursesActivitySigned
- if err := db.Where("user_id = ?", user).First(&signed).Error; err != nil {
- t.Fatalf("读 signed 失败: %v", err)
- }
- if signed.CreateTime.IsZero() {
- t.Fatal("signed.create_time 必须被兜底(否则审计时间被写成 0001-01-01)")
- }
- var dlog model.CoursesActivityUserDiscountLog
- if err := db.Where("activity_user_discount_id = ?", 1).First(&dlog).Error; err != nil {
- t.Fatalf("读 discount log 失败: %v", err)
- }
- if dlog.CreateTime.IsZero() {
- t.Fatal("discount_log.create_time 必须被兜底")
- }
- var ulog model.CoursesActivityUsedLog
- if err := db.Where("user_id = ?", user).First(&ulog).Error; err != nil {
- t.Fatalf("读 used log 失败: %v", err)
- }
- if ulog.CreateTime.IsZero() {
- t.Fatal("used_log.create_time 必须被兜底")
- }
- }
|