// RecordRepo 单测(AC-11):首次扣费记录端口契约(D11 前提)+ 排班幂等判据。 // // 测试栈:glebarez/sqlite(纯 Go 文件库,真实事务)。 package repository import ( "context" "errors" "path/filepath" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // newRecordRepoDB 课程域真实库(排班实例 + 打卡明细 2 表)。 func newRecordRepoDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_record_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() } }) autoMigrateSQLite(t, db, &model.CoursesRecord{}, &model.CoursesRecordStudent{}) return db } // autoMigrateSQLite 逐表 AutoMigrate 并**立即删除该表全部索引**。 // // 为什么需要(测试栈专有):SQLite 的索引名是**库级唯一**(MySQL 是表级),而 academic model // 有多张表共用同一索引名(`idx_inst_campus` / `idx_inst_campus_status` / `uk_biz_no` …), // 一次性 AutoMigrate 多表会在第二张表报 "index ... already exists"。逐表迁移后立刻删索引, // 后续表即可正常建同名索引。索引只影响查询性能,不影响本组用例断言的语义(生产 MySQL 无此限制)。 func autoMigrateSQLite(t *testing.T, db *gorm.DB, tables ...any) { t.Helper() for _, tbl := range tables { if err := db.AutoMigrate(tbl); err != nil { t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err) } stmt := &gorm.Statement{DB: db} if err := stmt.Parse(tbl); err != nil { t.Fatalf("解析表名(%T) 失败: %v", tbl, err) } var names []string if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'", stmt.Schema.Table).Scan(&names).Error; err != nil { t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err) } for _, name := range names { if err := db.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil { t.Fatalf("删除索引(%s) 失败: %v", name, err) } } } } // seedChargedRow 插一条打卡明细(用于首扣判定)。 // // 入参:db;recordID 上课记录 ID(**故意与本表自增 id 不同**,用于证明端口返回的是 record_id 列); // studentID;cost 本次课费(元字符串);endTime nil 表示未结课;startTime 上课时间。 func seedChargedRow(t *testing.T, db *gorm.DB, bizNo string, recordID int64, studentID, cost string, startTime time.Time, endTime *time.Time) { t.Helper() row := &model.CoursesRecordStudent{ BizNo: bizNo, CoursesID: 1, InstitutionID: 1, CampusID: 10, RecordID: recordID, StudentID: studentID, Type: model.CoursesRecordStudentTypeNormal, StartTime: startTime, EndTime: endTime, Cost: cost, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 打卡明细(%s) 失败: %v", bizNo, err) } } // TestFirstChargedRecordIDContract 首扣记录端口:4 个谓词 + 取 record_id 列(**不是本表 id**)。 // // dump `yuansiwei_finance.sql:763811` 口径: // // SELECT recordid FROM t_courses_record_student // WHERE student_id=? AND cost>0 AND end_time IS NOT NULL ORDER BY start_time ASC LIMIT 1; // // 断言设计(每条反直觉口径都有反向数据): // - A 行 cost=0(赠送行)且时间最早 → 必须被 cost>0 排除; // - B 行 end_time IS NULL(未结课)且时间次早 → 必须被排除; // - C 行 (record_id=9002, start=2026-03-01) 与 D 行 (record_id=9001, start=2026-02-01) 都合格 // → 取 start_time 最早的 D(9001),证明 ORDER BY start_time ASC LIMIT 1; // - 返回值必须等于 record_id(9001),而 D 行自增主键必然 > 9001(因为 A/B 先插入)→ // 若实现误取本表 id,断言立刻红(这是 D11 改绑失效的核心陷阱)。 func TestFirstChargedRecordIDContract(t *testing.T) { db := newRecordRepoDB(t) repo := NewRecordRepo(db, nil) // 端口方法不走 Scoped,nil scope 合法(有专门用例覆盖) ctx := context.Background() student := "01HSTUDENT00000000000001" done := time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC) seedChargedRow(t, db, "BZ-A", 8000, student, "0.00", time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC), &done) // cost=0 → 排除 seedChargedRow(t, db, "BZ-B", 8001, student, "80.00", time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC), nil) // end_time NULL → 排除 seedChargedRow(t, db, "BZ-C", 9002, student, "50.00", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC), &done) // 晚于 D → 不取 seedChargedRow(t, db, "BZ-D", 9001, student, "50.00", time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC), &done) // ← 期望命中 recordID, found, err := repo.FirstChargedRecordID(ctx, student) if err != nil { t.Fatalf("FirstChargedRecordID 返回错误: %v", err) } if !found { t.Fatal("FirstChargedRecordID found=false,期望 true(存在合格的首扣记录)") } if recordID != 9001 { t.Fatalf("FirstChargedRecordID=%d,期望 9001(= record_id 列,且按 start_time 最早)", recordID) } // 反向断言:另一个学生(只有 cost=0 / 未结课行)必须 found=false other := "01HSTUDENT00000000000002" seedChargedRow(t, db, "BZ-E", 8002, other, "0.00", time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC), &done) seedChargedRow(t, db, "BZ-F", 8003, other, "30.00", time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC), nil) if id, ok, e := repo.FirstChargedRecordID(ctx, other); e != nil || ok || id != 0 { t.Fatalf("无合格首扣记录时应返回 (0,false,nil),实际 (%d,%v,%v)", id, ok, e) } // 空 studentID → 2002(不静默返回 not found) if _, _, e := repo.FirstChargedRecordID(ctx, " "); e == nil { t.Fatal("空 student_id 应返回参数错误") } } // TestFirstChargedRecordIDIgnoresScope 端口**不做作用域过滤**(dump 口径按 student_id 精确取数)。 // // 反向断言:用校区作用域(InstitutionID=999,与数据不同)构造 repo,仍能查到 9001 —— // 若实现误用 Scoped,会返回 found=false(D11 改绑在"跨校区/系统调用"下静默失效)。 func TestFirstChargedRecordIDIgnoresScope(t *testing.T) { db := newRecordRepoDB(t) ctx := context.Background() student := "01HSTUDENT00000000000003" done := time.Now().Add(-time.Hour) seedChargedRow(t, db, "BZ-G", 9100, student, "10.00", time.Now().Add(-2*time.Hour), &done) repo := NewRecordRepo(db, &appscope.Scope{InstitutionID: 999, CampusID: 999}) id, found, err := repo.FirstChargedRecordID(ctx, student) if err != nil || !found || id != 9100 { t.Fatalf("跨作用域应仍可查到首扣记录,实际 (%d,%v,%v)", id, found, err) } } // TestFindByCoursesStartIdempotentAnchor 排班幂等判据:同 courses_id + start_time 命中既有实例。 func TestFindByCoursesStartIdempotentAnchor(t *testing.T) { db := newRecordRepoDB(t) sc := &appscope.Scope{InstitutionID: 1, CampusID: 10} repo := NewRecordRepo(db, sc) ctx := context.Background() start := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) end := start.Add(90 * time.Minute) row := &model.CoursesRecord{ BizNo: "REC-1", CoursesID: 7, ClassID: 3, InstitutionID: 1, CampusID: 10, Status: model.CoursesRecordStatusNotStarted, StartTime: &start, EndTime: &end, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 排班实例失败: %v", err) } got, err := repo.FindByCoursesStart(ctx, 7, start) if err != nil { t.Fatalf("FindByCoursesStart 应命中,实际 err=%v", err) } if got.ID != row.ID { t.Fatalf("FindByCoursesStart 命中 id=%d,期望 %d", got.ID, row.ID) } // 反向断言:不同 start_time → 3001(可插入) _, err = repo.FindByCoursesStart(ctx, 7, start.Add(time.Hour)) if !errors.Is(err, infraerrors.ErrNotFound) { t.Fatalf("不同 start_time 应返回 ErrNotFound,实际 %v", err) } } // TestMarkRecordStartedCAS 开始上课 CAS:第二次调用影响 0 行(并发双击只成功一次)。 func TestMarkRecordStartedCAS(t *testing.T) { db := newRecordRepoDB(t) sc := &appscope.Scope{InstitutionID: 1, CampusID: 10} repo := NewRecordRepo(db, sc) ctx := context.Background() start := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC) row := &model.CoursesRecord{ BizNo: "REC-2", CoursesID: 8, ClassID: 3, InstitutionID: 1, CampusID: 10, Status: model.CoursesRecordStatusNotStarted, StartTime: &start, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 排班实例失败: %v", err) } err := db.Transaction(func(tx *gorm.DB) error { affected, e := repo.MarkRecordStarted(ctx, tx, row.ID, time.Now()) if e != nil { return e } if affected != 1 { t.Fatalf("首次开始上课 affected=%d,期望 1", affected) } again, e := repo.MarkRecordStarted(ctx, tx, row.ID, time.Now()) if e != nil { return e } if again != 0 { t.Fatalf("重复开始上课 affected=%d,期望 0(CAS 兜底)", again) } return nil }) if err != nil { t.Fatalf("CAS 用例失败: %v", err) } }