PMFlow/backend/tests/test_pmflow_os_projection_a...

676 lines
24 KiB
Python

from __future__ import annotations
import os
from datetime import datetime
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON, 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_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,
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
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__,
]
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_project(session, *, tenant_id: int = 1, user_role: str = "MEMBER", member: bool = True):
user = User(
tenant_id=tenant_id,
username=f"user-{tenant_id}-{user_role}-{member}",
email=f"user-{tenant_id}-{user_role}-{member}@example.com",
password_hash="hashed",
name="Owner",
role=user_role,
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(user)
await session.flush()
project = Project(
tenant_id=tenant_id,
name="真实项目",
title="真实项目",
description="从真实后端读取的项目",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(project)
await session.flush()
if member:
session.add(UserProject(tenant_id=tenant_id, user_id=user.id, project_id=project.id))
await session.commit()
return user, project
def _build_app(session, current_user) -> FastAPI:
app = FastAPI()
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
@pytest.mark.asyncio
async def test_truth_state_empty_project_returns_compatible_empty_projection(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
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.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["project"]["id"] == str(project.id)
assert payload["requirements"] == []
assert payload["tasks"] == []
assert payload["changeSets"] == []
assert "truth_nodes_empty" in " ".join(payload["projectionWarnings"])
@pytest.mark.asyncio
async def test_truth_state_projects_memory_from_truth_nodes_and_semantic_facts(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
requirement = TruthNode(
tenant_id=1,
node_type="requirement",
project_id=project.id,
created_by=user.id,
title="真实需求",
content={"summary": "来自 truth_nodes 的需求", "stage": "planning"},
version=1,
status="aligned",
)
risk = TruthNode(
tenant_id=1,
node_type="risk",
project_id=project.id,
created_by=user.id,
title="真实风险",
content={"summary": "来自 truth_nodes 的风险", "risk_level": "high", "mitigation": "人工确认"},
version=1,
status="confirmed",
)
session.add_all([requirement, risk])
await session.flush()
session.add(
SemanticFact(
tenant_id=1,
entity_type="truth_node",
entity_id=requirement.id,
fact_namespace="project_memory",
fact_key="target_user",
fact_value_text="个人开发者",
fact_value_norm="个人开发者",
value_type="keyword",
confidence=0.91,
evidence_source_type="truth_node",
evidence_source_id=requirement.id,
evidence_excerpt="来自 truth node",
is_active=True,
)
)
await session.commit()
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.get(f"/pmflow-os/projects/{project.id}/truth-state")
payload = response.json()["data"]
assert payload["requirements"][0]["title"] == "真实需求"
assert payload["risks"][0]["level"] == "high"
assert payload["projectMemory"]["sourceSummary"]["truth_nodes"] == 2
assert payload["projectMemory"]["sourceSummary"]["semantic_facts"] == 1
assert payload["projectMemory"]["semanticFacts"][0]["source"] == "semantic_facts"
@pytest.mark.asyncio
async def test_truth_state_task_projection_comes_from_tasks_table(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="真实宪章",
content={"business_goal": "真实任务来源"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload={"acceptance_criteria": ["必须来自 tasks 表"]},
manual_overrides=None,
title="真实开发任务",
description="从 tasks 表投影出来",
required_skill_tag_ids=[],
status="in_progress",
priority="high",
)
session.add(task)
await session.commit()
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.get(f"/pmflow-os/projects/{project.id}/truth-state")
payload = response.json()["data"]
assert payload["tasks"][0]["title"] == "真实开发任务"
assert payload["tasks"][0]["requirementId"] == str(charter.id)
assert payload["tasks"][0]["acceptanceCriteria"] == ["必须来自 tasks 表"]
assert all(item["title"] != "重构会议转写主链" for item in payload["tasks"])
@pytest.mark.asyncio
async def test_execution_summary_empty_state_is_pending_with_warnings(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
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.get(f"/pmflow-os/projects/{project.id}/execution-summary")
payload = response.json()["data"]
assert payload["execution"]["status"] == "queued"
assert payload["execution"]["prompt"] == ""
assert payload["execution"]["testSummary"] == "pending"
assert "prompt_package_empty" in " ".join(payload["warnings"])
assert payload["activeDevTasks"] == []
@pytest.mark.asyncio
async def test_execution_summary_projects_real_prompt_repo_tasks_and_events(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="真实执行宪章",
content={"business_goal": "进入真实开发执行桥"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload={"acceptance_criteria": ["必须投影真实 dev-sync 数据"]},
manual_overrides=None,
title="接入真实执行摘要",
description="从 execution-summary 渲染执行桥",
required_skill_tag_ids=[],
status="in_progress",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="pmflow-app",
default_branch="main",
installation_id="install-1",
status="active",
)
session.add_all([task, repo])
await session.flush()
session.add_all(
[
PromptPackage(
tenant_id=1,
project_id=project.id,
charter_id=charter.id,
task_id=task.id,
package_kind="development",
status="active",
package_text="真实开发提示词",
package_payload={"scope": "execution"},
generated_from={"source": "truth_state"},
),
TaskCodeLink(
tenant_id=1,
task_id=task.id,
repo_binding_id=repo.id,
branch_name="codex/main-flow-phase-d-execution",
pr_number=128,
last_commit_sha="abcdef1234567890",
status="review",
),
ProgressEvent(
tenant_id=1,
task_id=task.id,
project_id=project.id,
user_id=user.id,
repo_binding_id=repo.id,
event_type="commit",
source="github",
derived_task_status="in_progress",
summary="真实 commit 已回流",
payload={
"workflow_state": "coding",
"branch_name": "codex/main-flow-phase-d-execution",
"commit_sha": "abcdef1234567890",
"pr_number": 128,
},
occurred_at=datetime(2026, 4, 26, 10, 30, 0),
),
GitHubWebhookDelivery(
tenant_id=1,
delivery_id="delivery-1",
event_type="push",
repo_binding_id=repo.id,
project_id=project.id,
installation_id="install-1",
status="matched",
payload={"repository": {"full_name": "pmflow/pmflow-app"}},
received_at=datetime(2026, 4, 26, 10, 31, 0),
),
]
)
await session.commit()
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.get(f"/pmflow-os/projects/{project.id}/execution-summary")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["promptPackage"]["package_text"] == "真实开发提示词"
assert payload["repoBindings"][0]["full_name"] == "pmflow/pmflow-app"
assert payload["repoBindings"][0]["task_link_count"] == 1
assert payload["activeDevTasks"][0]["title"] == "接入真实执行摘要"
assert payload["activeDevTasks"][0]["code_link"]["pr_number"] == 128
assert payload["recentProgressEvents"][0]["summary"] == "真实 commit 已回流"
assert payload["recentDeliveries"][0]["repository_full_name"] == "pmflow/pmflow-app"
assert payload["warnings"] == []
@pytest.mark.asyncio
async def test_execution_handoff_creates_prompt_package_and_progress_event(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="执行交接宪章",
content={"business_goal": "把确认任务交给真实开发执行桥"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload={"acceptance_criteria": ["生成可追踪 handoff 起点"]},
manual_overrides=None,
title="发起真实执行交接",
description="从任务、项目记忆和仓库生成 PromptPackage",
required_skill_tag_ids=[],
status="confirmed",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="execution-bridge",
default_branch="main",
installation_id="install-2",
status="active",
)
session.add_all([task, repo])
await session.commit()
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(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"runtime_target": "local_bridge",
"note": "交给本地桥接执行",
},
)
assert response.status_code == 200
payload = response.json()["data"]
assert payload["promptPackage"]["task_id"] == str(task.id)
assert payload["promptPackage"]["package_kind"] == "execution_handoff"
assert "发起真实执行交接" in payload["promptPackage"]["package_text"]
assert "pmflow/execution-bridge" in payload["promptPackage"]["package_text"]
assert payload["recentProgressEvents"][0]["event_type"] == "handoff_created"
assert payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/execution-bridge"
assert payload["execution"]["status"] == "pushed"
assert payload["warnings"] == []
prompt_rows = (await session.execute(select(PromptPackage).where(PromptPackage.task_id == task.id))).scalars().all()
event_rows = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalars().all()
assert len(prompt_rows) == 1
assert len(event_rows) == 1
assert event_rows[0].payload["runtime_target"] == "local_bridge"
@pytest.mark.asyncio
async def test_execution_handoff_without_repo_creates_draft_warning(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="无仓库交接宪章",
content={"business_goal": "允许先生成执行交接草案"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload=None,
manual_overrides=None,
title="缺少仓库也能建草案",
description="仓库后续绑定",
required_skill_tag_ids=[],
status="confirmed",
priority="medium",
)
session.add(task)
await session.commit()
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(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={"task_id": str(task.id), "runtime_target": "manual"},
)
assert response.status_code == 200
payload = response.json()["data"]
assert payload["promptPackage"]["task_id"] == str(task.id)
assert payload["recentProgressEvents"][0]["repo"] is None
assert "repo_bindings_empty" in " ".join(payload["warnings"])
event = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalar_one()
assert event.payload["handoff_state"] == "draft"
@pytest.mark.asyncio
async def test_execution_handoff_consume_records_pickup_event(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="交接领取宪章",
content={"business_goal": "执行交接被运行端领取后可追踪"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload={"acceptance_criteria": ["领取交接包后进入开发中"]},
manual_overrides=None,
title="领取执行交接包",
description="Codex 消费 PromptPackage",
required_skill_tag_ids=[],
status="confirmed",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="handoff-consume",
default_branch="main",
installation_id="install-3",
status="active",
)
session.add_all([task, repo])
await session.commit()
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:
create_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={"task_id": str(task.id), "repo_binding_id": str(repo.id), "runtime_target": "local_bridge"},
)
prompt_package_id = create_response.json()["data"]["promptPackage"]["id"]
consume_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs/{prompt_package_id}/consume",
json={"runtime_target": "codex", "consumer_label": "Codex", "note": "开始开发"},
)
assert consume_response.status_code == 200
payload = consume_response.json()["data"]
assert payload["execution"]["status"] == "coding"
assert payload["recentProgressEvents"][0]["event_type"] == "handoff_consumed"
assert payload["recentProgressEvents"][0]["derived_task_status"] == "coding"
assert payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/handoff-consume"
prompt = (
await session.execute(select(PromptPackage).where(PromptPackage.id == int(prompt_package_id)))
).scalar_one()
latest_handoff = prompt.package_payload["latest_handoff"]
assert latest_handoff["handoff_state"] == "consumed"
assert latest_handoff["consumer_label"] == "Codex"
assert latest_handoff["runtime_target"] == "codex"
event_types = [
row.event_type
for row in (
await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id).order_by(ProgressEvent.id.asc()))
)
.scalars()
.all()
]
assert event_types == ["handoff_created", "handoff_consumed"]
@pytest.mark.asyncio
async def test_execution_handoff_rejects_task_outside_project(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
other_project = Project(
tenant_id=1,
name="其它真实项目",
title="其它真实项目",
description="不能被当前项目交接",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(other_project)
await session.flush()
session.add(UserProject(tenant_id=1, user_id=user.id, project_id=other_project.id))
await session.flush()
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=other_project.id,
created_by=user.id,
title="其它项目宪章",
content={"business_goal": "不能跨项目交接"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
other_task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=other_project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload=None,
manual_overrides=None,
title="其它项目任务",
description="不能被当前项目 handoff",
required_skill_tag_ids=[],
status="confirmed",
priority="medium",
)
session.add(other_task)
await session.commit()
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(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={"task_id": str(other_task.id), "runtime_target": "codex"},
)
assert response.status_code == 404
assert response.json()["message"] == "task_not_found"
@pytest.mark.asyncio
async def test_project_permission_and_tenant_boundary(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session, member=True)
_, forbidden_project = await _seed_user_project(session, tenant_id=1, member=False)
_, other_tenant_project = await _seed_user_project(session, tenant_id=2, member=True)
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:
ok_response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
forbidden_response = await client.get(f"/pmflow-os/projects/{forbidden_project.id}/truth-state")
not_found_response = await client.get(f"/pmflow-os/projects/{other_tenant_project.id}/truth-state")
assert ok_response.status_code == 200
assert forbidden_response.status_code == 403
assert not_found_response.status_code == 404