267 lines
8.4 KiB
Python
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"
|