record_repo_test.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. // RecordRepo 单测(AC-11):首次扣费记录端口契约(D11 前提)+ 排班幂等判据。
  2. //
  3. // 测试栈:glebarez/sqlite(纯 Go 文件库,真实事务)。
  4. package repository
  5. import (
  6. "context"
  7. "errors"
  8. "path/filepath"
  9. "testing"
  10. "time"
  11. gormsqlite "github.com/glebarez/sqlite"
  12. "gorm.io/gorm"
  13. "yuansiwei/unified-backend/internal/academic/model"
  14. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  15. appscope "yuansiwei/unified-backend/internal/infra/scope"
  16. )
  17. // newRecordRepoDB 课程域真实库(排班实例 + 打卡明细 2 表)。
  18. func newRecordRepoDB(t *testing.T) *gorm.DB {
  19. t.Helper()
  20. dsn := filepath.Join(t.TempDir(), "academic_record_repo_test.db")
  21. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  22. if err != nil {
  23. t.Fatalf("gorm.Open(排班文件库) 失败: %v", err)
  24. }
  25. t.Cleanup(func() {
  26. if sqlDB, e := db.DB(); e == nil {
  27. _ = sqlDB.Close()
  28. }
  29. })
  30. autoMigrateSQLite(t, db, &model.CoursesRecord{}, &model.CoursesRecordStudent{})
  31. return db
  32. }
  33. // autoMigrateSQLite 逐表 AutoMigrate 并**立即删除该表全部索引**。
  34. //
  35. // 为什么需要(测试栈专有):SQLite 的索引名是**库级唯一**(MySQL 是表级),而 academic model
  36. // 有多张表共用同一索引名(`idx_inst_campus` / `idx_inst_campus_status` / `uk_biz_no` …),
  37. // 一次性 AutoMigrate 多表会在第二张表报 "index ... already exists"。逐表迁移后立刻删索引,
  38. // 后续表即可正常建同名索引。索引只影响查询性能,不影响本组用例断言的语义(生产 MySQL 无此限制)。
  39. func autoMigrateSQLite(t *testing.T, db *gorm.DB, tables ...any) {
  40. t.Helper()
  41. for _, tbl := range tables {
  42. if err := db.AutoMigrate(tbl); err != nil {
  43. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  44. }
  45. stmt := &gorm.Statement{DB: db}
  46. if err := stmt.Parse(tbl); err != nil {
  47. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  48. }
  49. var names []string
  50. if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
  51. stmt.Schema.Table).Scan(&names).Error; err != nil {
  52. t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  53. }
  54. for _, name := range names {
  55. if err := db.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
  56. t.Fatalf("删除索引(%s) 失败: %v", name, err)
  57. }
  58. }
  59. }
  60. }
  61. // seedChargedRow 插一条打卡明细(用于首扣判定)。
  62. //
  63. // 入参:db;recordID 上课记录 ID(**故意与本表自增 id 不同**,用于证明端口返回的是 record_id 列);
  64. // studentID;cost 本次课费(元字符串);endTime nil 表示未结课;startTime 上课时间。
  65. func seedChargedRow(t *testing.T, db *gorm.DB, bizNo string, recordID int64, studentID, cost string, startTime time.Time, endTime *time.Time) {
  66. t.Helper()
  67. row := &model.CoursesRecordStudent{
  68. BizNo: bizNo,
  69. CoursesID: 1,
  70. InstitutionID: 1,
  71. CampusID: 10,
  72. RecordID: recordID,
  73. StudentID: studentID,
  74. Type: model.CoursesRecordStudentTypeNormal,
  75. StartTime: startTime,
  76. EndTime: endTime,
  77. Cost: cost,
  78. }
  79. if err := db.Create(row).Error; err != nil {
  80. t.Fatalf("seed 打卡明细(%s) 失败: %v", bizNo, err)
  81. }
  82. }
  83. // TestFirstChargedRecordIDContract 首扣记录端口:4 个谓词 + 取 record_id 列(**不是本表 id**)。
  84. //
  85. // dump `yuansiwei_finance.sql:763811` 口径:
  86. //
  87. // SELECT recordid FROM t_courses_record_student
  88. // WHERE student_id=? AND cost>0 AND end_time IS NOT NULL ORDER BY start_time ASC LIMIT 1;
  89. //
  90. // 断言设计(每条反直觉口径都有反向数据):
  91. // - A 行 cost=0(赠送行)且时间最早 → 必须被 cost>0 排除;
  92. // - B 行 end_time IS NULL(未结课)且时间次早 → 必须被排除;
  93. // - C 行 (record_id=9002, start=2026-03-01) 与 D 行 (record_id=9001, start=2026-02-01) 都合格
  94. // → 取 start_time 最早的 D(9001),证明 ORDER BY start_time ASC LIMIT 1;
  95. // - 返回值必须等于 record_id(9001),而 D 行自增主键必然 > 9001(因为 A/B 先插入)→
  96. // 若实现误取本表 id,断言立刻红(这是 D11 改绑失效的核心陷阱)。
  97. func TestFirstChargedRecordIDContract(t *testing.T) {
  98. db := newRecordRepoDB(t)
  99. repo := NewRecordRepo(db, nil) // 端口方法不走 Scoped,nil scope 合法(有专门用例覆盖)
  100. ctx := context.Background()
  101. student := "01HSTUDENT00000000000001"
  102. done := time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC)
  103. seedChargedRow(t, db, "BZ-A", 8000, student, "0.00",
  104. time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC), &done) // cost=0 → 排除
  105. seedChargedRow(t, db, "BZ-B", 8001, student, "80.00",
  106. time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC), nil) // end_time NULL → 排除
  107. seedChargedRow(t, db, "BZ-C", 9002, student, "50.00",
  108. time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC), &done) // 晚于 D → 不取
  109. seedChargedRow(t, db, "BZ-D", 9001, student, "50.00",
  110. time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC), &done) // ← 期望命中
  111. recordID, found, err := repo.FirstChargedRecordID(ctx, student)
  112. if err != nil {
  113. t.Fatalf("FirstChargedRecordID 返回错误: %v", err)
  114. }
  115. if !found {
  116. t.Fatal("FirstChargedRecordID found=false,期望 true(存在合格的首扣记录)")
  117. }
  118. if recordID != 9001 {
  119. t.Fatalf("FirstChargedRecordID=%d,期望 9001(= record_id 列,且按 start_time 最早)", recordID)
  120. }
  121. // 反向断言:另一个学生(只有 cost=0 / 未结课行)必须 found=false
  122. other := "01HSTUDENT00000000000002"
  123. seedChargedRow(t, db, "BZ-E", 8002, other, "0.00",
  124. time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC), &done)
  125. seedChargedRow(t, db, "BZ-F", 8003, other, "30.00",
  126. time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC), nil)
  127. if id, ok, e := repo.FirstChargedRecordID(ctx, other); e != nil || ok || id != 0 {
  128. t.Fatalf("无合格首扣记录时应返回 (0,false,nil),实际 (%d,%v,%v)", id, ok, e)
  129. }
  130. // 空 studentID → 2002(不静默返回 not found)
  131. if _, _, e := repo.FirstChargedRecordID(ctx, " "); e == nil {
  132. t.Fatal("空 student_id 应返回参数错误")
  133. }
  134. }
  135. // TestFirstChargedRecordIDIgnoresScope 端口**不做作用域过滤**(dump 口径按 student_id 精确取数)。
  136. //
  137. // 反向断言:用校区作用域(InstitutionID=999,与数据不同)构造 repo,仍能查到 9001 ——
  138. // 若实现误用 Scoped,会返回 found=false(D11 改绑在"跨校区/系统调用"下静默失效)。
  139. func TestFirstChargedRecordIDIgnoresScope(t *testing.T) {
  140. db := newRecordRepoDB(t)
  141. ctx := context.Background()
  142. student := "01HSTUDENT00000000000003"
  143. done := time.Now().Add(-time.Hour)
  144. seedChargedRow(t, db, "BZ-G", 9100, student, "10.00", time.Now().Add(-2*time.Hour), &done)
  145. repo := NewRecordRepo(db, &appscope.Scope{InstitutionID: 999, CampusID: 999})
  146. id, found, err := repo.FirstChargedRecordID(ctx, student)
  147. if err != nil || !found || id != 9100 {
  148. t.Fatalf("跨作用域应仍可查到首扣记录,实际 (%d,%v,%v)", id, found, err)
  149. }
  150. }
  151. // TestFindByCoursesStartIdempotentAnchor 排班幂等判据:同 courses_id + start_time 命中既有实例。
  152. func TestFindByCoursesStartIdempotentAnchor(t *testing.T) {
  153. db := newRecordRepoDB(t)
  154. sc := &appscope.Scope{InstitutionID: 1, CampusID: 10}
  155. repo := NewRecordRepo(db, sc)
  156. ctx := context.Background()
  157. start := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC)
  158. end := start.Add(90 * time.Minute)
  159. row := &model.CoursesRecord{
  160. BizNo: "REC-1", CoursesID: 7, ClassID: 3, InstitutionID: 1, CampusID: 10,
  161. Status: model.CoursesRecordStatusNotStarted, StartTime: &start, EndTime: &end,
  162. }
  163. if err := db.Create(row).Error; err != nil {
  164. t.Fatalf("seed 排班实例失败: %v", err)
  165. }
  166. got, err := repo.FindByCoursesStart(ctx, 7, start)
  167. if err != nil {
  168. t.Fatalf("FindByCoursesStart 应命中,实际 err=%v", err)
  169. }
  170. if got.ID != row.ID {
  171. t.Fatalf("FindByCoursesStart 命中 id=%d,期望 %d", got.ID, row.ID)
  172. }
  173. // 反向断言:不同 start_time → 3001(可插入)
  174. _, err = repo.FindByCoursesStart(ctx, 7, start.Add(time.Hour))
  175. if !errors.Is(err, infraerrors.ErrNotFound) {
  176. t.Fatalf("不同 start_time 应返回 ErrNotFound,实际 %v", err)
  177. }
  178. }
  179. // TestMarkRecordStartedCAS 开始上课 CAS:第二次调用影响 0 行(并发双击只成功一次)。
  180. func TestMarkRecordStartedCAS(t *testing.T) {
  181. db := newRecordRepoDB(t)
  182. sc := &appscope.Scope{InstitutionID: 1, CampusID: 10}
  183. repo := NewRecordRepo(db, sc)
  184. ctx := context.Background()
  185. start := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC)
  186. row := &model.CoursesRecord{
  187. BizNo: "REC-2", CoursesID: 8, ClassID: 3, InstitutionID: 1, CampusID: 10,
  188. Status: model.CoursesRecordStatusNotStarted, StartTime: &start,
  189. }
  190. if err := db.Create(row).Error; err != nil {
  191. t.Fatalf("seed 排班实例失败: %v", err)
  192. }
  193. err := db.Transaction(func(tx *gorm.DB) error {
  194. affected, e := repo.MarkRecordStarted(ctx, tx, row.ID, time.Now())
  195. if e != nil {
  196. return e
  197. }
  198. if affected != 1 {
  199. t.Fatalf("首次开始上课 affected=%d,期望 1", affected)
  200. }
  201. again, e := repo.MarkRecordStarted(ctx, tx, row.ID, time.Now())
  202. if e != nil {
  203. return e
  204. }
  205. if again != 0 {
  206. t.Fatalf("重复开始上课 affected=%d,期望 0(CAS 兜底)", again)
  207. }
  208. return nil
  209. })
  210. if err != nil {
  211. t.Fatalf("CAS 用例失败: %v", err)
  212. }
  213. }