from __future__ import annotations import os from datetime import timedelta from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON, func, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test") from app.api import pmflow_launch_api, pmflow_os_api # noqa: E402 from app.core.database import get_db # noqa: E402 from app.dependencies.auth import get_current_user # noqa: E402 from app.models import ( # noqa: E402 DevWorkspaceBinding, FeedbackIngest, FeishuItem, GitHubWebhookDelivery, IterationRecommendation, ModelCatalogItem, ModelEvalRun, ModelRecommendationSnapshot, PMFlowApproval, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, OperationLog, PMFlowLaunchDraft, PMFlowThread, PMFlowThreadMessage, ProgressEvent, Project, ProjectModelBinding, PromptPackage, ReleaseValidationRun, RepoBinding, RevenueEvent, ROISnapshot, SemanticFact, Signal, Task, TaskCodeLink, TruthNode, User, UserProject, ) from app.models.base import Base # noqa: E402 from app.services import pmflow_launch_service # noqa: E402 from app.utils.timezone import now_cn_naive # noqa: E402 TABLES = [ User.__table__, FeishuItem.__table__, Signal.__table__, Project.__table__, UserProject.__table__, PMFlowLaunchDraft.__table__, PMFlowThread.__table__, PMFlowThreadMessage.__table__, PMFlowChangeSet.__table__, PMFlowChangeItem.__table__, PMFlowApproval.__table__, PMFlowEvidenceLink.__table__, TruthNode.__table__, Task.__table__, SemanticFact.__table__, ModelCatalogItem.__table__, ProjectModelBinding.__table__, ModelRecommendationSnapshot.__table__, ModelEvalRun.__table__, PromptPackage.__table__, DevWorkspaceBinding.__table__, RepoBinding.__table__, TaskCodeLink.__table__, ProgressEvent.__table__, ReleaseValidationRun.__table__, FeedbackIngest.__table__, IterationRecommendation.__table__, RevenueEvent.__table__, ROISnapshot.__table__, GitHubWebhookDelivery.__table__, OperationLog.__table__, ] def _use_sqlite_json_types() -> None: for table in TABLES: for column in table.columns: type_name = column.type.__class__.__name__.upper() if type_name in {"JSONB", "ARRAY"}: column.type = JSON() @pytest_asyncio.fixture async def db_env(): 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=TABLES)) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() async def _seed_user(session, *, tenant_id: int = 1, username: str = "owner"): user = User( tenant_id=tenant_id, username=f"{username}-{tenant_id}", email=f"{username}-{tenant_id}@example.com", password_hash="hashed", name="Owner", role="MEMBER", assigned_domains=[], workload=0, status="active", is_active=True, ) session.add(user) await session.commit() return user def _build_app(session, current_user) -> FastAPI: app = FastAPI() app.include_router(pmflow_launch_api.router) app.include_router(pmflow_os_api.router) async def _fake_get_db(): yield session app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_current_user] = lambda: current_user return app def _draft_payload(**overrides): payload = { "title": "AI 会议纪要助手", "one_liner": "自动整理会议纪要并生成待办。", "target_user": "个人开发者、小团队负责人", "problem": "会议信息散落,行动项难追踪。", "solution": "解析会议文本并转成项目任务和风险。", "requirements": [ {"title": "导入会议文本", "summary": "支持粘贴会议纪要", "acceptance_criteria": ["能保存输入"]}, {"title": "提取行动项", "summary": "识别待办和负责人", "acceptance_criteria": ["输出任务列表"]}, {"title": "风险提醒", "summary": "识别不确定项", "acceptance_criteria": ["输出风险"]}, ], "tasks": [ {"title": "设计输入流程", "summary": "完成启动台输入", "group": "product", "priority": "medium", "requirement_index": 0}, {"title": "实现文本解析", "summary": "调用结构化服务", "group": "engineering", "priority": "high", "requirement_index": 1}, {"title": "补充失败测试", "summary": "覆盖失败态", "group": "qa", "priority": "medium", "requirement_index": 2}, ], "risks": [ {"title": "结构化质量风险", "summary": "会议内容过短会影响提取", "level": "medium", "mitigation": "提示补充上下文"}, {"title": "隐私风险", "summary": "会议内容可能包含敏感信息", "level": "high", "mitigation": "明确权限边界"}, ], "decisions": [ {"title": "先做文本输入", "reason": "首版不接录音和日历", "owner": "项目负责人"}, ], "evidence": [{"source": "user_input", "excerpt": "我想做一个 AI 会议纪要工具"}], "confidence": 0.82, "warnings": [], } payload.update(overrides) return payload def _patch_llm(monkeypatch, payload): async def _fake_generate_structured_object(*args, **kwargs): return SimpleNamespace(payload=payload, raw_text="{}", provider="test", model="test") monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object) @pytest.mark.asyncio async def test_create_launch_draft_success_persists_draft(db_env, monkeypatch) -> None: session = db_env user = await _seed_user(session) _patch_llm(monkeypatch, _draft_payload()) app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具"}) assert response.status_code == 200 payload = response.json()["data"] assert payload["draft_id"].startswith("draft_") rows = (await session.execute(select(PMFlowLaunchDraft))).scalars().all() assert len(rows) == 1 assert rows[0].status == "draft" assert rows[0].draft_payload["title"] == "AI 会议纪要助手" @pytest.mark.asyncio async def test_create_launch_draft_missing_core_fields_returns_422(db_env, monkeypatch) -> None: session = db_env user = await _seed_user(session) _patch_llm(monkeypatch, _draft_payload(title="")) app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.post("/pmflow-launch/drafts", json={"input": "做一个工具"}) assert response.status_code == 422 assert response.json()["code"] == 422 assert "draft_structured_failed" in response.json()["message"] assert response.json()["data"] is None @pytest.mark.asyncio async def test_create_launch_draft_count_warnings_are_explicit(db_env, monkeypatch) -> None: session = db_env user = await _seed_user(session) _patch_llm(monkeypatch, _draft_payload(risks=[{"title": "单一风险", "summary": "不足", "level": "medium"}])) app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.post("/pmflow-launch/drafts", json={"input": "做一个工具"}) assert response.status_code == 200 assert "risks_count_out_of_range" in " ".join(response.json()["data"]["warnings"]) async def _create_draft_via_api(session, user, monkeypatch): _patch_llm(monkeypatch, _draft_payload()) app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=user.tenant_id, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具"}) assert response.status_code == 200 return app, response.json()["data"]["draft_id"] @pytest.mark.asyncio async def test_confirm_launch_draft_creates_project_thread_message_truth_nodes_tasks_and_truth_state(db_env, monkeypatch) -> None: session = db_env user = await _seed_user(session) app, draft_id = await _create_draft_via_api(session, user, monkeypatch) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: confirm = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True}) truth = await client.get(f"/pmflow-os/projects/{confirm.json()['data']['project_id']}/truth-state") assert confirm.status_code == 200 data = confirm.json()["data"] assert data["project_id"] > 0 assert data["thread_id"] is not None assert data["thread_message_id"] is not None assert len(data["created_truth_node_ids"]) == 6 assert len(data["created_task_ids"]) == 3 assert (await session.scalar(select(func.count(Project.id)))) == 1 assert (await session.scalar(select(func.count(PMFlowThread.id)))) == 1 assert (await session.scalar(select(func.count(PMFlowThreadMessage.id)))) == 1 assert (await session.scalar(select(func.count(TruthNode.id)))) == 6 assert (await session.scalar(select(func.count(Task.id)))) == 3 truth_node = (await session.execute(select(TruthNode).where(TruthNode.node_type == "requirement"))).scalars().first() assert truth_node.content["metadata"]["launch_draft_id"] == draft_id assert truth_node.content["metadata"]["thread_message_id"] == data["thread_message_id"] task = (await session.execute(select(Task))).scalars().first() assert task.ai_breakdown_payload["launch_metadata"]["launch_draft_id"] == draft_id assert task.manual_overrides["launch_metadata"]["thread_message_id"] == data["thread_message_id"] truth_payload = truth.json()["data"] assert truth_payload["project"]["id"] == str(data["project_id"]) assert truth_payload["thread"]["id"] == str(data["thread_id"]) assert truth_payload["messages"][0]["content"] == "我想做一个 AI 会议纪要工具" assert truth_payload["requirements"] assert truth_payload["tasks"] @pytest.mark.asyncio async def test_confirm_launch_draft_is_idempotent(db_env, monkeypatch) -> None: session = db_env user = await _seed_user(session) app, draft_id = await _create_draft_via_api(session, user, monkeypatch) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: first = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True}) second = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True}) assert first.status_code == 200 assert second.status_code == 200 assert first.json()["data"]["project_id"] == second.json()["data"]["project_id"] assert (await session.scalar(select(func.count(Project.id)))) == 1 assert (await session.scalar(select(func.count(TruthNode.id)))) == 6 assert (await session.scalar(select(func.count(Task.id)))) == 3 @pytest.mark.asyncio async def test_confirm_launch_draft_tenant_user_and_expiry_boundaries(db_env, monkeypatch) -> None: session = db_env owner = await _seed_user(session, tenant_id=1, username="owner") other = await _seed_user(session, tenant_id=1, username="other") app, draft_id = await _create_draft_via_api(session, owner, monkeypatch) other_app = _build_app(session, SimpleNamespace(id=other.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=other_app), base_url="http://test") as client: forbidden = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True}) assert forbidden.status_code == 404 row = (await session.execute(select(PMFlowLaunchDraft).where(PMFlowLaunchDraft.draft_id == draft_id))).scalar_one() row.expires_at = now_cn_naive() - timedelta(minutes=1) await session.commit() owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client: expired = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True}) assert expired.status_code == 409 assert expired.json()["code"] == 409 assert "draft_expired" in expired.json()["message"] assert expired.json()["data"] is None @pytest.mark.asyncio async def test_confirm_launch_draft_rolls_back_on_critical_write_failure(db_env, monkeypatch) -> None: session = db_env user = await _seed_user(session) app, draft_id = await _create_draft_via_api(session, user, monkeypatch) async def _boom(*args, **kwargs): raise RuntimeError("task write failed") monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_create_tasks", _boom) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: with pytest.raises(RuntimeError): await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True}) assert (await session.scalar(select(func.count(Project.id)))) == 0 assert (await session.scalar(select(func.count(TruthNode.id)))) == 0