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 payload["releaseGate"]["status"] == "empty" assert "缺少执行证据" in payload["releaseGate"]["title"] 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["releaseGate"]["status"] == "blocked" assert "单测失败" in " ".join(test_payload["releaseGate"]["blockingReasons"]) 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_release_gate_marks_test_passed_execution_as_candidate(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="提供 commit / PR / test evidence", required_skill_tag_ids=[], status="confirmed", priority="high", ) repo = RepoBinding( tenant_id=1, project_id=project.id, provider="github", owner="pmflow", repo_name="release-candidate", default_branch="main", 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: 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/release-candidate", "commit_sha": "abc123def456", }, ) 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/release-candidate", "pr_number": 99, }, ) 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_passed", "source": "local_bridge", "branch_name": "codex/release-candidate", "test_summary": "单测与构建全部通过", }, ) assert response.status_code == 200 payload = response.json()["data"] assert payload["releaseGate"]["status"] == "needs_confirmation" assert "发布候选" in payload["releaseGate"]["title"] assert payload["releaseGate"]["blockingReasons"] == [] assert any(item["key"] == "test" and item["status"] == "passed" for item in payload["releaseGate"]["checks"]) assert any(item["type"] == "test_passed" for item in payload["releaseGate"]["evidence"]) @pytest.mark.asyncio async def test_release_gate_blocks_explicit_failed_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": "failed event 必须阻塞发布"}, 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="显式 failed event", required_skill_tag_ids=[], status="confirmed", 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.post( f"/pmflow-os/projects/{project.id}/execution-progress-events", json={ "task_id": str(task.id), "event_type": "failed", "source": "codex", "summary": "Codex 执行失败:typecheck 未通过", }, ) assert response.status_code == 200 payload = response.json()["data"] assert payload["execution"]["status"] == "failed" assert payload["releaseGate"]["status"] == "blocked" assert "typecheck 未通过" in " ".join(payload["releaseGate"]["blockingReasons"]) assert payload["releaseGate"]["evidence"][0]["type"] == "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