port_test.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. // academic port 接口实现单测(org 只读适配器 + mock 可替换性)。
  2. //
  3. // 测试栈:glebarez/sqlite 临时文件库,手工建 t_campus / t_membership 最小表结构
  4. // (不依赖 org/model 的 AutoMigrate,验证适配器只看列名、不耦合 org 的 struct)。
  5. package port
  6. import (
  7. "context"
  8. stderrors "errors"
  9. "path/filepath"
  10. "testing"
  11. gormsqlite "github.com/glebarez/sqlite"
  12. "gorm.io/gorm"
  13. "yuansiwei/unified-backend/internal/academic/apperr"
  14. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  15. )
  16. // newCampusDB 建临时 sqlite 库并写入校区/成员样例数据。
  17. func newCampusDB(t *testing.T) *gorm.DB {
  18. t.Helper()
  19. dsn := filepath.Join(t.TempDir(), "academic_port_test.db")
  20. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  21. if err != nil {
  22. t.Fatalf("gorm.Open 失败: %v", err)
  23. }
  24. // Windows 下连接未关闭会导致 t.TempDir 清理失败(unlinkat 占用),故注册清理钩子
  25. t.Cleanup(func() {
  26. if sqlDB, e := db.DB(); e == nil {
  27. _ = sqlDB.Close()
  28. }
  29. })
  30. stmts := []string{
  31. `CREATE TABLE t_campus (
  32. campus_id INTEGER PRIMARY KEY,
  33. institution_id INTEGER NOT NULL,
  34. self_support TINYINT NOT NULL DEFAULT 1,
  35. status TEXT NOT NULL
  36. )`,
  37. `CREATE TABLE t_membership (
  38. membership_id INTEGER PRIMARY KEY,
  39. canonical_user_id VARCHAR(26) NOT NULL,
  40. institution_id INTEGER NOT NULL,
  41. campus_id INTEGER NOT NULL,
  42. role VARCHAR(32) NOT NULL,
  43. status VARCHAR(16) NOT NULL
  44. )`,
  45. `INSERT INTO t_campus (campus_id, institution_id, self_support, status) VALUES
  46. (7, 42, 1, 'active'),
  47. (8, 42, 0, 'active'),
  48. (9, 43, 1, 'disabled'),
  49. (10, 0, 1, 'active')`,
  50. `INSERT INTO t_membership (membership_id, canonical_user_id, institution_id, campus_id, role, status) VALUES
  51. (1, '01HZZZZZZZZZZZZZZZZZZZZZZ1', 42, 7, 'finance', 'active'),
  52. (2, '01HZZZZZZZZZZZZZZZZZZZZZZ2', 42, 7, 'teacher', 'inactive'),
  53. (3, '01HZZZZZZZZZZZZZZZZZZZZZZ3', 43, 9, 'academic', 'active')`,
  54. }
  55. for _, s := range stmts {
  56. if err := db.Exec(s).Error; err != nil {
  57. t.Fatalf("初始化样例数据失败: %v\nSQL: %s", err, s)
  58. }
  59. }
  60. return db
  61. }
  62. // TestCampusReaderGetByID 校区读取(自营/加盟/停用/不存在/参数错)。
  63. func TestCampusReaderGetByID(t *testing.T) {
  64. db := newCampusDB(t)
  65. r := NewOrgCampusReader(db)
  66. ctx := context.Background()
  67. self, err := r.GetByID(ctx, 7)
  68. if err != nil {
  69. t.Fatalf("GetByID(7) 失败: %v", err)
  70. }
  71. if self.CampusID != 7 || self.SchoolID != 42 || !self.SelfSupport || !self.IsActive() {
  72. t.Fatalf("自营校区字段不符: %+v", self)
  73. }
  74. if self.InstitutionID() != 42 {
  75. t.Fatalf("InstitutionID() 应等于 SchoolID,实得 %d", self.InstitutionID())
  76. }
  77. franchise, err := r.GetByID(ctx, 8)
  78. if err != nil {
  79. t.Fatalf("GetByID(8) 失败: %v", err)
  80. }
  81. if franchise.SelfSupport {
  82. t.Fatal("campus 8 应判定为加盟(self_support=0)")
  83. }
  84. disabled, err := r.GetByID(ctx, 9)
  85. if err != nil {
  86. t.Fatalf("GetByID(9) 失败: %v", err)
  87. }
  88. if disabled.IsActive() {
  89. t.Fatal("campus 9 status=disabled 应判定为不可用")
  90. }
  91. if _, err := r.GetByID(ctx, 999); !stderrors.Is(err, apperr.ErrCampusNotFound) {
  92. t.Fatalf("不存在校区应返回 3104,实得 %v", err)
  93. }
  94. if _, err := r.GetByID(ctx, 0); !stderrors.Is(err, apperr.ErrBusinessParam) {
  95. t.Fatalf("campusID<=0 应返回 3140,实得 %v", err)
  96. }
  97. }
  98. // TestCampusReaderSchoolID 机构 ID 解析(含"校区存在但未挂机构"的异常分支)。
  99. func TestCampusReaderSchoolID(t *testing.T) {
  100. db := newCampusDB(t)
  101. r := NewOrgCampusReader(db)
  102. ctx := context.Background()
  103. id, err := r.GetSchoolID(ctx, 7)
  104. if err != nil || id != 42 {
  105. t.Fatalf("GetSchoolID(7) = (%d,%v),期望 (42,nil)", id, err)
  106. }
  107. if _, err := r.GetSchoolID(ctx, 10); !stderrors.Is(err, apperr.ErrCampusNotFound) {
  108. t.Fatalf("校区 institution_id=0 应返回 3104,实得 %v", err)
  109. }
  110. }
  111. // TestCampusReaderIsSelfSupport 自营判定(活动失效范围的关键输入)。
  112. func TestCampusReaderIsSelfSupport(t *testing.T) {
  113. db := newCampusDB(t)
  114. r := NewOrgCampusReader(db)
  115. ctx := context.Background()
  116. if ok, err := r.IsSelfSupport(ctx, 7); err != nil || !ok {
  117. t.Fatalf("IsSelfSupport(7) = (%v,%v),期望 (true,nil)", ok, err)
  118. }
  119. if ok, err := r.IsSelfSupport(ctx, 8); err != nil || ok {
  120. t.Fatalf("IsSelfSupport(8) = (%v,%v),期望 (false,nil)", ok, err)
  121. }
  122. }
  123. // TestCampusReaderGetByIDTx 同事务只读(TxCampusReader 扩展接口)。
  124. func TestCampusReaderGetByIDTx(t *testing.T) {
  125. db := newCampusDB(t)
  126. r := NewOrgCampusReader(db)
  127. ctx := context.Background()
  128. err := db.Transaction(func(tx *gorm.DB) error {
  129. info, err := r.GetByIDTx(ctx, tx, 8)
  130. if err != nil {
  131. return err
  132. }
  133. if info.CampusID != 8 {
  134. t.Fatalf("事务内读取 campus_id = %d,期望 8", info.CampusID)
  135. }
  136. return nil
  137. })
  138. if err != nil {
  139. t.Fatalf("事务内读取失败: %v", err)
  140. }
  141. // 无 DB / tx 均为空 → 装配错误(5001 兜底)
  142. empty := &OrgCampusReader{}
  143. if _, err := empty.GetByIDTx(ctx, nil, 7); !stderrors.Is(err, infraerrors.ErrInternal) {
  144. t.Fatalf("DB 未注入应返回 ErrInternal,实得 %v", err)
  145. }
  146. }
  147. // TestMembershipChecker 成员关系校验(角色过滤 / inactive 不计 / 作用域断言 1004)。
  148. func TestMembershipChecker(t *testing.T) {
  149. db := newCampusDB(t)
  150. c := NewOrgMembershipChecker(db)
  151. ctx := context.Background()
  152. ok, err := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7, "finance")
  153. if err != nil || !ok {
  154. t.Fatalf("finance 成员应命中,实得 (%v,%v)", ok, err)
  155. }
  156. if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7, "academic"); ok {
  157. t.Fatal("角色不匹配不应命中")
  158. }
  159. if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 43, 0, "finance"); ok {
  160. t.Fatal("跨机构不应命中")
  161. }
  162. // status=inactive 不授权
  163. if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ2", 42, 7); ok {
  164. t.Fatal("inactive 成员关系不应授权")
  165. }
  166. // 空 userID 直接 false(不打库)
  167. if ok, _ := c.HasMembership(ctx, "", 42, 7); ok {
  168. t.Fatal("空 userID 应返回 false")
  169. }
  170. if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7); err != nil {
  171. t.Fatalf("作用域内应通过,实得 %v", err)
  172. }
  173. if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 43, 0); !stderrors.Is(err, infraerrors.ErrUnscoped) {
  174. t.Fatalf("越权应返回 1004,实得 %v", err)
  175. }
  176. if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 0, 0); !stderrors.Is(err, infraerrors.ErrUnscoped) {
  177. t.Fatalf("缺少机构上下文应返回 1004,实得 %v", err)
  178. }
  179. }
  180. // fakeCampusReader 接口可替换性验证(Service 单测用 mock,不连库)。
  181. type fakeCampusReader struct {
  182. info *CampusInfo
  183. err error
  184. }
  185. // GetByID 返回预置结果。
  186. func (f *fakeCampusReader) GetByID(context.Context, int64) (*CampusInfo, error) { return f.info, f.err }
  187. // GetSchoolID 返回预置结果的机构 ID。
  188. func (f *fakeCampusReader) GetSchoolID(context.Context, int64) (int64, error) {
  189. if f.info == nil {
  190. return 0, f.err
  191. }
  192. return f.info.SchoolID, f.err
  193. }
  194. // IsSelfSupport 返回预置结果的自营标记。
  195. func (f *fakeCampusReader) IsSelfSupport(context.Context, int64) (bool, error) {
  196. if f.info == nil {
  197. return false, f.err
  198. }
  199. return f.info.SelfSupport, f.err
  200. }
  201. // TestCampusReaderMockable 接口在 mock 下可测(AC-02 验收④)。
  202. func TestCampusReaderMockable(t *testing.T) {
  203. var reader CampusReader = &fakeCampusReader{info: &CampusInfo{CampusID: 1, SchoolID: 2, SelfSupport: true, Status: "active"}}
  204. got, err := reader.GetByID(context.Background(), 1)
  205. if err != nil || got.SchoolID != 2 {
  206. t.Fatalf("mock 读取失败: (%+v,%v)", got, err)
  207. }
  208. if ok, _ := reader.IsSelfSupport(context.Background(), 1); !ok {
  209. t.Fatal("mock 自营标记应为 true")
  210. }
  211. }