132 lines
6.1 KiB
Python
132 lines
6.1 KiB
Python
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.auth.project_dependencies import ProjectContext
|
|
from app.domain.schemas.operational_data import TaskDataQuery
|
|
from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
|
|
def context():
|
|
return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", project_role="member")
|
|
|
|
|
|
def settings():
|
|
return TaskDataSettings(_env_file=None, server_url="http://server.test", peripheral_url="http://sample.test")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_actual_read_has_scoped_headers_and_synthetic_provenance():
|
|
ctx = context()
|
|
def handler(request):
|
|
assert request.method == "GET"
|
|
assert request.url.path == "/api/sample/v1/demo-data"
|
|
assert not request.url.params
|
|
assert request.headers["X-Project-Id"] == str(ctx.project_id)
|
|
return httpx.Response(200, json={
|
|
"project_id": str(ctx.project_id),
|
|
"source_id": "tjwater_v2:peripheral_sample.demo_datasets:water-demo-v1",
|
|
"source_revision": "sha256:test",
|
|
"points": [{"point_id": "DEMO-P001"}],
|
|
"observations": [
|
|
{"point_id": "DEMO-P001", "observed_at": "2026-09-23T00:30:00Z", "value": 0.22},
|
|
{"point_id": "DEMO-P002", "observed_at": "2026-09-23T00:30:00Z", "value": 1.0},
|
|
{"point_id": "DEMO-P001", "observed_at": "2026-09-24T00:00:00Z", "value": 2.0},
|
|
],
|
|
})
|
|
result = await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(),
|
|
query=TaskDataQuery(source="peripheral_observations", point_id="DEMO-P001",
|
|
start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"),
|
|
transport=httpx.MockTransport(handler))
|
|
assert result.origin == "synthetic"
|
|
assert result.data["items"][0]["value"] == 0.22
|
|
assert len(result.data["items"]) == 1
|
|
assert result.data["point_id"] == "DEMO-P001"
|
|
assert result.data["window"] == {
|
|
"start": "2026-09-23T00:00:00+00:00", "end": "2026-09-23T01:00:00+00:00",
|
|
}
|
|
assert result.source_ref.startswith(
|
|
"peripheral_demo_data:DEMO-P001:2026-09-23T00:00:00+00:00:"
|
|
"2026-09-23T01:00:00+00:00:sha256:"
|
|
)
|
|
assert not result.truncated
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_historical_observations_keep_monitoring_endpoint():
|
|
def handler(request):
|
|
assert request.url.path == "/api/sample/v1/monitoring/observations"
|
|
assert request.url.params["point_id"] == "100026"
|
|
return httpx.Response(200, json={"items": [], "truncated": False, "origin": "historical_replay"})
|
|
result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
|
query=TaskDataQuery(source="peripheral_observations", point_id="100026",
|
|
start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"),
|
|
transport=httpx.MockTransport(handler))
|
|
assert result.origin == "system_data"
|
|
assert result.data["origin"] == "historical_replay"
|
|
assert result.source_ref.startswith("peripheral_historical_replay:peripheral_observations:sha256:")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("origin,expected", [("historical_replay", "system_data"), ("synthetic", "synthetic")])
|
|
async def test_monitoring_point_provenance_follows_source_not_transport(origin, expected):
|
|
result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
|
query=TaskDataQuery(source="peripheral_points"),
|
|
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"origin": origin, "items": []})))
|
|
assert result.origin == expected
|
|
assert result.data["origin"] == origin
|
|
assert result.source_ref.startswith(
|
|
"peripheral_historical_replay:" if origin == "historical_replay" else "peripheral_points:"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_monitoring_data_without_known_provenance_is_not_saved_as_fact():
|
|
with pytest.raises(TaskDataError, match="来源"):
|
|
await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
|
query=TaskDataQuery(source="peripheral_points"),
|
|
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"items": []})))
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_paged_data_is_marked_incomplete_and_receipt_is_content_bound():
|
|
values = [{"items": [1], "total": 2}, {"items": [2], "total": 2}]
|
|
def handler(request):
|
|
return httpx.Response(200, json=values.pop(0))
|
|
kwargs = dict(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
|
query=TaskDataQuery(source="scada_devices"), transport=httpx.MockTransport(handler))
|
|
first, second = await read_task_data(**kwargs), await read_task_data(**kwargs)
|
|
assert first.truncated and second.truncated
|
|
assert first.source_ref != second.source_ref
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("shape", ["root", "items", "list"])
|
|
async def test_cross_project_or_failed_query_cannot_become_evidence(shape):
|
|
ctx = context()
|
|
foreign = {"project_id": str(uuid4()), "value": 0.22}
|
|
payload = {"root": foreign, "items": {"project_id": str(ctx.project_id), "items": [foreign]},
|
|
"list": [foreign]}[shape]
|
|
def handler(request):
|
|
return httpx.Response(200, json=payload)
|
|
with pytest.raises(TaskDataError, match="其他项目"):
|
|
await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(),
|
|
query=TaskDataQuery(source="workbench_runtime"), transport=httpx.MockTransport(handler))
|
|
|
|
|
|
def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count():
|
|
with pytest.raises(ValidationError):
|
|
TaskDataQuery(source="http://arbitrary.test/delete")
|
|
with pytest.raises(ValidationError):
|
|
TaskDataQuery(source="scada_readings", device_ids=["p1"],
|
|
start="2026-09-01T00:00:00Z", end="2026-09-23T00:00:00Z")
|
|
with pytest.raises(ValidationError):
|
|
TaskDataQuery(source="peripheral_observations")
|