235 lines
7.3 KiB
Python
235 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from sync_state_machine.common.collection import DataCollection
|
|
from sync_state_machine.common.sync_node import SyncNode
|
|
from sync_state_machine.domain.material_detail.api_handler import MaterialDetailApiHandler
|
|
from sync_state_machine.domain.project.api_handler import ProjectApiHandler
|
|
from sync_state_machine.datasource.task_result import TaskStatus
|
|
|
|
|
|
class _MaterialDetailSchema(BaseModel):
|
|
id: str
|
|
project_id: str | None = None
|
|
parent_id: str
|
|
date: str | None = None
|
|
quantity: float
|
|
remark: str | None = None
|
|
is_complete: bool | None = None
|
|
|
|
|
|
class _MaterialDetailNode(SyncNode[_MaterialDetailSchema]):
|
|
node_type = "material_detail"
|
|
schema = _MaterialDetailSchema
|
|
|
|
|
|
class _ProjectSchema(BaseModel):
|
|
id: str
|
|
|
|
|
|
class _ProjectNode(SyncNode[_ProjectSchema]):
|
|
node_type = "project"
|
|
schema = _ProjectSchema
|
|
|
|
|
|
class _MockApiClient:
|
|
def __init__(self) -> None:
|
|
self.requests: list[dict[str, Any]] = []
|
|
|
|
async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
self.requests.append({"method": method, "endpoint": endpoint, **kwargs})
|
|
return {"code": 200, "message": "OK", "data": {"biz_id": kwargs.get("data", {}).get("biz_id")}}
|
|
|
|
|
|
class _FailingApiClient(_MockApiClient):
|
|
async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
self.requests.append({"method": method, "endpoint": endpoint, **kwargs})
|
|
raise Exception("API error: failed request")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_material_detail_update_returns_in_progress_with_shared_push_id() -> None:
|
|
handler = MaterialDetailApiHandler()
|
|
handler.api_client = _MockApiClient()
|
|
|
|
node = _MaterialDetailNode(
|
|
node_id="detail-node-1",
|
|
data_id="detail-1",
|
|
data={
|
|
"id": "detail-1",
|
|
"parent_id": "material-1",
|
|
"quantity": 10.0,
|
|
"remark": "new",
|
|
},
|
|
origin_data={
|
|
"id": "detail-1",
|
|
"parent_id": "material-1",
|
|
"quantity": 5.0,
|
|
"remark": None,
|
|
},
|
|
context={"material_id": "material-1"},
|
|
)
|
|
|
|
results = await handler.update_all([node])
|
|
|
|
assert len(results) == 1
|
|
assert results[0].status == TaskStatus.IN_PROGRESS
|
|
assert results[0].task_id
|
|
assert handler.api_client.requests[0]["endpoint"] == "/material/detail/update"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_material_detail_create_failure_preserves_push_id_for_trace_lookup() -> None:
|
|
handler = MaterialDetailApiHandler()
|
|
handler.api_client = _FailingApiClient()
|
|
|
|
node = _MaterialDetailNode(
|
|
node_id="detail-node-create-1",
|
|
data_id="",
|
|
data={
|
|
"id": "runtime-detail-1",
|
|
"project_id": "project-1",
|
|
"parent_id": "material-1",
|
|
"date": "2025-03-01",
|
|
"quantity": 4.0,
|
|
"remark": None,
|
|
"is_complete": False,
|
|
},
|
|
)
|
|
|
|
results = await handler.create_all([node])
|
|
|
|
assert len(results) == 1
|
|
assert results[0].status == TaskStatus.FAILED
|
|
assert results[0].push_id
|
|
assert handler.api_client.requests[0]["endpoint"] == "/material/detail/create"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_material_detail_update_failure_preserves_push_id_for_trace_lookup() -> None:
|
|
handler = MaterialDetailApiHandler()
|
|
handler.api_client = _FailingApiClient()
|
|
|
|
node = _MaterialDetailNode(
|
|
node_id="detail-node-update-fail-1",
|
|
data_id="detail-1",
|
|
data={
|
|
"id": "detail-1",
|
|
"parent_id": "material-1",
|
|
"quantity": 10.0,
|
|
"remark": "new",
|
|
},
|
|
origin_data={
|
|
"id": "detail-1",
|
|
"parent_id": "material-1",
|
|
"quantity": 5.0,
|
|
"remark": None,
|
|
},
|
|
context={"material_id": "material-1"},
|
|
)
|
|
|
|
results = await handler.update_all([node])
|
|
|
|
assert len(results) == 1
|
|
assert results[0].status == TaskStatus.FAILED
|
|
assert results[0].push_id
|
|
assert handler.api_client.requests[0]["endpoint"] == "/material/detail/update"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_material_detail_load_uses_project_aggregate_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
collection = DataCollection("remote")
|
|
project = _ProjectNode(
|
|
node_id="project-node-1",
|
|
data_id="project-1",
|
|
data={"id": "project-1"},
|
|
)
|
|
await collection.add(project)
|
|
|
|
handler = MaterialDetailApiHandler()
|
|
handler.set_collection(collection)
|
|
handler.set_handler_config({"load_method": "aggregate"})
|
|
handler.api_client = _MockApiClient()
|
|
|
|
async def _fake_get_aggregate(client, project_id: str, *, domains: list[str], limit: int = 10000):
|
|
assert project_id == "project-1"
|
|
assert domains == ["material_details"]
|
|
assert limit == 10000
|
|
return {
|
|
"meta": {
|
|
"project_id": project_id,
|
|
"requested_domains": domains,
|
|
"domain_limit": limit,
|
|
"truncated_domains": [],
|
|
},
|
|
"data": {
|
|
"material_details": [
|
|
{
|
|
"id": "detail-1",
|
|
"project_id": project_id,
|
|
"parent_id": "material-1",
|
|
"date": "2025-03-01",
|
|
"quantity": 2.0,
|
|
"remark": None,
|
|
"is_complete": False,
|
|
}
|
|
],
|
|
"construction_details": [],
|
|
"contract_settlement_details": [],
|
|
"power_details": [],
|
|
},
|
|
}
|
|
|
|
monkeypatch.setattr(ProjectApiHandler, "get_aggregate", _fake_get_aggregate)
|
|
|
|
nodes = await handler.load()
|
|
|
|
assert len(nodes) == 1
|
|
assert nodes[0].data_id == "detail-1"
|
|
detail_data = nodes[0].get_data()
|
|
assert detail_data is not None
|
|
assert detail_data["project_id"] == "project-1"
|
|
assert handler.api_client.requests == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_material_detail_load_aggregate_raises_on_truncated_domain(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
collection = DataCollection("remote")
|
|
project = _ProjectNode(
|
|
node_id="project-node-1",
|
|
data_id="project-1",
|
|
data={"id": "project-1"},
|
|
)
|
|
await collection.add(project)
|
|
|
|
handler = MaterialDetailApiHandler()
|
|
handler.set_collection(collection)
|
|
handler.set_handler_config({"load_method": "aggregate"})
|
|
handler.api_client = _MockApiClient()
|
|
|
|
async def _fake_get_aggregate(client, project_id: str, *, domains: list[str], limit: int = 10000):
|
|
assert domains == ["material_details"]
|
|
return {
|
|
"meta": {
|
|
"project_id": project_id,
|
|
"requested_domains": domains,
|
|
"domain_limit": limit,
|
|
"truncated_domains": ["material_details"],
|
|
},
|
|
"data": {
|
|
"material_details": [],
|
|
"construction_details": [],
|
|
"contract_settlement_details": [],
|
|
"power_details": [],
|
|
},
|
|
}
|
|
|
|
monkeypatch.setattr(ProjectApiHandler, "get_aggregate", _fake_get_aggregate)
|
|
|
|
with pytest.raises(RuntimeError, match="truncated domain material_details"):
|
|
await handler.load()
|