596 lines
21 KiB
Python
596 lines
21 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_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
|