302 lines
10 KiB
Python
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
|