From a57a26cb67458fc8ab16d83d706c6df42ce1552b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sun, 5 Jul 2026 01:37:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(tests):=20=E4=BF=AE=E5=A4=8D=20sys.modules?= =?UTF-8?q?=20=E6=B1=A1=E6=9F=93=E5=AF=BC=E8=87=B4=20test=5Fgenerated=5Fvi?= =?UTF-8?q?deo=5Fmanagement=20=E9=97=B4=E6=AD=87=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:test_subscription_api._install_mocks() 在模块导入时替换了 packages.domain / packages.ports 等命名空间包,导致后续测试文件 无法导入真实的 GeneratedVideo 等实体类。 修复:移除所有 packages.* 命名空间包的 fake module 创建,仅保留 必要的叶子 mock(redis/smtp 适配器、app.config/auth/dependencies)。 让 packages.* 模块从磁盘正常加载。 同时包含: - P2: asset_diagnosis 单素材诊断支持 - P2: edit_plans/edit_templates 管理员权限校验 - P2: edit_plans project 归属鉴权 - P3: test_error_scenarios 限流计数器重置 - P3: test_asset_diagnosis/test_edit_templates 测试修复 - P3: Alembic 迁移 024_add_user_is_admin --- alembic/versions/024_add_user_is_admin.py | 28 +++++ apps/api/app/api/routes/asset_diagnosis.py | 2 + apps/api/app/api/routes/edit_plans.py | 98 ++++++++------- apps/api/app/api/routes/edit_templates.py | 12 ++ apps/web/src/pages/dashboard/dashboard.css | 78 ++++++------ apps/web/src/pages/voices/voices.css | 10 +- packages/adapters/sqlalchemy_impl/models.py | 2 + .../sqlalchemy_impl/user_repository.py | 2 + packages/domain/entities.py | 2 + tests/integration/test_error_scenarios.py | 17 +++ tests/integration/test_subscription_api.py | 117 +++--------------- tests/unit/test_asset_diagnosis.py | 1 + tests/unit/test_edit_templates_api.py | 1 + 13 files changed, 180 insertions(+), 190 deletions(-) create mode 100644 alembic/versions/024_add_user_is_admin.py diff --git a/alembic/versions/024_add_user_is_admin.py b/alembic/versions/024_add_user_is_admin.py new file mode 100644 index 000000000..1fd571735 --- /dev/null +++ b/alembic/versions/024_add_user_is_admin.py @@ -0,0 +1,28 @@ +"""Task: Add is_admin to users + +Revision ID: 024 +Revises: 023 +Create Date: 2026-07-05 + +新增 is_admin 字段到 users 表,用于模板管理等管理员权限校验。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "024" +down_revision = "023" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + +def downgrade() -> None: + op.drop_column("users", "is_admin") diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py index 3e107eb37..f005a1b63 100644 --- a/apps/api/app/api/routes/asset_diagnosis.py +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -321,6 +321,8 @@ def get_project_asset_diagnosis( project = project_repository.find_by_id(project_id) if project is None: raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(authenticated_user.user.id): + raise HTTPException(status_code=403, detail="Access denied to project") # 单素材诊断模式 if asset_id: diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 9d82f2d1c..ee4b3350c 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -39,6 +39,9 @@ from packages.application.generation_tasks import ( from packages.domain.config_schemas import normalize_plan_config from packages.domain.edit_plan import EditPlan, EditPlanStatus +# AI 推荐 & 封面生成(轻量模块,无 Celery 依赖,可安全静态导入) +from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend, run_generate_cover + logger = logging.getLogger(__name__) router = APIRouter() @@ -196,7 +199,7 @@ class GenerateCoverResponse(BaseModel): def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None: """校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)""" - if not project_id: + if not project_id or not project_id.strip(): return project = project_repository.find_by_id(project_id) if project is None: @@ -241,6 +244,10 @@ def list_plans( """获取剪辑计划列表(支持分页、按模板/状态/项目筛选)""" svc = EditPlanService(db) + # 空串 project_id 视为未传(避免 DB 中匹配到空串记录) + if project_id is not None and not project_id.strip(): + project_id = None + # 解析状态筛选 status_enum: Optional[EditPlanStatus] = None if status_filter: @@ -308,9 +315,11 @@ def create_plan( project_repository: Any = Depends(get_project_repository), ) -> EditPlanResponse: """创建剪辑计划""" + # 空串 project_id 统一为 "" + project_id = (body.project_id or "").strip() # 项目鉴权 - if body.project_id: - _check_project_access(body.project_id, current_user.user.id, project_repository) + if project_id: + _check_project_access(project_id, current_user.user.id, project_repository) svc = EditPlanService(db) # 标准化 config,填充 cover/title/subtitle/bgm 默认值 normalized_config = normalize_plan_config(body.config) @@ -320,7 +329,7 @@ def create_plan( name=body.name, config=normalized_config, total_duration=body.total_duration, - project_id=body.project_id, + project_id=project_id, created_by_user_id=current_user.user.id, ) except ValueError as exc: @@ -654,15 +663,6 @@ def ai_recommend_clips( ) # 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步) - try: - from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend - except ImportError: - logger.error("ai_tasks 模块不可用,无法执行 AI 推荐") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="AI 推荐服务暂不可用", - ) - result = run_ai_recommend( plan_id=plan_id, template_id=plan.template_id, @@ -671,32 +671,45 @@ def ai_recommend_clips( target_duration=body.target_duration, ) - # 清除现有片段 - svc.delete_all_clips(plan_id) + # ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ── + # TODO: 当前各 repo 方法内部 commit(),无法真正回滚。 + # 后续重构 repo 为 flush() 模式后,此处改为统一 commit。 + try: + # 清除现有片段 + svc.delete_all_clips(plan_id) - # 按推荐方案创建新片段 - for clip_data in result["clips"]: - svc.create_clip( - plan_id=plan_id, - clip_type=clip_data["clip_type"], - order=clip_data["order"], - text_content=clip_data.get("text_content", ""), - duration=clip_data["duration"], - transition_effect=clip_data.get("transition_effect", "cut"), - asset_id=clip_data.get("asset_id", ""), - start_time=clip_data.get("start_time", 0.0), - config=clip_data.get("config", {}), + # 按推荐方案创建新片段 + for clip_data in result["clips"]: + svc.create_clip( + plan_id=plan_id, + clip_type=clip_data["clip_type"], + order=clip_data["order"], + text_content=clip_data.get("text_content", ""), + duration=clip_data["duration"], + transition_effect=clip_data.get("transition_effect", "cut"), + asset_id=clip_data.get("asset_id", ""), + start_time=clip_data.get("start_time", 0.0), + config=clip_data.get("config", {}), + ) + + # 更新计划 config 和 total_duration + normalized_config = normalize_plan_config(result.get("config", {})) + svc.update_plan( + plan_id, + config=normalized_config, + total_duration=result["total_duration"], + ) + except Exception as exc: + logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id) + # 尝试回滚未提交的变更 + try: + db.rollback() + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"AI 推荐结果写入失败: {exc}", ) - - # 更新计划 config 和 total_duration - from packages.domain.config_schemas import normalize_plan_config - - normalized_config = normalize_plan_config(result.get("config", {})) - svc.update_plan( - plan_id, - config=normalized_config, - total_duration=result["total_duration"], - ) logger.info( "AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s", @@ -770,15 +783,6 @@ def generate_cover( _check_project_access(plan.project_id, current_user.user.id, project_repository) # 调用 AI 封面生成服务 - try: - from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover - except ImportError: - logger.error("ai_tasks 模块不可用,无法生成封面") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="AI 封面生成服务暂不可用", - ) - cover_data = run_generate_cover( plan_id=plan_id, asset_ids=body.asset_ids, @@ -787,8 +791,6 @@ def generate_cover( ) # 更新 plan.config["cover"] - from packages.domain.config_schemas import normalize_plan_config - current_config = dict(plan.config) current_config["cover"] = cover_data normalized = normalize_plan_config(current_config) diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py index e0674531e..108cc3d8e 100644 --- a/apps/api/app/api/routes/edit_templates.py +++ b/apps/api/app/api/routes/edit_templates.py @@ -86,6 +86,15 @@ class EditTemplateListResponse(BaseModel): # ── Helpers ─────────────────────────────────────────────────────────────────── +def _require_admin(current_user: AuthenticatedUser) -> None: + """校验当前用户是否为管理员,非管理员返回 403""" + if not getattr(current_user.user, "is_admin", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅管理员可执行此操作", + ) + + def _to_response(t: EditTemplate) -> EditTemplateResponse: return EditTemplateResponse( id=t.id, @@ -176,6 +185,7 @@ def create_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """创建模板(管理员)""" + _require_admin(current_user) svc = EditTemplateService(db) # 标准化 config,填充 cover/title/subtitle/bgm 默认值 normalized_config = normalize_template_config(body.config) @@ -205,6 +215,7 @@ def update_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """更新模板""" + _require_admin(current_user) svc = EditTemplateService(db) # 解析状态 @@ -254,6 +265,7 @@ def delete_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> None: """删除模板(软删除 → 设为 inactive)""" + _require_admin(current_user) svc = EditTemplateService(db) try: svc.deactivate_template(template_id) diff --git a/apps/web/src/pages/dashboard/dashboard.css b/apps/web/src/pages/dashboard/dashboard.css index a0224b816..7c6aeef43 100644 --- a/apps/web/src/pages/dashboard/dashboard.css +++ b/apps/web/src/pages/dashboard/dashboard.css @@ -21,7 +21,7 @@ } .xx-dashboard-welcome h2 { - margin: 0 0 4px; + margin: 0 0 var(--space-xs); font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--text-primary); @@ -51,7 +51,7 @@ ); border: 1px solid var(--border-color); border-radius: var(--radius-lg); - padding: 20px; + padding: var(--space-lg); transition: var(--transition-all); position: relative; overflow: hidden; @@ -81,8 +81,8 @@ display: flex; align-items: center; justify-content: center; - font-size: 20px; - margin-bottom: 12px; + font-size: var(--font-size-lg); + margin-bottom: var(--space-sm); } .xx-kpi-value { @@ -90,17 +90,17 @@ font-weight: 800; color: var(--text-primary); line-height: 1.2; - margin-bottom: 4px; + margin-bottom: var(--space-xs); } .xx-kpi-label { - font-size: 13px; + font-size: var(--font-size-sm); color: var(--text-secondary); - margin-bottom: 8px; + margin-bottom: var(--space-sm); } .xx-kpi-trend { - font-size: 12px; + font-size: var(--font-size-xs); display: inline-flex; align-items: center; gap: 4px; @@ -181,17 +181,17 @@ .xx-task-list { display: flex; flex-direction: column; - gap: 10px; + gap: var(--space-sm); } .xx-task-item { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-md); - padding: 14px 16px; + padding: var(--space-sm) var(--space-md); display: grid; grid-template-columns: 1fr auto auto auto; - gap: 16px; + gap: var(--space-md); align-items: center; transition: var(--transition-all); } @@ -202,14 +202,14 @@ } .xx-task-info h4 { - margin: 0 0 4px; - font-size: 14px; + margin: 0 0 var(--space-xs); + font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); } .xx-task-info span { - font-size: 12px; + font-size: var(--font-size-xs); color: var(--text-secondary); } @@ -219,7 +219,7 @@ gap: 4px; padding: 3px 10px; border-radius: var(--radius-sm); - font-size: 12px; + font-size: var(--font-size-xs); font-weight: 500; white-space: nowrap; } @@ -250,7 +250,7 @@ .xx-task-time { text-align: right; - font-size: 12px; + font-size: var(--font-size-xs); color: var(--text-secondary); min-width: 100px; } @@ -261,7 +261,7 @@ } .xx-task-action { - font-size: 12px; + font-size: var(--font-size-xs); } /* ============================================================ @@ -274,7 +274,7 @@ .xx-chart-bars { display: flex; align-items: flex-end; - gap: 8px; + gap: var(--space-sm); height: 160px; padding-top: var(--space-sm); } @@ -308,7 +308,7 @@ top: -20px; left: 50%; transform: translateX(-50%); - font-size: 11px; + font-size: var(--font-size-xs); font-weight: 600; color: var(--text-primary); white-space: nowrap; @@ -322,14 +322,14 @@ .xx-chart-labels { display: flex; - gap: 8px; - margin-top: 8px; + gap: var(--space-sm); + margin-top: var(--space-sm); } .xx-chart-label { flex: 1; text-align: center; - font-size: 11px; + font-size: var(--font-size-xs); color: var(--text-secondary); } @@ -347,7 +347,7 @@ background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); - padding: 20px; + padding: var(--space-lg); cursor: pointer; transition: var(--transition-all); text-align: center; @@ -366,14 +366,14 @@ display: flex; align-items: center; justify-content: center; - font-size: 24px; - margin: 0 auto 14px; + font-size: var(--font-size-xl); + margin: 0 auto var(--space-md); color: #fff; } .xx-quick-card h3 { - margin: 0 0 8px; - font-size: 16px; + margin: 0 0 var(--space-sm); + font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); color: var(--text-primary); } @@ -381,7 +381,7 @@ .xx-quick-card p { margin: 0; color: var(--text-secondary); - font-size: 13px; + font-size: var(--font-size-sm); } /* ============================================================ @@ -390,13 +390,13 @@ .xx-announcement-list { display: flex; flex-direction: column; - gap: 12px; + gap: var(--space-sm); } .xx-announcement-item { display: flex; - gap: 12px; - padding: 12px; + gap: var(--space-sm); + padding: var(--space-sm); border-radius: var(--radius-md); background: var(--bg-secondary, #f8fafc); border: 1px solid var(--border-color); @@ -412,7 +412,7 @@ flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); - font-size: 11px; + font-size: var(--font-size-xs); font-weight: 500; height: fit-content; } @@ -438,15 +438,15 @@ } .xx-announcement-content h4 { - margin: 0 0 4px; - font-size: 13px; + margin: 0 0 var(--space-xs); + font-size: var(--font-size-sm); font-weight: 500; color: var(--text-primary); line-height: 1.4; } .xx-announcement-content time { - font-size: 11px; + font-size: var(--font-size-xs); color: var(--text-secondary); } @@ -454,7 +454,7 @@ 存储用量条 ============================================================ */ .xx-storage-bar { - margin-top: 12px; + margin-top: var(--space-sm); } .xx-storage-bar-track { @@ -474,9 +474,9 @@ .xx-storage-bar-label { display: flex; justify-content: space-between; - font-size: 11px; + font-size: var(--font-size-xs); color: var(--text-secondary); - margin-top: 4px; + margin-top: var(--space-xs); } /* ============================================================ @@ -511,7 +511,7 @@ .xx-task-item { grid-template-columns: 1fr; - gap: 8px; + gap: var(--space-sm); } .xx-task-time { diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index 8d813e616..34771f4ba 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -130,7 +130,7 @@ display: grid; place-items: center; color: var(--text-inverse); - font-size: 20px; + font-size: var(--font-size-lg); flex-shrink: 0; grid-row: 1 / 3; transition: var(--transition-all); @@ -385,7 +385,7 @@ } .xx-voices-empty-icon { - font-size: 56px; + font-size: var(--font-size-3xl); margin-bottom: var(--space-lg); opacity: 0.3; color: var(--text-tertiary); @@ -472,7 +472,7 @@ border-radius: var(--radius-sm); background: var(--bg-tertiary); color: var(--text-secondary); - font-size: 13px; + font-size: var(--font-size-sm); cursor: pointer; transition: var(--transition-all); } @@ -507,7 +507,7 @@ display: grid; place-items: center; color: var(--text-inverse); - font-size: 18px; + font-size: var(--font-size-md); flex-shrink: 0; } @@ -794,7 +794,7 @@ border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); - font-size: 16px; + font-size: var(--font-size-md); cursor: pointer; transition: var(--transition-all); } diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index fc8c8f564..94aaff0f4 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -28,6 +28,8 @@ class UserModel(Base): max_projects = Column(Integer, nullable=False, default=3) max_storage_gb = Column(Integer, nullable=False, default=10) used_storage_gb = Column(Integer, nullable=False, default=0) + # 管理员标识 + is_admin = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/adapters/sqlalchemy_impl/user_repository.py b/packages/adapters/sqlalchemy_impl/user_repository.py index ff44c9277..a5076583c 100644 --- a/packages/adapters/sqlalchemy_impl/user_repository.py +++ b/packages/adapters/sqlalchemy_impl/user_repository.py @@ -32,6 +32,7 @@ class SQLAlchemyUserRepository(UserRepository): model.subscription_expires_at = user.subscription_expires_at model.max_projects = user.max_projects model.max_storage_gb = user.max_storage_gb + model.is_admin = user.is_admin model.created_at = user.created_at self.session.commit() @@ -85,5 +86,6 @@ class SQLAlchemyUserRepository(UserRepository): subscription_expires_at=model.subscription_expires_at, max_projects=model.max_projects or 3, max_storage_gb=model.max_storage_gb or 10, + is_admin=model.is_admin or False, created_at=model.created_at, ) diff --git a/packages/domain/entities.py b/packages/domain/entities.py index d7c67e26a..e4fa37ae0 100644 --- a/packages/domain/entities.py +++ b/packages/domain/entities.py @@ -52,6 +52,8 @@ class User: max_projects: int = 3 # free: 3, pro: unlimited, enterprise: unlimited max_storage_gb: int = 10 # free: 10, pro: 100, enterprise: 1000 used_storage_gb: float = 0.0 + # 管理员标识 + is_admin: bool = False created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/tests/integration/test_error_scenarios.py b/tests/integration/test_error_scenarios.py index 33e7cf56b..4f8bc06cf 100755 --- a/tests/integration/test_error_scenarios.py +++ b/tests/integration/test_error_scenarios.py @@ -37,6 +37,23 @@ from apps.api.main import app client = TestClient(app) +def _reset_rate_limiter(): + """重置限流中间件状态,避免测试间互相影响。""" + node = getattr(app, "middleware_stack", None) + while node is not None: + if hasattr(node, "requests"): + node.requests.clear() + break + node = getattr(node, "app", None) + + +@pytest.fixture(autouse=True) +def _clear_rate_limit_between_tests(): + """每个测试前清空限流计数器。""" + _reset_rate_limiter() + yield + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- diff --git a/tests/integration/test_subscription_api.py b/tests/integration/test_subscription_api.py index d94d3f47b..b74045fc0 100644 --- a/tests/integration/test_subscription_api.py +++ b/tests/integration/test_subscription_api.py @@ -18,6 +18,7 @@ import sys import types from dataclasses import dataclass, field from datetime import datetime, timezone +from pathlib import Path from typing import Optional from unittest.mock import MagicMock @@ -31,110 +32,27 @@ from fastapi.testclient import TestClient def _install_mocks(): - """在 sys.modules 中安装所有必需的 mock 模块,使 subscription.py 可导入。""" + """在 sys.modules 中安装所有必需的 mock 模块,使 subscription.py 可导入。 - # ---------- packages.domain.entities ---------- - @dataclass(slots=True) - class User: - id: str = "user-001" - email: str = "test@example.com" - display_name: str = "Test User" - username: str = "testuser" - password_hash: str = "" - email_verified: bool = False - email_verification_token: str | None = None - password_reset_token: str | None = None - password_reset_expires_at: datetime | None = None - last_login_at: datetime | None = None - last_login_ip: str | None = None - subscription_plan: str = "free" - subscription_status: str = "active" - subscription_expires_at: datetime | None = None - max_projects: int = 3 - max_storage_gb: int = 10 - used_storage_gb: float = 0.0 - created_at: datetime = field(default_factory=lambda: datetime(2026, 1, 1, tzinfo=timezone.utc)) + 注意:不 mock packages.* 命名空间包(packages.domain / packages.ports / + packages.adapters 等),只 mock 必要的叶子模块,避免阻断其他测试文件 + 对真实 packages.* 子模块的导入。 + """ - entities_mod = types.ModuleType("packages.domain.entities") - entities_mod.User = User + # ---------- packages.adapters 叶子 mock ---------- + # 仅 mock redis / smtp 适配器(subscription 路由间接依赖), + # 不创建 packages.adapters 命名包——让 Python 使用磁盘上的真实包。 + for leaf_name in ["packages.adapters.redis", "packages.adapters.smtp"]: + if leaf_name not in sys.modules: + mod = types.ModuleType(leaf_name) + sys.modules[leaf_name] = mod - # ---------- packages.ports.user_repository ---------- - class UserRepository: - def save(self, user): - pass - - def find_by_id(self, user_id): - return None - - def find_by_email(self, email): - return None - - def find_by_username(self, username): - return None - - def find_by_verification_token(self, token): - return None - - def find_by_password_reset_token(self, token): - return None - - def delete(self, user_id): - return True - - user_repo_mod = types.ModuleType("packages.ports.user_repository") - user_repo_mod.UserRepository = UserRepository - - # ---------- packages (namespace) ---------- - for name in [ - "packages", - "packages.domain", - "packages.ports", - "packages.adapters", - "packages.adapters.sqlalchemy_impl", - "packages.adapters.sqlalchemy_impl.user_repository", - "packages.adapters.sqlalchemy_impl.session", - "packages.adapters.redis", - "packages.adapters.smtp", - "packages.application", - ]: - if name not in sys.modules: - sys.modules[name] = types.ModuleType(name) - - sys.modules["packages.domain.entities"] = entities_mod - sys.modules["packages.ports.user_repository"] = user_repo_mod - sys.modules["packages.adapters.sqlalchemy_impl.user_repository"].SQLAlchemyUserRepository = MagicMock - sys.modules["packages.adapters.sqlalchemy_impl.session"].build_session_factory = MagicMock( - return_value=(MagicMock(), MagicMock()) - ) sys.modules["packages.adapters.redis"].NoopSessionStore = MagicMock sys.modules["packages.adapters.redis"].SessionStore = MagicMock sys.modules["packages.adapters.smtp"].EmailConfig = MagicMock sys.modules["packages.adapters.smtp"].NoopEmailService = MagicMock sys.modules["packages.adapters.smtp"].get_email_service = MagicMock() - # Stub 其他 repository ports(dependencies.py 会 import 它们) - for port_name in [ - "asset_repository", - "asset_library_repository", - "classification_job_repository", - "duplication_repository", - "generated_video_repository", - "generation_task_repository", - "title_library_repository", - "voice_library_repository", - "ingest_job_repository", - "project_repository", - ]: - mod = types.ModuleType(f"packages.ports.{port_name}") - # 动态创建一个 Mock repository class - class_name = port_name.replace("_", " ").title().replace(" ", "") + "Port" - setattr(mod, "".join(w.capitalize() for w in port_name.split("_")), MagicMock) - sys.modules[f"packages.ports.{port_name}"] = mod - - sa_mod = types.ModuleType(f"packages.adapters.sqlalchemy_impl.{port_name}") - setattr(sa_mod, f"SQLAlchemy{''.join(w.capitalize() for w in port_name.split('_'))}", MagicMock) - sys.modules[f"packages.adapters.sqlalchemy_impl.{port_name}"] = sa_mod - # ---------- app.config ---------- config_mod = types.ModuleType("app.config") @@ -156,15 +74,18 @@ def _install_mocks(): config_mod.get_settings = lambda: _Settings() sys.modules["app.config"] = config_mod + # 使用真实的 User 实体(packages.domain.entities 无重依赖) + from packages.domain.entities import User as _RealUser + # ---------- app.auth ---------- @dataclass(frozen=True, slots=True) class AuthenticatedUser: - user: User + user: _RealUser session_id: str | None = None token_type: str | None = None async def _mock_get_current_user(): - return AuthenticatedUser(user=User()) + return AuthenticatedUser(user=_RealUser()) auth_mod = types.ModuleType("app.auth") auth_mod.AuthenticatedUser = AuthenticatedUser @@ -264,7 +185,7 @@ def _install_mocks(): except Exception: pass # 如果已经导入过,跳过 - return User, AuthenticatedUser + return _RealUser, AuthenticatedUser User, AuthenticatedUser = _install_mocks() diff --git a/tests/unit/test_asset_diagnosis.py b/tests/unit/test_asset_diagnosis.py index ec40c1a8a..270e84be8 100644 --- a/tests/unit/test_asset_diagnosis.py +++ b/tests/unit/test_asset_diagnosis.py @@ -153,6 +153,7 @@ def _build_route_test_app(project_repo, library_repo, asset_repo): mock_user = MagicMock(spec=AuthenticatedUser) mock_user.id = "user-1" mock_user.email = "test@example.com" + mock_user.user.id = "user-1" app.dependency_overrides[get_current_user] = lambda: mock_user app.dependency_overrides[_dep("get_project_repository")] = lambda: project_repo app.dependency_overrides[_dep("get_asset_library_repository")] = lambda: library_repo diff --git a/tests/unit/test_edit_templates_api.py b/tests/unit/test_edit_templates_api.py index 270392623..1537ab73b 100644 --- a/tests/unit/test_edit_templates_api.py +++ b/tests/unit/test_edit_templates_api.py @@ -105,6 +105,7 @@ class StubEditTemplateRepository: class FakeUser: id: str = "user-001" email: str = "test@example.com" + is_admin: bool = True @dataclass