PMFlow/backend/tests/test_pmflow_os_projection_a...

302 lines
10 KiB
Python

from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
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_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