// academic port 接口实现单测(org 只读适配器 + mock 可替换性)。 // // 测试栈:glebarez/sqlite 临时文件库,手工建 t_campus / t_membership 最小表结构 // (不依赖 org/model 的 AutoMigrate,验证适配器只看列名、不耦合 org 的 struct)。 package port import ( "context" stderrors "errors" "path/filepath" "testing" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // newCampusDB 建临时 sqlite 库并写入校区/成员样例数据。 func newCampusDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_port_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open 失败: %v", err) } // Windows 下连接未关闭会导致 t.TempDir 清理失败(unlinkat 占用),故注册清理钩子 t.Cleanup(func() { if sqlDB, e := db.DB(); e == nil { _ = sqlDB.Close() } }) stmts := []string{ `CREATE TABLE t_campus ( campus_id INTEGER PRIMARY KEY, institution_id INTEGER NOT NULL, self_support TINYINT NOT NULL DEFAULT 1, status TEXT NOT NULL )`, `CREATE TABLE t_membership ( membership_id INTEGER PRIMARY KEY, canonical_user_id VARCHAR(26) NOT NULL, institution_id INTEGER NOT NULL, campus_id INTEGER NOT NULL, role VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL )`, `INSERT INTO t_campus (campus_id, institution_id, self_support, status) VALUES (7, 42, 1, 'active'), (8, 42, 0, 'active'), (9, 43, 1, 'disabled'), (10, 0, 1, 'active')`, `INSERT INTO t_membership (membership_id, canonical_user_id, institution_id, campus_id, role, status) VALUES (1, '01HZZZZZZZZZZZZZZZZZZZZZZ1', 42, 7, 'finance', 'active'), (2, '01HZZZZZZZZZZZZZZZZZZZZZZ2', 42, 7, 'teacher', 'inactive'), (3, '01HZZZZZZZZZZZZZZZZZZZZZZ3', 43, 9, 'academic', 'active')`, } for _, s := range stmts { if err := db.Exec(s).Error; err != nil { t.Fatalf("初始化样例数据失败: %v\nSQL: %s", err, s) } } return db } // TestCampusReaderGetByID 校区读取(自营/加盟/停用/不存在/参数错)。 func TestCampusReaderGetByID(t *testing.T) { db := newCampusDB(t) r := NewOrgCampusReader(db) ctx := context.Background() self, err := r.GetByID(ctx, 7) if err != nil { t.Fatalf("GetByID(7) 失败: %v", err) } if self.CampusID != 7 || self.SchoolID != 42 || !self.SelfSupport || !self.IsActive() { t.Fatalf("自营校区字段不符: %+v", self) } if self.InstitutionID() != 42 { t.Fatalf("InstitutionID() 应等于 SchoolID,实得 %d", self.InstitutionID()) } franchise, err := r.GetByID(ctx, 8) if err != nil { t.Fatalf("GetByID(8) 失败: %v", err) } if franchise.SelfSupport { t.Fatal("campus 8 应判定为加盟(self_support=0)") } disabled, err := r.GetByID(ctx, 9) if err != nil { t.Fatalf("GetByID(9) 失败: %v", err) } if disabled.IsActive() { t.Fatal("campus 9 status=disabled 应判定为不可用") } if _, err := r.GetByID(ctx, 999); !stderrors.Is(err, apperr.ErrCampusNotFound) { t.Fatalf("不存在校区应返回 3104,实得 %v", err) } if _, err := r.GetByID(ctx, 0); !stderrors.Is(err, apperr.ErrBusinessParam) { t.Fatalf("campusID<=0 应返回 3140,实得 %v", err) } } // TestCampusReaderSchoolID 机构 ID 解析(含"校区存在但未挂机构"的异常分支)。 func TestCampusReaderSchoolID(t *testing.T) { db := newCampusDB(t) r := NewOrgCampusReader(db) ctx := context.Background() id, err := r.GetSchoolID(ctx, 7) if err != nil || id != 42 { t.Fatalf("GetSchoolID(7) = (%d,%v),期望 (42,nil)", id, err) } if _, err := r.GetSchoolID(ctx, 10); !stderrors.Is(err, apperr.ErrCampusNotFound) { t.Fatalf("校区 institution_id=0 应返回 3104,实得 %v", err) } } // TestCampusReaderIsSelfSupport 自营判定(活动失效范围的关键输入)。 func TestCampusReaderIsSelfSupport(t *testing.T) { db := newCampusDB(t) r := NewOrgCampusReader(db) ctx := context.Background() if ok, err := r.IsSelfSupport(ctx, 7); err != nil || !ok { t.Fatalf("IsSelfSupport(7) = (%v,%v),期望 (true,nil)", ok, err) } if ok, err := r.IsSelfSupport(ctx, 8); err != nil || ok { t.Fatalf("IsSelfSupport(8) = (%v,%v),期望 (false,nil)", ok, err) } } // TestCampusReaderGetByIDTx 同事务只读(TxCampusReader 扩展接口)。 func TestCampusReaderGetByIDTx(t *testing.T) { db := newCampusDB(t) r := NewOrgCampusReader(db) ctx := context.Background() err := db.Transaction(func(tx *gorm.DB) error { info, err := r.GetByIDTx(ctx, tx, 8) if err != nil { return err } if info.CampusID != 8 { t.Fatalf("事务内读取 campus_id = %d,期望 8", info.CampusID) } return nil }) if err != nil { t.Fatalf("事务内读取失败: %v", err) } // 无 DB / tx 均为空 → 装配错误(5001 兜底) empty := &OrgCampusReader{} if _, err := empty.GetByIDTx(ctx, nil, 7); !stderrors.Is(err, infraerrors.ErrInternal) { t.Fatalf("DB 未注入应返回 ErrInternal,实得 %v", err) } } // TestMembershipChecker 成员关系校验(角色过滤 / inactive 不计 / 作用域断言 1004)。 func TestMembershipChecker(t *testing.T) { db := newCampusDB(t) c := NewOrgMembershipChecker(db) ctx := context.Background() ok, err := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7, "finance") if err != nil || !ok { t.Fatalf("finance 成员应命中,实得 (%v,%v)", ok, err) } if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7, "academic"); ok { t.Fatal("角色不匹配不应命中") } if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 43, 0, "finance"); ok { t.Fatal("跨机构不应命中") } // status=inactive 不授权 if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ2", 42, 7); ok { t.Fatal("inactive 成员关系不应授权") } // 空 userID 直接 false(不打库) if ok, _ := c.HasMembership(ctx, "", 42, 7); ok { t.Fatal("空 userID 应返回 false") } if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7); err != nil { t.Fatalf("作用域内应通过,实得 %v", err) } if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 43, 0); !stderrors.Is(err, infraerrors.ErrUnscoped) { t.Fatalf("越权应返回 1004,实得 %v", err) } if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 0, 0); !stderrors.Is(err, infraerrors.ErrUnscoped) { t.Fatalf("缺少机构上下文应返回 1004,实得 %v", err) } } // fakeCampusReader 接口可替换性验证(Service 单测用 mock,不连库)。 type fakeCampusReader struct { info *CampusInfo err error } // GetByID 返回预置结果。 func (f *fakeCampusReader) GetByID(context.Context, int64) (*CampusInfo, error) { return f.info, f.err } // GetSchoolID 返回预置结果的机构 ID。 func (f *fakeCampusReader) GetSchoolID(context.Context, int64) (int64, error) { if f.info == nil { return 0, f.err } return f.info.SchoolID, f.err } // IsSelfSupport 返回预置结果的自营标记。 func (f *fakeCampusReader) IsSelfSupport(context.Context, int64) (bool, error) { if f.info == nil { return false, f.err } return f.info.SelfSupport, f.err } // TestCampusReaderMockable 接口在 mock 下可测(AC-02 验收④)。 func TestCampusReaderMockable(t *testing.T) { var reader CampusReader = &fakeCampusReader{info: &CampusInfo{CampusID: 1, SchoolID: 2, SelfSupport: true, Status: "active"}} got, err := reader.GetByID(context.Background(), 1) if err != nil || got.SchoolID != 2 { t.Fatalf("mock 读取失败: (%+v,%v)", got, err) } if ok, _ := reader.IsSelfSupport(context.Background(), 1); !ok { t.Fatal("mock 自营标记应为 true") } }