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()