734 lines
30 KiB
Python
734 lines
30 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
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, projects_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,
|
||
PMFlowAIFeedbackEvent,
|
||
PMFlowChangeItem,
|
||
PMFlowChangeSet,
|
||
PMFlowEvidenceLink,
|
||
OperationLog,
|
||
PMFlowLaunchDraft,
|
||
PMFlowLaunchDraftJob,
|
||
PMFlowThread,
|
||
PMFlowThreadMessage,
|
||
ComplianceCheckRun,
|
||
ProgressEvent,
|
||
Project,
|
||
ProjectModelBinding,
|
||
PromptRelease,
|
||
ProjectSignalLink,
|
||
PromptPackage,
|
||
ReleaseValidationRun,
|
||
RepoBinding,
|
||
RevenueEvent,
|
||
ROISnapshot,
|
||
SemanticFact,
|
||
Signal,
|
||
Task,
|
||
TaskCodeLink,
|
||
TruthNode,
|
||
User,
|
||
UserProject,
|
||
)
|
||
from app.schemas.pmflow_launch import PMFlowLaunchAttachment, PMFlowLaunchDraftJobRequest # noqa: E402
|
||
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__,
|
||
ProjectSignalLink.__table__,
|
||
UserProject.__table__,
|
||
PMFlowLaunchDraft.__table__,
|
||
PMFlowLaunchDraftJob.__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__,
|
||
PromptRelease.__table__,
|
||
PromptPackage.__table__,
|
||
DevWorkspaceBinding.__table__,
|
||
RepoBinding.__table__,
|
||
TaskCodeLink.__table__,
|
||
ProgressEvent.__table__,
|
||
ReleaseValidationRun.__table__,
|
||
ComplianceCheckRun.__table__,
|
||
FeedbackIngest.__table__,
|
||
IterationRecommendation.__table__,
|
||
RevenueEvent.__table__,
|
||
ROISnapshot.__table__,
|
||
GitHubWebhookDelivery.__table__,
|
||
OperationLog.__table__,
|
||
PMFlowAIFeedbackEvent.__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)
|
||
app.include_router(projects_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)
|
||
|
||
|
||
def _runtime_profile(**overrides):
|
||
data = {
|
||
"function_name": "pmflow_launch_draft_v1",
|
||
"scene_key": "project_launch",
|
||
"prompt_key": "PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||
"required_slot": "structured",
|
||
"response_contract": "structured",
|
||
"max_tokens": 7200,
|
||
"repair_max_tokens": 3600,
|
||
"temperature": 0.0,
|
||
"foreground_wait_seconds": 10,
|
||
"provider_timeout_seconds": 90,
|
||
"job_timeout_seconds": 180,
|
||
"max_retries": 1,
|
||
"large_input_policy": "preprocess_then_generate",
|
||
"response_mode": "fast",
|
||
"response_format": "json_object",
|
||
"runtime_mode": "active",
|
||
"quality_gate_mode": "strict",
|
||
"profile_version": "test",
|
||
"profile_source": "active",
|
||
}
|
||
data.update(overrides)
|
||
return pmflow_launch_service.AIFunctionRuntimeProfile(**data)
|
||
|
||
|
||
@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_uses_platform_prompt_release(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session, tenant_id=12)
|
||
release = PromptRelease(
|
||
tenant_id=1,
|
||
prompt_key="PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||
version=1,
|
||
body_snapshot=(
|
||
"ACTIVE_RELEASE_MARKER\n"
|
||
"用户输入:{input_text}\n"
|
||
"附件摘要:{attachments_json}\n"
|
||
"输出 JSON:{{\"title\":\"string\",\"one_liner\":\"string\",\"target_user\":\"string\","
|
||
"\"problem\":\"string\",\"solution\":\"string\",\"requirements\":[],\"tasks\":[],"
|
||
"\"risks\":[],\"decisions\":[],\"evidence\":[],\"confidence\":0,\"warnings\":[]}}"
|
||
),
|
||
status="active",
|
||
created_by=1,
|
||
activated_by=1,
|
||
activated_at=now_cn_naive().isoformat(),
|
||
)
|
||
session.add(release)
|
||
await session.commit()
|
||
await session.refresh(release)
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_generate_structured_object(prompt, *args, **kwargs):
|
||
captured["prompt"] = prompt
|
||
captured["routing_meta"] = kwargs.get("routing_meta")
|
||
captured["user_id"] = kwargs.get("user_id")
|
||
return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test")
|
||
|
||
monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object)
|
||
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
|
||
assert "ACTIVE_RELEASE_MARKER" in str(captured["prompt"])
|
||
routing_meta = captured["routing_meta"]
|
||
assert isinstance(routing_meta, dict)
|
||
assert routing_meta["prompt_key"] == "PROMPT_PMFLOW_LAUNCH_DRAFT"
|
||
assert routing_meta["prompt_source"] == "active_release"
|
||
assert routing_meta["prompt_release_id"] == release.id
|
||
assert routing_meta["prompt_runtime_tenant_id"] == 1
|
||
assert routing_meta["provider_timeout_seconds"] == 90
|
||
assert routing_meta["job_timeout_seconds"] == 180
|
||
assert routing_meta["large_input_policy"] == "preprocess_then_generate"
|
||
assert captured["user_id"] == user.id
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_job_response_exposes_runtime_policy(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session)
|
||
|
||
async def _fake_resolve(self, *, stage: str = "active"):
|
||
return _runtime_profile(
|
||
foreground_wait_seconds=12,
|
||
provider_timeout_seconds=120,
|
||
job_timeout_seconds=240,
|
||
max_retries=2,
|
||
large_input_policy="direct_generate",
|
||
)
|
||
|
||
monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_resolve_draft_runtime_profile", _fake_resolve)
|
||
service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1)
|
||
job = await service.create_draft_job(
|
||
body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,面向小团队整理行动项。"),
|
||
user_id=int(user.id),
|
||
)
|
||
|
||
assert job.runtime_policy is not None
|
||
assert job.runtime_policy.foreground_wait_seconds == 12
|
||
assert job.runtime_policy.provider_timeout_seconds == 120
|
||
assert job.runtime_policy.job_timeout_seconds == 240
|
||
assert job.runtime_policy.max_retries == 2
|
||
assert job.runtime_policy.large_input_policy == "direct_generate"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_job_marks_failed_when_job_timeout_expires(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session)
|
||
|
||
async def _fake_resolve(self, *, stage: str = "active"):
|
||
return _runtime_profile(job_timeout_seconds=10)
|
||
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _slow_generate_structured_object(*args, **kwargs):
|
||
captured["user_id"] = kwargs.get("user_id")
|
||
await asyncio.sleep(2)
|
||
return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test")
|
||
|
||
monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_resolve_draft_runtime_profile", _fake_resolve)
|
||
monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_bounded_seconds", staticmethod(lambda *args, **kwargs: 1))
|
||
monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _slow_generate_structured_object)
|
||
|
||
service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1)
|
||
job = await service.create_draft_job(
|
||
body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,面向小团队整理行动项。"),
|
||
user_id=int(user.id),
|
||
)
|
||
await service.run_draft_job(job_id=job.job_id)
|
||
|
||
row = (
|
||
await session.execute(select(PMFlowLaunchDraftJob).where(PMFlowLaunchDraftJob.job_id == job.job_id))
|
||
).scalar_one()
|
||
assert row.status == "failed"
|
||
assert row.error_code == "draft_job_timeout"
|
||
assert captured["user_id"] == user.id
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_large_input_policy_compacts_material_before_prompt(db_env) -> None:
|
||
service = pmflow_launch_service.PMFlowLaunchService(db_env, tenant_id=1)
|
||
body = PMFlowLaunchDraftJobRequest(
|
||
input="产品目标:" + ("很长的项目材料 " * 300),
|
||
attachments=[{"type": "text", "name": "research.md", "summary": "用户访谈:" + ("访谈内容 " * 1000)}],
|
||
)
|
||
next_body, meta = service._apply_large_input_policy(
|
||
body=body,
|
||
routing_meta={
|
||
"input_bucket": "large",
|
||
"large_input_policy": "preprocess_then_generate",
|
||
},
|
||
)
|
||
|
||
assert meta["large_input_preprocessed"] is True
|
||
assert len(next_body.input) <= 3900
|
||
assert next_body.attachments[0].summary is not None
|
||
assert len(next_body.attachments[0].summary or "") <= 560
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_launch_seed_is_preserved_as_generation_context_and_large_input_attachment(db_env) -> None:
|
||
service = pmflow_launch_service.PMFlowLaunchService(db_env, tenant_id=1)
|
||
launch_seed = {
|
||
"packet_version": "pmflow_project_launch_seed.v1",
|
||
"source": "space_ai_turn",
|
||
"idea_input": "AI 音乐自动生成工具",
|
||
"customer_safe_claims": [{"claim_text": "首版围绕文本生成音乐和音频导出闭环。"}],
|
||
"materialization_policy": {"mode": "seed_first", "do_not_reinterpret_as_new_idea": True},
|
||
}
|
||
body = PMFlowLaunchDraftJobRequest(
|
||
input="产品目标:" + ("很长的项目材料 " * 300),
|
||
attachments=[
|
||
PMFlowLaunchAttachment(
|
||
type="pmflow_launch_seed",
|
||
name="Space AI 本轮产品方案种子",
|
||
summary="来自 PMFlow AI 本轮回答的产品方案种子。",
|
||
payload=launch_seed,
|
||
),
|
||
{"type": "text", "name": "research.md", "summary": "用户访谈:" + ("访谈内容 " * 1000)},
|
||
],
|
||
)
|
||
|
||
context = service._launch_seed_generation_context(body.attachments)
|
||
assert "Space AI 同轮产品方案种子" in context
|
||
assert "不是重新解释用户创意" in context
|
||
assert "AI 音乐自动生成工具" in context
|
||
|
||
next_body, meta = service._apply_large_input_policy(
|
||
body=body,
|
||
routing_meta={
|
||
"input_bucket": "large",
|
||
"large_input_policy": "preprocess_then_generate",
|
||
},
|
||
)
|
||
|
||
assert meta["large_input_preprocessed"] is True
|
||
assert next_body.attachments[0].type == "pmflow_launch_seed"
|
||
assert next_body.attachments[0].payload == launch_seed
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_launch_draft_uses_product_sized_token_budget(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session, tenant_id=12)
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_generate_structured_object(*args, **kwargs):
|
||
captured["max_tokens"] = kwargs.get("max_tokens")
|
||
return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test")
|
||
|
||
monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object)
|
||
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
|
||
assert captured["max_tokens"] == pmflow_launch_service.PMFlowLaunchService.DRAFT_GENERATION_MAX_TOKENS
|
||
|
||
|
||
@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": "我想做一个 AI 会议纪要工具,帮助团队提取行动项"})
|
||
|
||
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_rejects_low_information_input_before_llm(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session)
|
||
called = False
|
||
|
||
async def _fake_generate_structured_object(*args, **kwargs):
|
||
nonlocal called
|
||
called = True
|
||
return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test")
|
||
|
||
monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object)
|
||
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 "input_needs_more_info" in response.json()["message"]
|
||
assert response.json()["data"] is None
|
||
assert called is False
|
||
|
||
|
||
@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": "我想做一个 AI 会议纪要工具,帮助团队提取行动项"})
|
||
|
||
assert response.status_code == 200
|
||
assert "risks_count_out_of_range" in " ".join(response.json()["data"]["warnings"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_launch_draft_job_runs_and_persists_completed_draft(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session)
|
||
_patch_llm(monkeypatch, _draft_payload())
|
||
service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1)
|
||
|
||
job = await service.create_draft_job(
|
||
body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,帮助团队提取行动项", source="web"),
|
||
user_id=int(user.id),
|
||
)
|
||
|
||
assert job.status == "queued"
|
||
await service.run_draft_job(job_id=job.job_id)
|
||
|
||
completed = await service.get_draft_job(job_id=job.job_id, user_id=int(user.id))
|
||
assert completed.status == "completed"
|
||
assert completed.draft_id is not None
|
||
assert completed.draft is not None
|
||
assert completed.draft.title == "AI 会议纪要助手"
|
||
assert completed.changed_fields
|
||
assert (await session.scalar(select(func.count(PMFlowLaunchDraftJob.id)))) == 1
|
||
assert (await session.scalar(select(func.count(PMFlowLaunchDraft.id)))) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_launch_draft_job_supersedes_active_job_when_user_supplements_input(db_env, monkeypatch) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session)
|
||
_patch_llm(monkeypatch, _draft_payload())
|
||
service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1)
|
||
|
||
first = await service.create_draft_job(
|
||
body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,帮助团队提取行动项", source="web"),
|
||
user_id=int(user.id),
|
||
)
|
||
second = await service.create_draft_job(
|
||
body=PMFlowLaunchDraftJobRequest(
|
||
input="我想做一个 AI 会议纪要工具,帮助团队提取行动项。补充:首版优先飞书接入。",
|
||
source="web",
|
||
mode="merge_supplement",
|
||
revision_note="首版优先飞书接入",
|
||
),
|
||
user_id=int(user.id),
|
||
)
|
||
|
||
superseded = await service.get_draft_job(job_id=first.job_id, user_id=int(user.id))
|
||
assert superseded.status == "superseded"
|
||
assert superseded.superseded_by_job_id == second.job_id
|
||
assert second.status == "queued"
|
||
|
||
|
||
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
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_create_duplicate_title_defaults_to_conflict(db_env) -> None:
|
||
session = db_env
|
||
user = await _seed_user(session)
|
||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER"))
|
||
|
||
payload = {"title": "同名项目", "description": "普通创建默认不吞掉重复标题。", "status": "active"}
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
first = await client.post("/projects/", json=payload)
|
||
second = await client.post("/projects/", json=payload)
|
||
|
||
assert first.status_code == 200
|
||
assert second.status_code == 409
|
||
assert second.json()["detail"] == "project title already exists"
|
||
assert (await session.scalar(select(func.count(Project.id)))) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_projects_list_and_detail_are_scoped_by_role(db_env) -> None:
|
||
session = db_env
|
||
manager = await _seed_user(session, username="manager")
|
||
builder = await _seed_user(session, username="builder")
|
||
outsider = await _seed_user(session, username="outsider")
|
||
|
||
owned_project = Project(
|
||
tenant_id=1,
|
||
name="Builder Owned",
|
||
title="Builder Owned",
|
||
description="Owned by builder",
|
||
owner_id=builder.id,
|
||
status="active",
|
||
assigned_role="BUILDER",
|
||
signal_count=0,
|
||
)
|
||
member_project = Project(
|
||
tenant_id=1,
|
||
name="Builder Member",
|
||
title="Builder Member",
|
||
description="Builder is assigned",
|
||
owner_id=outsider.id,
|
||
status="active",
|
||
assigned_role="BUILDER",
|
||
signal_count=0,
|
||
)
|
||
hidden_project = Project(
|
||
tenant_id=1,
|
||
name="Hidden Project",
|
||
title="Hidden Project",
|
||
description="No builder access",
|
||
owner_id=outsider.id,
|
||
status="active",
|
||
assigned_role="BUILDER",
|
||
signal_count=0,
|
||
)
|
||
session.add_all([owned_project, member_project, hidden_project])
|
||
await session.flush()
|
||
session.add(UserProject(tenant_id=1, user_id=builder.id, project_id=member_project.id))
|
||
await session.commit()
|
||
|
||
manager_app = _build_app(session, SimpleNamespace(id=manager.id, tenant_id=1, role="OWNER"))
|
||
builder_app = _build_app(session, SimpleNamespace(id=builder.id, tenant_id=1, role="BUILDER"))
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=manager_app), base_url="http://test") as client:
|
||
manager_response = await client.get("/projects/?summary=1")
|
||
assert manager_response.status_code == 200
|
||
assert {item["title"] for item in manager_response.json()["projects"]} == {
|
||
"Builder Owned",
|
||
"Builder Member",
|
||
"Hidden Project",
|
||
}
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=builder_app), base_url="http://test") as client:
|
||
builder_response = await client.get("/projects/?summary=1")
|
||
allowed_detail = await client.get(f"/projects/{member_project.id}")
|
||
hidden_detail = await client.get(f"/projects/{hidden_project.id}")
|
||
|
||
assert builder_response.status_code == 200
|
||
assert {item["title"] for item in builder_response.json()["projects"]} == {
|
||
"Builder Owned",
|
||
"Builder Member",
|
||
}
|
||
assert allowed_detail.status_code == 200
|
||
assert allowed_detail.json()["title"] == "Builder Member"
|
||
assert hidden_detail.status_code == 404
|