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"