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 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_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