PMFlow/backend/tests/test_pmflow_os_projection_a...

849 lines
30 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 datetime import datetime
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON, 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, 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_execution_summary_projects_real_prompt_repo_tasks_and_events(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": ["必须投影真实 dev-sync 数据"]},
manual_overrides=None,
title="接入真实执行摘要",
description="从 execution-summary 渲染执行桥",
required_skill_tag_ids=[],
status="in_progress",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="pmflow-app",
default_branch="main",
installation_id="install-1",
status="active",
)
session.add_all([task, repo])
await session.flush()
session.add_all(
[
PromptPackage(
tenant_id=1,
project_id=project.id,
charter_id=charter.id,
task_id=task.id,
package_kind="development",
status="active",
package_text="真实开发提示词",
package_payload={"scope": "execution"},
generated_from={"source": "truth_state"},
),
TaskCodeLink(
tenant_id=1,
task_id=task.id,
repo_binding_id=repo.id,
branch_name="codex/main-flow-phase-d-execution",
pr_number=128,
last_commit_sha="abcdef1234567890",
status="review",
),
ProgressEvent(
tenant_id=1,
task_id=task.id,
project_id=project.id,
user_id=user.id,
repo_binding_id=repo.id,
event_type="commit",
source="github",
derived_task_status="in_progress",
summary="真实 commit 已回流",
payload={
"workflow_state": "coding",
"branch_name": "codex/main-flow-phase-d-execution",
"commit_sha": "abcdef1234567890",
"pr_number": 128,
},
occurred_at=datetime(2026, 4, 26, 10, 30, 0),
),
GitHubWebhookDelivery(
tenant_id=1,
delivery_id="delivery-1",
event_type="push",
repo_binding_id=repo.id,
project_id=project.id,
installation_id="install-1",
status="matched",
payload={"repository": {"full_name": "pmflow/pmflow-app"}},
received_at=datetime(2026, 4, 26, 10, 31, 0),
),
]
)
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}/execution-summary")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["promptPackage"]["package_text"] == "真实开发提示词"
assert payload["repoBindings"][0]["full_name"] == "pmflow/pmflow-app"
assert payload["repoBindings"][0]["task_link_count"] == 1
assert payload["activeDevTasks"][0]["title"] == "接入真实执行摘要"
assert payload["activeDevTasks"][0]["code_link"]["pr_number"] == 128
assert payload["recentProgressEvents"][0]["summary"] == "真实 commit 已回流"
assert payload["recentDeliveries"][0]["repository_full_name"] == "pmflow/pmflow-app"
assert payload["warnings"] == []
@pytest.mark.asyncio
async def test_execution_handoff_creates_prompt_package_and_progress_event(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": ["生成可追踪 handoff 起点"]},
manual_overrides=None,
title="发起真实执行交接",
description="从任务、项目记忆和仓库生成 PromptPackage",
required_skill_tag_ids=[],
status="confirmed",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="execution-bridge",
default_branch="main",
installation_id="install-2",
status="active",
)
session.add_all([task, repo])
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.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"runtime_target": "local_bridge",
"note": "交给本地桥接执行",
},
)
assert response.status_code == 200
payload = response.json()["data"]
assert payload["promptPackage"]["task_id"] == str(task.id)
assert payload["promptPackage"]["package_kind"] == "execution_handoff"
assert "发起真实执行交接" in payload["promptPackage"]["package_text"]
assert "pmflow/execution-bridge" in payload["promptPackage"]["package_text"]
assert payload["recentProgressEvents"][0]["event_type"] == "handoff_created"
assert payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/execution-bridge"
assert payload["execution"]["status"] == "pushed"
assert payload["warnings"] == []
prompt_rows = (await session.execute(select(PromptPackage).where(PromptPackage.task_id == task.id))).scalars().all()
event_rows = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalars().all()
assert len(prompt_rows) == 1
assert len(event_rows) == 1
assert event_rows[0].payload["runtime_target"] == "local_bridge"
@pytest.mark.asyncio
async def test_execution_handoff_without_repo_creates_draft_warning(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=None,
manual_overrides=None,
title="缺少仓库也能建草案",
description="仓库后续绑定",
required_skill_tag_ids=[],
status="confirmed",
priority="medium",
)
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.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={"task_id": str(task.id), "runtime_target": "manual"},
)
assert response.status_code == 200
payload = response.json()["data"]
assert payload["promptPackage"]["task_id"] == str(task.id)
assert payload["recentProgressEvents"][0]["repo"] is None
assert "repo_bindings_empty" in " ".join(payload["warnings"])
event = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalar_one()
assert event.payload["handoff_state"] == "draft"
@pytest.mark.asyncio
async def test_execution_handoff_consume_records_pickup_event(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": ["领取交接包后进入开发中"]},
manual_overrides=None,
title="领取执行交接包",
description="Codex 消费 PromptPackage",
required_skill_tag_ids=[],
status="confirmed",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="handoff-consume",
default_branch="main",
installation_id="install-3",
status="active",
)
session.add_all([task, repo])
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:
create_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={"task_id": str(task.id), "repo_binding_id": str(repo.id), "runtime_target": "local_bridge"},
)
prompt_package_id = create_response.json()["data"]["promptPackage"]["id"]
consume_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs/{prompt_package_id}/consume",
json={"runtime_target": "codex", "consumer_label": "Codex", "note": "开始开发"},
)
assert consume_response.status_code == 200
payload = consume_response.json()["data"]
assert payload["execution"]["status"] == "coding"
assert payload["recentProgressEvents"][0]["event_type"] == "handoff_consumed"
assert payload["recentProgressEvents"][0]["derived_task_status"] == "coding"
assert payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/handoff-consume"
prompt = (
await session.execute(select(PromptPackage).where(PromptPackage.id == int(prompt_package_id)))
).scalar_one()
latest_handoff = prompt.package_payload["latest_handoff"]
assert latest_handoff["handoff_state"] == "consumed"
assert latest_handoff["consumer_label"] == "Codex"
assert latest_handoff["runtime_target"] == "codex"
event_types = [
row.event_type
for row in (
await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id).order_by(ProgressEvent.id.asc()))
)
.scalars()
.all()
]
assert event_types == ["handoff_created", "handoff_consumed"]
@pytest.mark.asyncio
async def test_execution_progress_writeback_records_branch_pr_commit_and_test(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": ["回流 branch/commit/PR/test"]},
manual_overrides=None,
title="执行结果写回",
description="Codex 或 Local Bridge 写回进展",
required_skill_tag_ids=[],
status="confirmed",
priority="high",
)
repo = RepoBinding(
tenant_id=1,
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="writeback",
default_branch="main",
installation_id="install-4",
status="active",
)
session.add_all([task, repo])
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:
commit_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "commit",
"source": "codex",
"branch_name": "codex/main-flow-d3",
"commit_sha": "abc123def456",
"workflow_state": "coding",
},
)
pr_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "pr_opened",
"source": "github_agent",
"branch_name": "codex/main-flow-d3",
"pr_number": 77,
},
)
test_response = await client.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "test_failed",
"source": "local_bridge",
"branch_name": "codex/main-flow-d3",
"test_summary": "单测失败:ExecutionBridgePage 回写表单缺少断言",
},
)
assert commit_response.status_code == 200
assert commit_response.json()["data"]["execution"]["status"] == "coding"
assert pr_response.status_code == 200
pr_payload = pr_response.json()["data"]
assert pr_payload["execution"]["status"] == "pr_open"
assert pr_payload["execution"]["prStatus"] == "PR #77 · linked"
assert test_response.status_code == 200
test_payload = test_response.json()["data"]
assert test_payload["execution"]["status"] == "failed"
assert test_payload["execution"]["testSummary"] == "单测失败:ExecutionBridgePage 回写表单缺少断言"
assert test_payload["recentProgressEvents"][0]["event_type"] == "test_failed"
assert test_payload["recentProgressEvents"][0]["repo"]["full_name"] == "pmflow/writeback"
link = (await session.execute(select(TaskCodeLink).where(TaskCodeLink.task_id == task.id))).scalar_one()
assert link.branch_name == "codex/main-flow-d3"
assert link.pr_number == 77
assert link.last_commit_sha == "abc123def456"
events = (await session.execute(select(ProgressEvent).where(ProgressEvent.task_id == task.id))).scalars().all()
assert [event.event_type for event in events] == ["commit", "pr_opened", "test_failed"]
@pytest.mark.asyncio
async def test_execution_progress_writeback_rejects_repo_outside_project(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
other_project = Project(
tenant_id=1,
name="其它回写项目",
title="其它回写项目",
description="不能被当前项目进展事件引用",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(other_project)
await session.flush()
session.add(UserProject(tenant_id=1, user_id=user.id, project_id=other_project.id))
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="当前项目宪章",
content={"business_goal": "校验 repo project 边界"},
version=1,
status="aligned",
)
repo = RepoBinding(
tenant_id=1,
project_id=other_project.id,
provider="github",
owner="pmflow",
repo_name="other-writeback",
default_branch="main",
status="active",
)
session.add_all([charter, repo])
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=None,
manual_overrides=None,
title="当前项目任务",
description="不能绑定其它项目 repo",
required_skill_tag_ids=[],
status="confirmed",
priority="medium",
)
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.post(
f"/pmflow-os/projects/{project.id}/execution-progress-events",
json={
"task_id": str(task.id),
"repo_binding_id": str(repo.id),
"event_type": "commit",
"source": "codex",
"branch_name": "codex/wrong-repo",
},
)
assert response.status_code == 422
assert response.json()["message"] == "repo_binding_not_found"
@pytest.mark.asyncio
async def test_execution_handoff_rejects_task_outside_project(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
other_project = Project(
tenant_id=1,
name="其它真实项目",
title="其它真实项目",
description="不能被当前项目交接",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(other_project)
await session.flush()
session.add(UserProject(tenant_id=1, user_id=user.id, project_id=other_project.id))
await session.flush()
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=other_project.id,
created_by=user.id,
title="其它项目宪章",
content={"business_goal": "不能跨项目交接"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
other_task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=other_project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload=None,
manual_overrides=None,
title="其它项目任务",
description="不能被当前项目 handoff",
required_skill_tag_ids=[],
status="confirmed",
priority="medium",
)
session.add(other_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.post(
f"/pmflow-os/projects/{project.id}/execution-handoffs",
json={"task_id": str(other_task.id), "runtime_target": "codex"},
)
assert response.status_code == 404
assert response.json()["message"] == "task_not_found"
@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