from __future__ import annotations from pydantic import BaseModel import pytest from sync_state_machine.common.collection import DataCollection from sync_state_machine.common.sync_node import SyncNode from sync_state_machine.domain.project_detail.api_handler import ProjectDetailApiHandler from sync_state_machine.domain.project_detail.sync_node import ProjectDetailSyncNode from sync_state_machine.datasource.task_result import TaskStatus class _ProjectSchema(BaseModel): id: str class _ProjectNode(SyncNode[_ProjectSchema]): node_type = "project" schema = _ProjectSchema def test_project_detail_load_injects_project_id_before_validation() -> None: collection = DataCollection("remote") project = _ProjectNode( node_id="project-node-1", data_id="project-1", data={"id": "project-1"}, context={ "all_info": { "project_detail": [ { "id": "detail-1", "key": "plan_construction", "value": [{"date": "2026-01-01", "capacity": 12.5}], } ] } }, ) collection._nodes[project.node_id] = project handler = ProjectDetailApiHandler() handler.set_collection(collection) nodes = __import__("asyncio").run(handler.load()) assert len(nodes) == 1 detail_data = nodes[0].get_data() assert detail_data is not None assert detail_data["project_id"] == "project-1" assert detail_data["id"] == "detail-1" class _FailingApiClient: async def request(self, method, endpoint, params=None, data=None, **kwargs): return {"code": 500, "message": "boom"} class _RecordingApiClient: def __init__(self) -> None: self.calls = [] async def request(self, method, endpoint, params=None, data=None, **kwargs): self.calls.append( { "method": method, "endpoint": endpoint, "params": params, "data": data, "kwargs": kwargs, } ) return {"code": 200, "message": "OK"} @pytest.mark.asyncio async def test_project_detail_create_failure_preserves_push_id_and_biz_id() -> None: collection = DataCollection("remote") project = _ProjectNode( node_id="project-node-1", data_id="project-1", data={"id": "project-1"}, context={"all_info": {"project_detail": []}}, ) await collection.add(project) node = ProjectDetailSyncNode( node_id="detail-node-1", data_id="local-detail-1", data={ "id": "local-detail-1", "project_id": "project-1", "key": "production", "value": [{"date": "2026-01-01", "capacity": 1.0}], }, ) handler = ProjectDetailApiHandler() handler.set_collection(collection) handler.api_client = _FailingApiClient() results = await handler.create_all([node]) assert len(results) == 1 result = results[0] assert result.status == TaskStatus.FAILED assert result.error == "API error: boom" assert isinstance(result.push_id, str) and result.push_id assert result.metadata["biz_id"] == "project-1" assert result.metadata["project_id"] == "project-1" assert result.metadata["key"] == "production" @pytest.mark.asyncio async def test_project_detail_production_payload_excludes_ic_plan_dates() -> None: collection = DataCollection("remote") project = _ProjectNode( node_id="project-node-1", data_id="project-1", data={"id": "project-1"}, context={"all_info": {"project_detail": []}}, ) await collection.add(project) node = ProjectDetailSyncNode( node_id="detail-node-1", data_id="local-detail-1", data={ "id": "local-detail-1", "project_id": "project-1", "key": "production", "value": [{"date": "2026-01-01", "capacity": 1.0}], "plan_production_date": "2026-01-01", "plan_full_production_date": "2026-06-01", "ic_plan_first_production_date": "2026-02-01", "ic_plan_full_production_date": "2026-07-01", "actual_production_date": "2026-01-15", "actual_full_production_date": "2026-03-15", }, ) handler = ProjectDetailApiHandler() handler.set_collection(collection) handler.api_client = _RecordingApiClient() results = await handler.create_all([node]) assert len(results) == 1 assert results[0].status == TaskStatus.IN_PROGRESS assert len(handler.api_client.calls) == 1 request_payload = handler.api_client.calls[0]["data"]["data"] assert request_payload["plan_production_date"] == "2026-01-01" assert request_payload["plan_full_production_date"] == "2026-06-01" assert request_payload["actual_production_date"] == "2026-01-15" assert request_payload["actual_full_production_date"] == "2026-03-15" assert "ic_plan_first_production_date" not in request_payload assert "ic_plan_full_production_date" not in request_payload @pytest.mark.asyncio async def test_project_detail_create_all_keeps_partial_results_when_schema_check_errors() -> None: collection = DataCollection("remote") project = _ProjectNode( node_id="project-node-1", data_id="project-1", data={"id": "project-1"}, context={"all_info": {"project_detail": []}}, ) await collection.add(project) valid_node = ProjectDetailSyncNode( node_id="detail-node-1", data_id="local-detail-1", data={ "id": "local-detail-1", "project_id": "project-1", "key": "plan_construction", "value": [{"date": "2026-01-01", "capacity": 1.0}], }, ) invalid_node = ProjectDetailSyncNode( node_id="detail-node-2", data_id="local-detail-2", data={ "id": "local-detail-2", "project_id": "project-1", "key": "production", "value": [{"date": "2026-01-02", "capacity": 2.0}], }, ) handler = ProjectDetailApiHandler() handler.set_collection(collection) handler.api_client = _RecordingApiClient() class _ExplodingSchema: @classmethod def model_validate(cls, data): raise RuntimeError("schema exploded") original_get_update_config = handler._get_update_config def _patched_get_update_config(key): config = original_get_update_config(key) if key == "production": assert config is not None return _ExplodingSchema, config[1] return config handler._get_update_config = _patched_get_update_config results = await handler.create_all([valid_node, invalid_node]) assert len(results) == 2 assert results[0].status == TaskStatus.IN_PROGRESS assert results[1].status == TaskStatus.FAILED assert results[1].error == "schema exploded" assert len(handler.api_client.calls) == 1 assert handler.api_client.calls[0]["endpoint"] == "/project/plan_construction" def test_project_detail_load_allows_same_project_key_with_different_data_id() -> None: collection = DataCollection("remote") project = _ProjectNode( node_id="project-node-1", data_id="project-1", data={"id": "project-1"}, ) collection._nodes[project.node_id] = project existing_detail = ProjectDetailSyncNode( node_id="detail-node-1", data_id="detail-old", data={ "id": "detail-old", "project_id": "project-1", "key": "actual_construction", "value": [{"date": "2026-01-01", "capacity": 1.0}], }, context={"project_id": "project-1"}, ) collection._nodes[existing_detail.node_id] = existing_detail collection._data_id_to_node_id[existing_detail.data_id] = existing_detail.node_id collection._node_id_to_data_id[existing_detail.node_id] = existing_detail.data_id handler = ProjectDetailApiHandler() handler.set_collection(collection) node = handler._create_node( { "id": "detail-new", "project_id": "project-1", "key": "actual_construction", "value": [{"date": "2026-01-02", "capacity": 2.0}], } ) assert node.node_id != existing_detail.node_id assert node.data_id == "detail-new" assert node.context["project_id"] == "project-1"