| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- // 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")
- }
- }
|