PMFlow/backend/tests/test_pmflow_change_review_a...

547 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
IterationRecommendation,
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__,
IterationRecommendation.__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",
},
]
def _commit_proposed_items():
return [
{
"group": "new_object",
"object_type": "Requirement",
"title": "新增需求:真实写入项目记忆",
"summary": "确认后的需求必须进入 truth_nodes。",
"diff": "写入 requirement truth node。",
"payload": {"summary": "确认后的需求", "stage": "planning"},
"confidence": 0.92,
"risk_level": "medium",
},
{
"group": "new_object",
"object_type": "Task",
"title": "新增任务:生成开发上下文包",
"summary": "确认后的任务必须进入 tasks。",
"diff": "写入 task。",
"payload": {
"summary": "确认后的任务",
"description": "生成开发上下文包并等待执行桥消费。",
"priority": "high",
"acceptanceCriteria": ["任务进入 tasks", "任务绑定 truth_node"],
},
"confidence": 0.88,
"risk_level": "medium",
},
{
"group": "risk",
"object_type": "Risk",
"title": "新增风险:commit 需要审批",
"summary": "高风险项必须有 approval 才能写入。",
"diff": "写入 high risk。",
"payload": {"summary": "确认后的高风险", "mitigation": "提交前检查 approval gate。"},
"confidence": 0.81,
"risk_level": "high",
},
]
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"]
async def _create_commit_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": _commit_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_review_decision_request_and_approve_high_risk_satisfies_commit_gate(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
reviewer = User(
tenant_id=1,
username="reviewer",
email="reviewer@example.com",
password_hash="hashed",
name="审查负责人",
role="REVIEWER",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(reviewer)
await session.flush()
session.add(UserProject(tenant_id=1, user_id=reviewer.id, project_id=project.id))
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:
_, detail = await _create_commit_extraction(client, project.id)
change_set_id = detail["changeSet"]["id"]
high_risk_id = next(item["id"] for item in detail["items"] if item["riskLevel"] == "high")
request_review = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "request_review", "reviewer_id": reviewer.id, "reason": "请 reviewer 复核高风险项。"},
)
approve = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "approve", "reviewer_id": reviewer.id, "reason": "高风险项通过。", "item_ids": [high_risk_id]},
)
commit = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/commit",
json={"item_ids": [high_risk_id], "mode": "atomic"},
)
assert request_review.status_code == 200
request_payload = request_review.json()["data"]
assert request_payload["warnings"] == []
routing_item = request_payload["truthState"]["reviewerRouting"]["items"][0]
assert routing_item["reviewDecision"] == "request_review"
assert routing_item["approvalState"] == "requested"
assert routing_item["requestedAt"]
assert approve.status_code == 200
approve_payload = approve.json()["data"]
assert approve_payload["truthState"]["reviewerRouting"]["items"][0]["approvalState"] == "approved"
assert approve_payload["truthState"]["reviewerRouting"]["items"][0]["decisionReason"] == "高风险项通过。"
approval_rows = (await session.execute(select(PMFlowApproval).where(PMFlowApproval.change_set_id == int(change_set_id)))).scalars().all()
assert any(row.action == "approve" and row.decision == "approved" and row.change_item_id == int(high_risk_id) for row in approval_rows)
assert any(row.action == "approve" and row.actor_user_id == user.id for row in approval_rows)
high_risk = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(high_risk_id)))).scalar_one()
assert high_risk.status == "committed"
assert commit.status_code == 200
assert await session.scalar(select(func.count(TruthNode.id))) == 1
@pytest.mark.asyncio
async def test_review_decision_request_changes_and_reject_do_not_commit_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)
change_set_id = detail["changeSet"]["id"]
item_ids = [item["id"] for item in detail["items"]]
changes = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "request_changes", "reason": "摘要需要补证据。", "item_ids": [item_ids[0]]},
)
rejected = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "reject", "reason": "风险项本轮不采纳。", "item_ids": [item_ids[1]]},
)
assert changes.status_code == 200
assert changes.json()["data"]["truthState"]["reviewerRouting"]["items"][0]["approvalState"] == "changes_requested"
assert rejected.status_code == 200
assert rejected.json()["data"]["truthState"]["reviewerRouting"]["items"][0]["approvalState"] == "rejected"
assert await session.scalar(select(func.count(PMFlowApproval.id))) == 2
assert await session.scalar(select(func.count(TruthNode.id))) == 0
assert await session.scalar(select(func.count(Task.id))) == 0
rejected_item = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_ids[1])))).scalar_one()
assert rejected_item.status == "deferred"
@pytest.mark.asyncio
async def test_change_set_commit_writes_truth_nodes_tasks_and_semantic_facts(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_commit_extraction(client, project.id)
item_ids = [item["id"] for item in detail["items"]]
await client.post(f"/pmflow-os/change-items/{item_ids[0]}/accept")
await client.post(f"/pmflow-os/change-items/{item_ids[1]}/accept")
await client.post(
f"/pmflow-os/change-items/{item_ids[2]}/edit",
json={"summary": "人工确认后的高风险", "payload_patch": {"mitigation": "人工确认后再落库"}},
)
committed = await client.post(
f"/pmflow-os/change-sets/{detail['changeSet']['id']}/commit",
json={"mode": "atomic", "note": "确认写入项目记忆"},
)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert committed.status_code == 200
payload = committed.json()["data"]
assert payload["changeSet"]["status"] == "committed"
assert len(payload["committedItems"]) == 3
assert payload["failedItems"] == []
assert await session.scalar(select(func.count(TruthNode.id))) == 3
assert await session.scalar(select(func.count(Task.id))) == 1
assert await session.scalar(select(func.count(SemanticFact.id))) == 4
task = (await session.execute(select(Task))).scalar_one()
assert task.priority == "high"
assert task.status == "todo"
assert task.manual_overrides["acceptance_criteria"] == ["任务进入 tasks", "任务绑定 truth_node"]
truth_payload = truth.json()["data"]
assert truth_payload["activeChangeSetId"] is None
assert truth_payload["projectMemory"]["sourceSummary"]["truth_nodes"] == 3
assert truth_payload["projectMemory"]["sourceSummary"]["semantic_facts"] == 4
@pytest.mark.asyncio
async def test_change_set_commit_blocks_high_risk_without_approval_and_rolls_back(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_commit_extraction(client, project.id)
high_risk_id = detail["items"][2]["id"]
item = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(high_risk_id)))).scalar_one()
item.status = "accepted"
change_set = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.id == int(item.change_set_id)))).scalar_one()
change_set.status = "reviewed"
await session.commit()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
blocked = await client.post(
f"/pmflow-os/change-sets/{detail['changeSet']['id']}/commit",
json={"item_ids": [high_risk_id], "mode": "atomic"},
)
assert blocked.status_code == 409
assert "high_risk_approval_required" in blocked.json()["message"]
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
@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