PMFlow/backend/tests/test_daily_briefing_v2_api.py

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 = "REVIEWER", 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="BUILDER", 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=["OWNER"], 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="BUILDER",
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=["BUILDER"], 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="BUILDER",
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"