PMFlow/backend/tests/test_pmflow_change_review_a...

323 lines
12 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, 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_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, role: str = "MEMBER", member: bool = True):
user = User(
tenant_id=tenant_id,
username=f"user-{tenant_id}-{role}-{member}",
email=f"user-{tenant_id}-{role}-{member}@example.com",
password_hash="hashed",
name="Owner",
role=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="用于 Change Review 测试",
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
def _proposed_items():
return [
{
"group": "new_object",
"object_type": "Requirement",
"title": "新增需求:审查真实落库",
"summary": "把 AI 候选变更写入待审状态机。",
"diff": "新增真实 ChangeItem。",
"payload": {"summary": "候选需求"},
"confidence": 0.91,
"risk_level": "medium",
},
{
"group": "risk",
"object_type": "Risk",
"title": "新增风险:高风险需记录审批",
"summary": "高风险项本轮先记录 approval。",
"diff": "新增高风险候选。",
"payload": {"summary": "候选风险"},
"confidence": 0.78,
"risk_level": "high",
},
{
"group": "decision",
"object_type": "Decision",
"title": "待确认决策:先审查后 commit",
"summary": "C1 不直接写项目真相层。",
"diff": "新增决策候选。",
"payload": {"summary": "候选决策"},
"confidence": 0.83,
"risk_level": "medium",
},
]
async def _create_message_and_extraction(client, project_id: int):
message = await client.post(
f"/pmflow-os/projects/{project_id}/threads/thread-project-{project_id}/messages",
json={"content": "请把本轮对话提取成待审变更", "source": "web", "attachments": []},
)
assert message.status_code == 200
msg = message.json()["data"]
extraction = await client.post(
f"/pmflow-os/projects/{project_id}/extractions",
json={
"thread_id": msg["threadId"],
"message_id": msg["id"],
"scope": "latest_message",
"headline": "本轮待审变更",
"summary": "生成 3 个候选变更。",
"proposed_items": _proposed_items(),
},
)
assert extraction.status_code == 200
return msg, extraction.json()["data"]
@pytest.mark.asyncio
async def test_change_review_message_extraction_detail_and_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:
msg, detail = await _create_message_and_extraction(client, project.id)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert msg["threadId"].isdigit()
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(PMFlowChangeSet.id))) == 1
assert await session.scalar(select(func.count(PMFlowChangeItem.id))) == 3
assert await session.scalar(select(func.count(PMFlowEvidenceLink.id))) == 1
assert detail["changeSet"]["status"] == "open"
assert len(detail["items"]) == 3
assert detail["items"][0]["status"] == "pending_review"
assert detail["evidence"][0]["sourceType"] == "thread_message"
assert detail["evidence"][0]["sourceId"] == msg["id"]
truth_payload = truth.json()["data"]
assert truth_payload["loopStage"] == "review_open"
assert truth_payload["activeChangeSetId"] == detail["changeSet"]["id"]
assert len(truth_payload["changeSets"]) == 1
assert len(truth_payload["changeItems"]) == 3
assert len(truth_payload["evidence"]) == 1
@pytest.mark.asyncio
async def test_change_item_accept_edit_defer_write_approvals_and_do_not_write_memory(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:
_, detail = await _create_message_and_extraction(client, project.id)
item_ids = [item["id"] for item in detail["items"]]
accepted = await client.post(f"/pmflow-os/change-items/{item_ids[0]}/accept")
edited = await client.post(
f"/pmflow-os/change-items/{item_ids[1]}/edit",
json={"summary": "人工修订摘要", "diff": "人工修订 diff", "payload_patch": {"owner": "user"}, "reason": "收紧范围"},
)
deferred = await client.post(f"/pmflow-os/change-items/{item_ids[2]}/defer", json={"reason": "下一轮再看"})
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert accepted.status_code == 200
assert edited.status_code == 200
assert deferred.status_code == 200
assert await session.scalar(select(func.count(PMFlowApproval.id))) == 3
assert await session.scalar(select(func.count(TruthNode.id))) == 0
assert await session.scalar(select(func.count(Task.id))) == 0
assert await session.scalar(select(func.count(SemanticFact.id))) == 0
change_set = (await session.execute(select(PMFlowChangeSet))).scalar_one()
assert change_set.status == "reviewed"
edited_row = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_ids[1])))).scalar_one()
assert edited_row.status == "edited"
assert edited_row.edited_payload["owner"] == "user"
assert truth.json()["data"]["projectMemory"]["sourceSummary"]["truth_nodes"] == 0
assert len(truth.json()["data"]["approvals"]) == 3
@pytest.mark.asyncio
async def test_change_item_review_conflict_and_closed_changeset_guard(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:
_, detail = await _create_message_and_extraction(client, project.id)
item_id = detail["items"][0]["id"]
first = await client.post(f"/pmflow-os/change-items/{item_id}/accept")
second = await client.post(f"/pmflow-os/change-items/{item_id}/accept")
assert first.status_code == 200
assert second.status_code == 409
assert "item_already_reviewed" in second.json()["message"]
pending = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.status == "pending_review"))).scalars().first()
change_set = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.id == pending.change_set_id))).scalar_one()
change_set.status = "closed"
await session.commit()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
blocked = await client.post(f"/pmflow-os/change-items/{pending.id}/accept")
assert blocked.status_code == 409
assert "changeset_not_open" in blocked.json()["message"]
@pytest.mark.asyncio
async def test_change_review_project_permission_boundary(db_env) -> None:
session = db_env
owner, project = await _seed_user_project(session)
outsider, _ = await _seed_user_project(session, tenant_id=1, member=False)
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:
_, detail = await _create_message_and_extraction(client, project.id)
outsider_app = _build_app(session, SimpleNamespace(id=outsider.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=outsider_app), base_url="http://test") as client:
denied = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}")
denied_action = await client.post(f"/pmflow-os/change-items/{detail['items'][0]['id']}/accept")
assert denied.status_code == 403
assert denied_action.status_code == 403
cross_tenant_app = _build_app(session, SimpleNamespace(id=outsider.id, tenant_id=2, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=cross_tenant_app), base_url="http://test") as client:
missing = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}")
assert missing.status_code == 404