Files
next-tjwater-server/tests/api/test_operational_task_dynamic_flow.py
T
jiang 05183aff93
Generic Container CI/CD / test-build-publish (push) Successful in 3m53s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m52s
feat: extend operational investigation workflows
2026-10-30 17:30:19 +08:00

145 lines
7.8 KiB
Python

from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints import operational_tasks as endpoints
from app.auth.project_dependencies import ProjectContext
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import EvidenceRecord
from app.domain.schemas.operational_task import TaskPresentationEventCommand
from app.main import app
@pytest.fixture
def anyio_backend():
return "asyncio"
def _service_context():
return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test",
project_role="member", system_role="service")
def test_dynamic_investigation_routes_are_in_the_public_contract():
paths = app.openapi()["paths"]
assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests"]
assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests/{access_request_id}/decision"]
assert "patch" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/draft"]
assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"]
assert "post" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-cursor"]
context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"]
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
@pytest.mark.anyio
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
from app.domain.domain_errors import DomainError
service = SimpleNamespace(
publish_presentation_event=AsyncMock(side_effect=DomainError("SESSION_MISMATCH", "stale session", status=409)),
presentation_cursor=AsyncMock(return_value={"task_id": str(uuid4()), "execution_generation": 1,
"active_run": None, "event_cursor": 17}),
)
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
with pytest.raises(HTTPException) as error:
await endpoints.publish_operational_task_presentation_event(
uuid4(), TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="stale",
command={"action": "clear_highlights"}),
ctx=_service_context(), conn=object(),
)
assert error.value.status_code == 409
assert error.value.detail["code"] == "SESSION_MISMATCH"
result = await endpoints.get_operational_task_presentation_cursor(uuid4(), ctx=_service_context(), conn=object())
assert result["event_cursor"] == 17 and result["active_run"] is None
@pytest.mark.anyio
async def test_data_query_requires_task_grant_before_source_request(monkeypatch):
service = SimpleNamespace(can_read_source=AsyncMock(return_value=False))
source = AsyncMock()
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
monkeypatch.setattr(endpoints, "read_task_data", source)
with pytest.raises(HTTPException) as error:
await endpoints.query_operational_task_data(
uuid4(), TaskDataQuery(source="scada_devices"), authorization="Bearer AGENT",
ctx=_service_context(), conn=object(),
)
assert error.value.status_code == 403
source.assert_not_awaited()
@pytest.mark.anyio
async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch):
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="scada_devices:sha256:test",
queried_at=now, origin="system_data", data={"items": []}, truncated=False)
evidence = EvidenceRecord(evidence_id=snapshot.source_ref, kind="system_data",
summary="SCADA 设备数据快照", source_label="scada_devices",
source_ref=snapshot.source_ref, observed_at=now, recorded_at=now,
recorded_by="service", has_snapshot=True)
service = SimpleNamespace(can_read_source=AsyncMock(return_value=True),
record_data_snapshot=AsyncMock(return_value=(evidence, snapshot)))
source = AsyncMock(return_value=snapshot)
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
monkeypatch.setattr(endpoints, "read_task_data", source)
result = await endpoints.query_operational_task_data(
uuid4(), TaskDataQuery(source="scada_devices"), authorization="Bearer AGENT",
ctx=_service_context(), conn=object(),
)
assert source.await_args.kwargs["delegated"] is False
assert result.evidence.evidence_id == snapshot.source_ref
service.record_data_snapshot.assert_awaited_once()
@pytest.mark.anyio
async def test_report_endpoints_preserve_interactive_blocks_and_version_context(monkeypatch):
from app.domain.schemas.operational_task import AgentArtifactsCommand, InvestigationReportRevision
from tests.unit.test_report_ui import rich_report
report = rich_report()
snapshot = InvestigationReportRevision(
revision=2, execution_generation=1, source_session_id="session-1",
published_at="2026-10-20T08:00:00Z", published_by="agent", report=report,
)
service = SimpleNamespace(report=AsyncMock(return_value=snapshot),
list_reports=AsyncMock(return_value=([snapshot], None)),
publish_artifacts=AsyncMock(return_value={"report_revision": 2}))
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
task_id = uuid4()
command = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=report)
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
assert service.publish_artifacts.await_args.args[1].report.ui_blocks[0].block.block_id == "pressure"
result = await endpoints.get_investigation_report(task_id, 2, ctx=_service_context(), conn=object())
restored = InvestigationReportRevision.model_validate_json(result.model_dump_json())
assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert restored.revision == 2
schemas = app.openapi()["components"]["schemas"]
assert "ui_blocks" in schemas["InvestigationReportInput"]["properties"]
assert "ui_blocks" in schemas["InvestigationReport"]["properties"]
@pytest.mark.anyio
async def test_presentation_history_route_forwards_read_only_paging_and_domain_errors(monkeypatch):
service = SimpleNamespace(presentation_history=AsyncMock(return_value={"items": []}))
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
task_id = uuid4()
await endpoints.get_operational_task_presentation_history(
task_id, after=7, through=30, limit=20, ctx=_service_context(), conn=object(),
)
service.presentation_history.assert_awaited_once_with(task_id, after=7, through=30, limit=20)
from app.domain.domain_errors import DomainError
service.presentation_history.side_effect = DomainError("TASK_NOT_FOUND", "missing", status=404)
with pytest.raises(HTTPException) as error:
await endpoints.get_operational_task_presentation_history(
task_id, after=0, through=None, limit=100, ctx=_service_context(), conn=object(),
)
assert error.value.status_code == 404