wiring_academic_test.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. // cmd/api 装配单测(AC-11 硬要点 5):DivideService 必须注入「首次扣费上课记录」端口。
  2. //
  3. // 这是 D11(撤销首扣订单 → 改绑保留分成)在生产生效的前提:
  4. // 未注入时 DivideService 只能走"回滚删除"分支 → 撤销首扣订单会把真实分成整组冲掉(少发钱)。
  5. // 本用例用真实 sqlite + 真实 RecordRepo 跑一遍 DeleteLog 的两条分支,防止"注入被误删"回归。
  6. package main
  7. import (
  8. "context"
  9. "path/filepath"
  10. "testing"
  11. "time"
  12. gormsqlite "github.com/glebarez/sqlite"
  13. "gorm.io/gorm"
  14. "yuansiwei/unified-backend/internal/academic/model"
  15. "yuansiwei/unified-backend/internal/academic/money"
  16. )
  17. // newWiringDB 装配用例库(分账明细 + 打卡明细 2 表)。
  18. //
  19. // 逐表迁移(SQLite 索引名库级唯一;两张表都有 uk_biz_no/index,见 service 测试同名助手注释)。
  20. func newWiringDB(t *testing.T) *gorm.DB {
  21. t.Helper()
  22. dsn := filepath.Join(t.TempDir(), "academic_wiring_test.db")
  23. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  24. if err != nil {
  25. t.Fatalf("gorm.Open(装配文件库) 失败: %v", err)
  26. }
  27. t.Cleanup(func() {
  28. if sqlDB, e := db.DB(); e == nil {
  29. _ = sqlDB.Close()
  30. }
  31. })
  32. for _, tbl := range []any{&model.UserDivideInto{}, &model.UserDivideIntoLog{}, &model.CoursesRecordStudent{}} {
  33. if err := db.AutoMigrate(tbl); err != nil {
  34. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  35. }
  36. stmt := &gorm.Statement{DB: db}
  37. if err := stmt.Parse(tbl); err != nil {
  38. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  39. }
  40. var names []string
  41. if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
  42. stmt.Schema.Table).Scan(&names).Error; err != nil {
  43. t.Fatalf("读取索引清单失败: %v", err)
  44. }
  45. for _, name := range names {
  46. if err := db.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
  47. t.Fatalf("删除索引(%s) 失败: %v", name, err)
  48. }
  49. }
  50. }
  51. return db
  52. }
  53. // TestAcademicWiringFirstChargedInjected 装配断言:端口已注入,且 D11 改绑分支真实生效。
  54. func TestAcademicWiringFirstChargedInjected(t *testing.T) {
  55. db := newWiringDB(t)
  56. svc := newAcademicServices(db)
  57. if svc == nil || svc.Divide == nil {
  58. t.Fatal("newAcademicServices 未装配 DivideService")
  59. }
  60. if !svc.FirstChargedInjected {
  61. t.Fatal("FirstChargedInjected=false —— 首次扣费端口未注入(D11 改绑分支将永远不生效)")
  62. }
  63. ctx := context.Background()
  64. student := "01HSTUDENT000000000000042"
  65. // ① 学生存在首扣记录(record_id=900,cost>0 且 end_time 非空)
  66. endTime := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
  67. if err := db.Create(&model.CoursesRecordStudent{
  68. BizNo: "RS-FIRST", CoursesID: 1, InstitutionID: 1, CampusID: 10, RecordID: 900,
  69. StudentID: student, Type: model.CoursesRecordStudentTypeNormal,
  70. StartTime: time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC), EndTime: &endTime, Cost: "50.00",
  71. }).Error; err != nil {
  72. t.Fatalf("seed 首扣打卡明细失败: %v", err)
  73. }
  74. // 分成明细(type=2 按课次,order_no=500)
  75. log := &model.UserDivideIntoLog{
  76. Type: model.DivideTypePerLesson, UserID: "01HAGENT0000000000000001",
  77. StudentID: student, OrderNo: strPtr("500"), Money: "10.00",
  78. }
  79. if err := db.Create(log).Error; err != nil {
  80. t.Fatalf("seed 分成明细失败: %v", err)
  81. }
  82. if err := svc.Divide.DeleteLog(ctx, student, 500); err != nil {
  83. t.Fatalf("DeleteLog 失败: %v", err)
  84. }
  85. var rebound model.UserDivideIntoLog
  86. if err := db.Where("id = ?", log.ID).First(&rebound).Error; err != nil {
  87. t.Fatalf("改绑分支不应删除明细,读取失败: %v", err)
  88. }
  89. if rebound.OrderNo == nil || *rebound.OrderNo != "900" {
  90. t.Fatalf("order_no=%v,期望 900(改绑到首次扣费的 record_id)", rebound.OrderNo)
  91. }
  92. if got, _ := money.Parse(rebound.Money); got.Cents() != 1000 {
  93. t.Fatalf("改绑不应改金额:%s", rebound.Money)
  94. }
  95. // ② 反向断言:无首扣记录的学生 → 走回滚删除分支(明细被删)
  96. other := "01HSTUDENT000000000000043"
  97. orphan := &model.UserDivideIntoLog{
  98. Type: model.DivideTypePerLesson, UserID: "01HAGENT0000000000000001",
  99. StudentID: other, OrderNo: strPtr("600"), Money: "20.00",
  100. }
  101. if err := db.Create(orphan).Error; err != nil {
  102. t.Fatalf("seed 无首扣分成明细失败: %v", err)
  103. }
  104. if err := svc.Divide.DeleteLog(ctx, other, 600); err != nil {
  105. t.Fatalf("DeleteLog(无首扣) 失败: %v", err)
  106. }
  107. var count int64
  108. if err := db.Model(&model.UserDivideIntoLog{}).Where("id = ?", orphan.ID).Count(&count).Error; err != nil {
  109. t.Fatalf("统计失败: %v", err)
  110. }
  111. if count != 0 {
  112. t.Fatalf("无首扣记录时应回滚删除明细,实际仍存在 %d 行", count)
  113. }
  114. }
  115. // strPtr 字符串指针构造(空串 → nil)。
  116. func strPtr(s string) *string {
  117. if s == "" {
  118. return nil
  119. }
  120. return &s
  121. }