fix(tests): 修复 sys.modules 污染导致 test_generated_video_management 间歇失败
Deploy / Staging E2E Tests (push) Failing after 107h15m3s
Deploy / Deploy Staging (push) Failing after 107h16m41s
CI/CD Pipeline / Frontend Lint (push) Failing after 107h17m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 107h17m18s
Deploy / Production Browser E2E (push) Failing after 1765h46m3s
Deploy / Deploy Production (push) Failing after 1765h46m5s
Deploy / Build Production Runtime Images (push) Failing after 1765h51m12s

根因: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
This commit is contained in:
灵应
2026-07-05 01:37:06 +08:00
parent bc111fe08a
commit a57a26cb67
13 changed files with 180 additions and 190 deletions
+28
View File
@@ -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")
@@ -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:
+50 -48
View File
@@ -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)
+12
View File
@@ -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)
+39 -39
View File
@@ -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 {
+5 -5
View File
@@ -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);
}
@@ -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))
@@ -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,
)
+2
View File
@@ -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))
+17
View File
@@ -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
# ---------------------------------------------------------------------------
+19 -98
View File
@@ -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()
+1
View File
@@ -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
+1
View File
@@ -105,6 +105,7 @@ class StubEditTemplateRepository:
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
is_admin: bool = True
@dataclass