"""Opt-in PostgreSQL lifecycle check; all schema/data changes roll back.""" import json import os from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 import httpx import psycopg import pytest from psycopg.rows import dict_row from app.auth.permissions import permissions_for_context from app.auth.project_dependencies import ProjectContext from app.core.config import get_pgconn_string from app.domain.domain_errors import DomainError from app.domain.schemas.operational_task import ( ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand, AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand, NotificationAcknowledgeCommand, OperationalTaskCreate, ) from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository from app.infra.integrations.operational_actions import ( ActionDispatchOutcome, HttpOperationalActionDispatcher, OperationalActionSettings, ) from app.services.operational_tasks import OperationalTaskService pytestmark = pytest.mark.skipif( os.environ.get("OPERATIONAL_TASKS_STORAGE_TEST") != "1", reason="explicit PostgreSQL test opt-in required", ) @pytest.fixture def anyio_backend(): return "asyncio" @pytest.mark.anyio async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(): migration = "\n".join( (Path(__file__).parents[2] / f"resources/sql/{name}").read_text() for name in ("014_operational_tasks.sql", "015_workbench_events_and_task_runs.sql") ) # The migration is normally atomic itself. This test owns an outer rollback # transaction, so it executes its statements without transaction delimiters. migration = migration.replace("\nBEGIN;", "").replace("\nCOMMIT;", "") context = ProjectContext(project_id=uuid4(), project_code="isolated-storage-test", user_id=uuid4(), project_role="member") conn = await psycopg.AsyncConnection.connect(get_pgconn_string(), row_factory=dict_row, connect_timeout=3) try: async with conn.transaction(force_rollback=True): await conn.execute(migration) repository = OperationalTaskRepository(conn, context.project_id) service = OperationalTaskService(repository, context, permissions_for_context(context)) now = datetime.now(timezone.utc) command = OperationalTaskCreate( request_id=uuid4(), task_type="event_investigation", workflow="water_quality_complaint", title="AUTOTEST 合成调查", summary="隔离事务验证,不是生产投诉", source={"kind": "operator", "event_kind": "water_quality_complaint"}, trigger={"kind": "manual", "occurred_at": now, "detail": "transactional integration test"}, ) task = await service.create(command) assert (await service.create(command)).task_id == task.task_id assert task.status == "queued" queued_rows = await repository.list_tasks( task_type=None, status="queued", limit=100, offset=0 ) assert [row["task_id"] for row in queued_rows] == [str(task.task_id)] all_rows = await repository.list_tasks( task_type=None, status=None, limit=100, offset=0 ) assert [row["task_id"] for row in all_rows] == [str(task.task_id)] claimed_run = await service.claim_run(task.task_id, task.active_run.run_id, AgentRunCommand( request_id=task.active_run.execution_request_id, expected_revision=task.active_run.revision, source_session_id="test-investigation-session", )) task = await service.detail(task.task_id) assert task.active_run == claimed_run artifact = AgentArtifactsCommand.model_validate({ "request_id": str(uuid4()), "source_session_id": "test-investigation-session", "run_id": str(task.active_run.run_id), "evidence": [{"evidence_id": "synthetic-1", "kind": "synthetic", "summary": "合成采样输入", "source_label": "事务集成测试", "observed_at": now.isoformat()}], "plan": {"schema": "execution-plan@1", "title": "调查", "reason": "等待采样", "nodes": [{"id": "review", "title": "核对输入", "owner": "agent", "status": "completed", "detail": "已读取合成输入", "evidence_refs": ["synthetic-1"]}, {"id": "sample", "title": "现场采样", "owner": "human", "status": "waiting", "detail": "等待外部反馈", "evidence_refs": []}], "edges": [{"from": "review", "to": "sample"}]}, "report": {"title": "调查阶段结论", "current_conclusion": "待现场核验", "conclusion_boundary": "合成输入", "evidence_refs": ["synthetic-1"], "pending_items": ["现场采样"]}, }) published = await service.publish_artifacts(task.task_id, artifact) assert published.task.status == "running" assert published.task.latest_report.report.evidence_refs == ["synthetic-1"] assert (await service.publish_artifacts(task.task_id, artifact)).report_revision == 1 task = await service.complete_run( task.task_id, published.task.active_run.run_id, AgentRunCommand( request_id=uuid4(), expected_revision=published.task.active_run.revision, source_session_id="test-investigation-session", ), ) assert task.status == "waiting_human" action = await service.propose_action(task.task_id, ActionProposalCommand( request_id=uuid4(), kind="create_work_order", reason="需要现场采样", evidence_refs=["synthetic-1"], payload={"title": "采样", "description": "合成核验", "work_order_type": "sampling"}, )) member_notifications, member_next = await service.list_notifications( unread_only=False, limit=100, offset=0 ) assert member_next is None assert member_notifications assert all(item.task_id == task.task_id for item in member_notifications) action_pending = next( item for item in member_notifications if item.kind == "action_pending" ) task_created = next( item for item in member_notifications if item.kind == "task_created" ) assert action_pending.required_permission == action.required_approval_permission viewer_context = ProjectContext( project_id=context.project_id, project_code=context.project_code, user_id=uuid4(), project_role="viewer", ) viewer_service = OperationalTaskService( OperationalTaskRepository(conn, viewer_context.project_id), viewer_context, permissions_for_context(viewer_context), ) viewer_notifications, _ = await viewer_service.list_notifications( unread_only=True, limit=100, offset=0 ) assert task_created.notification_id in { item.notification_id for item in viewer_notifications } assert action_pending.notification_id not in { item.notification_id for item in viewer_notifications } acknowledged = await service.acknowledge_notification( task_created.notification_id, NotificationAcknowledgeCommand( request_id=uuid4(), expected_version=task_created.version ), ) assert acknowledged.status == "read" assert acknowledged.task_id == task.task_id persisted_service = OperationalTaskService( OperationalTaskRepository(conn, context.project_id), context, permissions_for_context(context), ) persisted_notifications, _ = await persisted_service.list_notifications( unread_only=False, limit=100, offset=0 ) persisted_created = next( item for item in persisted_notifications if item.notification_id == task_created.notification_id ) assert persisted_created.status == "read" assert persisted_created.version == task_created.version + 1 assert persisted_created.read_by == f"principal:{context.user_id}" unread_notifications, _ = await persisted_service.list_notifications( unread_only=True, limit=100, offset=0 ) assert task_created.notification_id not in { item.notification_id for item in unread_notifications } viewer_after_member_ack, _ = await viewer_service.list_notifications( unread_only=True, limit=100, offset=0 ) assert task_created.notification_id in { item.notification_id for item in viewer_after_member_ack } action = await service.decide_action(task.task_id, action.action_id, ActionDecisionCommand( request_id=uuid4(), expected_version=action.version, decision="approved", )) assert action.execution_status == "not_submitted" class NoAdapter: async def dispatch(self, **kwargs): return ActionDispatchOutcome("unsupported", "测试不执行外部动作") action = await service.submit_action(task.task_id, action.action_id, ActionExecuteCommand( request_id=uuid4(), expected_version=action.version, ), NoAdapter(), None) assert action.execution_status == "unsupported" fresh_service = OperationalTaskService(OperationalTaskRepository(conn, context.project_id), context, permissions_for_context(context)) assert (await fresh_service.detail(task.task_id)).actions[0].action_id == action.action_id before_feedback = await fresh_service.detail(task.task_id) feedback = ExternalFeedbackCommand( request_id=uuid4(), source_event_id="synthetic-sampling-order", source_revision=2, status="completed", occurred_at=now, detail="合成采样工单完成,调查仍需分析", evidence={"evidence_id": "synthetic-sampling-order:2", "kind": "synthetic", "summary": "合成采样结果已回传", "source_label": "事务集成外围工单", "observed_at": now}, ) resumed = (await fresh_service.external_feedback(task.task_id, feedback)).task assert resumed.status == "queued" assert resumed.execution_generation == before_feedback.execution_generation assert resumed.execution_request_id == before_feedback.execution_request_id assert resumed.agent_session_id is None assert len(resumed.evidence) == 2 replayed = (await fresh_service.external_feedback(task.task_id, feedback)).task assert replayed.execution_generation == resumed.execution_generation assert replayed.revision == resumed.revision claimed_run = await fresh_service.claim_run( task.task_id, resumed.active_run.run_id, AgentRunCommand( request_id=resumed.active_run.execution_request_id, expected_revision=resumed.active_run.revision, source_session_id="test-investigation-followup-session", ), ) resumed = await fresh_service.detail(task.task_id) assert resumed.active_run == claimed_run with pytest.raises(DomainError) as stale_session: await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={ "request_id": uuid4(), "run_id": resumed.active_run.run_id, })) assert stale_session.value.code == "SESSION_MISMATCH" assert (await fresh_service.detail(task.task_id)).agent_session_id == resumed.agent_session_id contract_output = os.environ.get("OPERATIONAL_TASK_CONTRACT_OUTPUT") if contract_output: # Optional generated test artifact for cross-repository consumers. Path(contract_output).write_text( (await fresh_service.detail(task.task_id)).model_dump_json(indent=2, by_alias=True), encoding="utf-8" ) outsider = OperationalTaskService(OperationalTaskRepository(conn, uuid4()), context, permissions_for_context(context)) with pytest.raises(DomainError): await outsider.detail(task.task_id) sampling = await fresh_service.propose_action(task.task_id, ActionProposalCommand( request_id=uuid4(), kind="create_work_order", reason="需要补充采样", evidence_refs=["synthetic-1"], payload={"title": "补充采样", "description": "隔离事务测试", "work_order_type": "sampling"}, )) sampling = await fresh_service.decide_action(task.task_id, sampling.action_id, ActionDecisionCommand( request_id=uuid4(), expected_version=sampling.version, decision="approved", )) calls, execution_ids = [], [] receipt_ready = False order_id = str(uuid4()) def sample_transport(request): calls.append(request.method) assert request.headers["X-Project-Id"] == str(context.project_id) if request.method == "POST": execution_ids.append(json.loads(request.content)["request_id"]) raise httpx.ReadTimeout("simulated lost response", request=request) if not receipt_ready: return httpx.Response(404, json={"effect": "none"}) assert request.url.path.endswith(execution_ids[0]) return httpx.Response(200, json={"request_id": execution_ids[0], "result": { "work_order_id": order_id, "project_id": str(context.project_id), "created_at": now.isoformat(), "origin": "synthetic", "status": "open", }}) dispatcher = HttpOperationalActionDispatcher( OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test"), transport=httpx.MockTransport(sample_transport), ) submission = ActionExecuteCommand(request_id=uuid4(), expected_version=sampling.version) unknown = await fresh_service.submit_action(task.task_id, sampling.action_id, submission, dispatcher, "Bearer TEST_ONLY") assert unknown.execution_status == "unknown" assert calls == ["POST", "GET"] receipt_ready = True confirmed = await fresh_service.reconcile_action(task.task_id, sampling.action_id, ActionReconcileCommand(request_id=uuid4(), expected_version=unknown.version), dispatcher, "Bearer TEST_ONLY") assert confirmed.execution_status == "succeeded" assert confirmed.receipt.receipt_id == order_id assert confirmed.receipt.external_status == "open" assert confirmed.execution_request_id == unknown.execution_request_id assert calls == ["POST", "GET", "GET"] await fresh_service.submit_action(task.task_id, sampling.action_id, submission, dispatcher, "Bearer TEST_ONLY") assert calls == ["POST", "GET", "GET"] assert (await fresh_service.detail(task.task_id)).status == "queued" finally: await conn.close()