317 lines
11 KiB
Python
317 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import JSON
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.api.ai_steward_api import routes_brief_blockers
|
|
from app.core.database import get_db
|
|
from app.dependencies.auth import get_current_user
|
|
from app.dependencies.tenant import get_tenant_id
|
|
from app.models.base import Base
|
|
from app.models.project import Project
|
|
from app.models.task import Task
|
|
from app.models.user import User
|
|
from app.models.user_project import UserProject
|
|
from app.models.user_workload import UserWorkload
|
|
from app.services.tenant_briefing_service import TenantBriefingService
|
|
|
|
|
|
def _use_sqlite_json_types() -> None:
|
|
User.__table__.c.assigned_domains.type = JSON()
|
|
Task.__table__.c.ai_recommended_assignees.type = JSON()
|
|
Task.__table__.c.ai_breakdown_payload.type = JSON()
|
|
Task.__table__.c.manual_overrides.type = JSON()
|
|
Task.__table__.c.required_skill_tag_ids.type = JSON()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def db_session():
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
_use_sqlite_json_types()
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(
|
|
lambda sync_conn: Base.metadata.create_all(
|
|
sync_conn,
|
|
tables=[
|
|
User.__table__,
|
|
Project.__table__,
|
|
UserProject.__table__,
|
|
UserWorkload.__table__,
|
|
],
|
|
)
|
|
)
|
|
maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
async with maker() as session:
|
|
yield session
|
|
await engine.dispose()
|
|
|
|
|
|
def _build_app(*, db_session, role: str = "ADMIN", user_id: int = 9001) -> FastAPI:
|
|
app = FastAPI()
|
|
app.include_router(routes_brief_blockers.router)
|
|
|
|
async def _fake_get_db():
|
|
yield db_session
|
|
|
|
app.dependency_overrides[get_db] = _fake_get_db
|
|
app.dependency_overrides[get_tenant_id] = lambda: 1
|
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
|
id=user_id,
|
|
tenant_id=1,
|
|
name="测试用户",
|
|
username="tester",
|
|
role=role,
|
|
)
|
|
return app
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_daily_briefing_v2_is_enveloped_for_any_authenticated_user(db_session, monkeypatch) -> None:
|
|
async def fake_build_viewer_payload(self, *, viewer_id: int, viewer_roles: list[str], mode: str): # noqa: ANN001
|
|
_ = self, viewer_id, viewer_roles
|
|
return {
|
|
"brief_date": "2026-03-30",
|
|
"mode": mode,
|
|
"viewer_scope": "personal",
|
|
"hero": {
|
|
"title": "我的每日简报",
|
|
"decision_count": 1,
|
|
"anomaly_count": 2,
|
|
"key_alerts": [],
|
|
"snapshot_state": "ready",
|
|
},
|
|
"sections": [],
|
|
"personal_summary": {
|
|
"headline": "你今天需要关注 1 项事项",
|
|
"action_count": 1,
|
|
},
|
|
"personal_workload": {
|
|
"user_id": 7,
|
|
"user_name": "开发甲",
|
|
"workload_ratio": 88,
|
|
},
|
|
"export_title": "我的每日简报",
|
|
}
|
|
|
|
monkeypatch.setattr(TenantBriefingService, "build_viewer_payload", fake_build_viewer_payload)
|
|
|
|
app = _build_app(db_session=db_session, role="BE", user_id=7)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/ai-steward/daily-briefing-v2", params={"mode": "live"})
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["code"] == 0
|
|
assert payload["message"] == "success"
|
|
assert payload["data"]["viewer_scope"] == "personal"
|
|
assert payload["data"]["mode"] == "live"
|
|
assert payload["data"]["export_title"] == "我的每日简报"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_briefing_service_returns_full_scope_for_manager(db_session, monkeypatch) -> None:
|
|
service = TenantBriefingService(db_session, tenant_id=1)
|
|
|
|
async def fake_get_tenant_payload(self, *, mode: str): # noqa: ANN001
|
|
_ = self
|
|
return {
|
|
"brief_date": "2026-03-30",
|
|
"mode": mode,
|
|
"hero": {
|
|
"title": "老板晨报中心",
|
|
"decision_count": 2,
|
|
"anomaly_count": 3,
|
|
"key_alerts": [],
|
|
"snapshot_state": "ready",
|
|
},
|
|
"sections": [
|
|
{
|
|
"key": "decisions",
|
|
"title": "今日需拍板",
|
|
"summary": "有 2 项事项需要处理。",
|
|
"metrics": [],
|
|
"items": [
|
|
{
|
|
"title": "项目 A 风险",
|
|
"detail": "需要关注",
|
|
"tone": "warning",
|
|
"project_id": 11,
|
|
"target": {"type": "project_board", "ref": "11"},
|
|
},
|
|
{
|
|
"title": "成员过载",
|
|
"detail": "需要协调",
|
|
"tone": "critical",
|
|
"user_id": 22,
|
|
"target": {"type": "workload_detail", "ref": "22"},
|
|
},
|
|
],
|
|
"drilldown_tab": "signals",
|
|
}
|
|
],
|
|
}
|
|
|
|
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
|
|
|
|
payload = await service.build_viewer_payload(viewer_id=1, viewer_roles=["BOSS"], mode="snapshot")
|
|
|
|
assert payload["viewer_scope"] == "tenant_full"
|
|
assert payload["hero"]["decision_count"] == 2
|
|
assert len(payload["sections"][0]["items"]) == 2
|
|
assert payload["sections"][0]["items"][0]["target"]["type"] == "project_board"
|
|
assert "全租户" in payload["export_title"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_briefing_service_filters_personal_scope_and_load(db_session, monkeypatch) -> None:
|
|
viewer = User(
|
|
tenant_id=1,
|
|
username="be_member",
|
|
email="be@example.com",
|
|
password_hash="hash",
|
|
name="后端成员",
|
|
role="BE",
|
|
status="active",
|
|
is_active=True,
|
|
)
|
|
db_session.add(viewer)
|
|
await db_session.flush()
|
|
|
|
db_session.add(UserProject(tenant_id=1, user_id=viewer.id, project_id=101))
|
|
db_session.add(
|
|
UserWorkload(
|
|
tenant_id=1,
|
|
user_id=viewer.id,
|
|
current_assigned_hours=28,
|
|
max_available_hours_per_week=40,
|
|
workload_ratio=70,
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
|
|
service = TenantBriefingService(db_session, tenant_id=1)
|
|
|
|
async def fake_get_tenant_payload(self, *, mode: str): # noqa: ANN001
|
|
_ = self
|
|
return {
|
|
"brief_date": "2026-03-30",
|
|
"mode": mode,
|
|
"hero": {
|
|
"title": "租户级简报",
|
|
"decision_count": 3,
|
|
"anomaly_count": 5,
|
|
"key_alerts": [],
|
|
"snapshot_state": "ready",
|
|
},
|
|
"sections": [
|
|
{
|
|
"key": "decisions",
|
|
"title": "今日需关注",
|
|
"summary": "租户汇总",
|
|
"metrics": [],
|
|
"items": [
|
|
{
|
|
"title": "项目 101 风险",
|
|
"detail": "与你项目相关",
|
|
"tone": "warning",
|
|
"project_id": 101,
|
|
"target": {"type": "project_board", "ref": "101"},
|
|
},
|
|
{
|
|
"title": "项目 202 风险",
|
|
"detail": "与他人项目相关",
|
|
"tone": "warning",
|
|
"project_id": 202,
|
|
"target": {"type": "project_board", "ref": "202"},
|
|
},
|
|
],
|
|
"drilldown_tab": "signals",
|
|
},
|
|
{
|
|
"key": "team_capacity",
|
|
"title": "负载与协作",
|
|
"summary": "租户汇总",
|
|
"metrics": [],
|
|
"items": [
|
|
{
|
|
"title": "后端成员",
|
|
"detail": "70% 负载",
|
|
"tone": "info",
|
|
"user_id": viewer.id,
|
|
"target": {"type": "workload_detail", "ref": str(viewer.id)},
|
|
},
|
|
{
|
|
"title": "其他成员",
|
|
"detail": "130% 负载",
|
|
"tone": "critical",
|
|
"user_id": 999,
|
|
"target": {"type": "workload_detail", "ref": "999"},
|
|
},
|
|
],
|
|
"drilldown_tab": "conflicts",
|
|
},
|
|
],
|
|
}
|
|
|
|
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
|
|
|
|
payload = await service.build_viewer_payload(viewer_id=int(viewer.id), viewer_roles=["BE"], mode="live")
|
|
|
|
assert payload["viewer_scope"] == "personal"
|
|
assert payload["hero"]["decision_count"] == 1
|
|
assert payload["sections"][0]["items"] == [
|
|
{
|
|
"title": "项目 101 风险",
|
|
"detail": "与你项目相关",
|
|
"tone": "warning",
|
|
"project_id": 101,
|
|
"target": {"type": "project_board", "ref": "101"},
|
|
}
|
|
]
|
|
assert payload["personal_workload"]["user_id"] == int(viewer.id)
|
|
assert payload["personal_workload"]["workload_ratio"] == 70
|
|
assert "我的每日简报" == payload["export_title"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_briefing_service_personal_workload_ratio_matches_weekly_hours(db_session) -> None:
|
|
viewer = User(
|
|
tenant_id=1,
|
|
username="be_member",
|
|
email="be@example.com",
|
|
password_hash="hash",
|
|
name="后端成员",
|
|
role="BE",
|
|
status="active",
|
|
is_active=True,
|
|
)
|
|
db_session.add(viewer)
|
|
await db_session.flush()
|
|
|
|
db_session.add(
|
|
UserWorkload(
|
|
tenant_id=1,
|
|
user_id=viewer.id,
|
|
current_assigned_hours=95.6,
|
|
max_available_hours_per_week=60,
|
|
workload_ratio=797,
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
|
|
service = TenantBriefingService(db_session, tenant_id=1)
|
|
workload = await service._get_viewer_workload(int(viewer.id))
|
|
|
|
assert workload["user_name"] == "后端成员"
|
|
assert workload["assigned_hours"] == 95.6
|
|
assert workload["max_weekly_hours"] == 60.0
|
|
assert workload["workload_ratio"] == 159
|
|
assert workload["tone"] == "critical"
|