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