323 lines
12 KiB
Python
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
|