335 lines
14 KiB
Python
335 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,
|
|
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__,
|
|
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
|