Files
ecm_sync_system/tests/unit/test_project_detail_api_handler.py
T
2026-04-02 11:14:44 +08:00

267 lines
8.4 KiB
Python

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"