Files
next-tjwater-server/tests/integration/test_operational_tasks_storage.py
T
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

353 lines
20 KiB
Python

"""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 (
"008_workbench_alert_events.sql",
"011_workbench_work_orders.sql",
"014_operational_tasks.sql",
"015_workbench_events_and_task_runs.sql",
"017_workbench_schema_comments.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)
comment_rows = await (
await conn.execute(
"""SELECT c.relname, obj_description(c.oid, 'pg_class') AS description
FROM pg_class c
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='workbench' AND c.relkind='r'""",
)
).fetchall()
assert len(comment_rows) >= 17
assert all(row["description"] for row in comment_rows)
messages_migration = (Path(__file__).parents[2] / "resources/sql/024_agent_messages.sql").read_text()
await conn.execute(messages_migration.replace("\nBEGIN;", "").replace("\nCOMMIT;", ""))
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": {"stage": "final", "outcome": "concluded", "stop_reason": None, "data_gaps": [], "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 == "completed"
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_approval_pending"
)
report_notice = next(
item for item in member_notifications
if item.kind == "report_updated"
)
assert not any(item.kind in {
"task_created", "human_input_required", "task_completed"
} for item in member_notifications)
assert report_notice.report_reference.revision == 1
assert report_notice.report_reference.execution_generation == 1
history, next_offset = await service.list_reports(task.task_id, 20, 0)
assert next_offset is None and history[0].revision == 1
assert (await service.report(task.task_id, 1)).report.model_dump() == artifact.report.model_dump()
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 report_notice.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(
report_notice.notification_id,
NotificationAcknowledgeCommand(
request_id=uuid4(), expected_version=report_notice.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 == report_notice.notification_id
)
assert persisted_created.status == "read"
assert persisted_created.version == report_notice.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 report_notice.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 report_notice.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)
await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={
"request_id": uuid4(), "run_id": resumed.active_run.run_id,
"source_session_id": "test-investigation-followup-session",
}))
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"
# A separate investigation ends normally on an evidenced empty query.
missing = await service.create(command.model_copy(update={
"request_id": uuid4(), "title": "AUTOTEST 缺数退出",
}))
missing_run = await service.claim_run(missing.task_id, missing.active_run.run_id, AgentRunCommand(
request_id=missing.active_run.execution_request_id,
expected_revision=missing.active_run.revision, source_session_id="missing-data-session"))
exit_artifact = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="missing-data-session", run_id=missing_run.run_id,
evidence=[{"evidence_id": "empty-sample", "kind": "synthetic", "summary": "查询成功,无采样记录",
"source_label": "合成空查询", "observed_at": now.isoformat()}],
plan={"schema": "execution-plan@1", "title": "调查", "reason": "关键数据不足",
"nodes": [{"id": "query", "title": "核对采样", "owner": "agent", "status": "completed",
"detail": "查询范围内无记录", "evidence_refs": ["empty-sample"]}]},
report={"title": "无法形成结论", "current_conclusion": "无法确定投诉原因",
"conclusion_boundary": "缺少末梢采样", "evidence_refs": ["empty-sample"],
"stage": "final", "outcome": "insufficient_data", "stop_reason": "关键采样数据为空",
"data_gaps": [{"gap_id": "sample", "node_id": "query", "missing_data": "末梢采样",
"scope": "投诉时段", "impact": "无法判断异常来源", "required_data": "末梢采样结果",
"evidence_refs": ["empty-sample"], "blocking": True}]})
exited = await service.publish_artifacts(missing.task_id, exit_artifact)
await service.complete_run(missing.task_id, missing_run.run_id, AgentRunCommand(
request_id=uuid4(), expected_revision=exited.task.active_run.revision,
source_session_id="missing-data-session"))
restored = await fresh_service.detail(missing.task_id)
assert restored.lifecycle_status == "completed"
assert restored.active_run is None
assert restored.latest_report.report.outcome == "insufficient_data"
assert restored.latest_report.report.data_gaps[0].blocking is True
assert not restored.actions
finally:
await conn.close()