PMFlow/backend/tests/test_pmflow_launch_api.py

337 lines
14 KiB
Python

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