first commit
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
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.project.api_handler import ProjectApiHandler
|
||||
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
|
||||
|
||||
|
||||
class _MockApiClient:
|
||||
def __init__(self):
|
||||
self.requests: List[Dict[str, Any]] = []
|
||||
self.push_logs: Dict[str, Dict[str, Any]] = {}
|
||||
self.all_info_calls: List[str] = []
|
||||
|
||||
async def request(self, method: str, endpoint: str, params: Dict[str, Any] | None = None, data: Dict[str, Any] | None = None, **kwargs):
|
||||
if method == "GET" and endpoint == "/project/all_info":
|
||||
project_id = (params or {}).get("project_id")
|
||||
if project_id:
|
||||
self.all_info_calls.append(str(project_id))
|
||||
self.requests.append(
|
||||
{
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"params": params,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
return {"code": 200, "data": {"biz_id": (data or {}).get("biz_id")}}
|
||||
|
||||
async def get_push_logs(self, task_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
return {task_id: self.push_logs.get(task_id, {}) for task_id in task_ids}
|
||||
|
||||
|
||||
def _build_project_node(*, node_id: str, project_id: str, all_info: Dict[str, Any]) -> _ProjectNode:
|
||||
return _ProjectNode(
|
||||
node_id=node_id,
|
||||
data_id=project_id,
|
||||
data={"id": project_id},
|
||||
context={"all_info": all_info},
|
||||
)
|
||||
|
||||
|
||||
def _build_project_detail_node(*, node_id: str, data_id: str, project_id: str, key: str, capacity: float = 12.3) -> ProjectDetailSyncNode:
|
||||
payload = {
|
||||
"id": data_id,
|
||||
"project_id": project_id,
|
||||
"key": key,
|
||||
"value": [{"date": "2026-01-01", "capacity": capacity}],
|
||||
}
|
||||
return ProjectDetailSyncNode(node_id=node_id, data_id=data_id, data=payload)
|
||||
|
||||
|
||||
def _patch_project_all_info_lookup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
collection: DataCollection,
|
||||
call_recorder: List[str] | None = None,
|
||||
) -> None:
|
||||
async def _fake_get_all_info(cls, _client, project_id: str):
|
||||
if call_recorder is not None:
|
||||
call_recorder.append(project_id)
|
||||
node = collection.get_by_data_id("project", project_id)
|
||||
assert node is not None
|
||||
return node.context.get("all_info", {})
|
||||
|
||||
monkeypatch.setattr(ProjectApiHandler, "get_all_info", classmethod(_fake_get_all_info))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_all_uses_project_update_endpoint_and_returns_in_progress() -> None:
|
||||
collection = DataCollection("remote")
|
||||
project = _build_project_node(node_id="p-node", project_id="project-1", all_info={"project_detail": []})
|
||||
await collection.add(project)
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
handler.api_client = _MockApiClient()
|
||||
|
||||
node = _build_project_detail_node(
|
||||
node_id="detail-node-1",
|
||||
data_id="local-detail-1",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
)
|
||||
|
||||
results = await handler.create_all([node])
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].status == TaskStatus.IN_PROGRESS
|
||||
assert isinstance(results[0].task_id, str) and results[0].task_id
|
||||
|
||||
req = handler.api_client.requests[-1]
|
||||
assert req["method"] == "PUT"
|
||||
assert req["endpoint"] == "/project/plan_construction"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_tasks_backfills_real_data_id_from_project_all_info(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
collection = DataCollection("remote")
|
||||
project = _build_project_node(
|
||||
node_id="p-node",
|
||||
project_id="project-1",
|
||||
all_info={
|
||||
"project_detail": [
|
||||
{
|
||||
"id": "remote-detail-1",
|
||||
"project_id": "project-1",
|
||||
"key": "plan_construction",
|
||||
"value": [{"date": "2026-01-01", "capacity": 13.0}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
await collection.add(project)
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
handler.api_client = _MockApiClient()
|
||||
_patch_project_all_info_lookup(monkeypatch, collection=collection)
|
||||
|
||||
node = _build_project_detail_node(
|
||||
node_id="detail-node-1",
|
||||
data_id="local-detail-1",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
)
|
||||
|
||||
create_result = (await handler.create_all([node]))[0]
|
||||
assert create_result.task_id
|
||||
|
||||
handler.api_client.push_logs[create_result.task_id] = {"code": 200, "data": {"biz_id": "project-1"}}
|
||||
|
||||
polled = await handler.poll_tasks([create_result.task_id])
|
||||
final = polled[create_result.task_id]
|
||||
|
||||
assert final.status == TaskStatus.SUCCESS
|
||||
assert final.data_id == "remote-detail-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_tasks_fails_when_all_info_missing_detail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
collection = DataCollection("remote")
|
||||
project = _build_project_node(node_id="p-node", project_id="project-1", all_info={"project_detail": []})
|
||||
await collection.add(project)
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
handler.api_client = _MockApiClient()
|
||||
_patch_project_all_info_lookup(monkeypatch, collection=collection)
|
||||
|
||||
node = _build_project_detail_node(
|
||||
node_id="detail-node-1",
|
||||
data_id="local-detail-1",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
)
|
||||
|
||||
create_result = (await handler.create_all([node]))[0]
|
||||
assert create_result.task_id
|
||||
handler.api_client.push_logs[create_result.task_id] = {"code": 200, "data": {"biz_id": "project-1"}}
|
||||
|
||||
polled = await handler.poll_tasks([create_result.task_id])
|
||||
final = polled[create_result.task_id]
|
||||
|
||||
assert final.status == TaskStatus.FAILED
|
||||
assert "project_detail not found in all_info" in (final.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_tasks_fails_when_project_detail_has_duplicate_same_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
collection = DataCollection("remote")
|
||||
project = _build_project_node(
|
||||
node_id="p-node",
|
||||
project_id="project-1",
|
||||
all_info={
|
||||
"project_detail": [
|
||||
{
|
||||
"id": "remote-detail-1",
|
||||
"project_id": "project-1",
|
||||
"key": "plan_construction",
|
||||
"value": [{"date": "2026-01-01", "capacity": 13.0}],
|
||||
},
|
||||
{
|
||||
"id": "remote-detail-2",
|
||||
"project_id": "project-1",
|
||||
"key": "plan_construction",
|
||||
"value": [{"date": "2026-01-01", "capacity": 14.0}],
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
await collection.add(project)
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
handler.api_client = _MockApiClient()
|
||||
_patch_project_all_info_lookup(monkeypatch, collection=collection)
|
||||
|
||||
node = _build_project_detail_node(
|
||||
node_id="detail-node-1",
|
||||
data_id="local-detail-1",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
)
|
||||
|
||||
create_result = (await handler.create_all([node]))[0]
|
||||
assert create_result.task_id
|
||||
handler.api_client.push_logs[create_result.task_id] = {"code": 200, "data": {"biz_id": "project-1"}}
|
||||
|
||||
polled = await handler.poll_tasks([create_result.task_id])
|
||||
final = polled[create_result.task_id]
|
||||
|
||||
assert final.status == TaskStatus.FAILED
|
||||
assert "duplicated" in (final.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_all_marks_duplicate_project_key_in_same_batch_as_error() -> None:
|
||||
collection = DataCollection("remote")
|
||||
project = _build_project_node(node_id="p-node", project_id="project-1", all_info={"project_detail": []})
|
||||
await collection.add(project)
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
handler.api_client = _MockApiClient()
|
||||
|
||||
node1 = _build_project_detail_node(
|
||||
node_id="detail-node-1",
|
||||
data_id="local-detail-1",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
capacity=10.0,
|
||||
)
|
||||
node2 = _build_project_detail_node(
|
||||
node_id="detail-node-2",
|
||||
data_id="local-detail-2",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
capacity=20.0,
|
||||
)
|
||||
|
||||
results = await handler.create_all([node1, node2])
|
||||
|
||||
assert results[0].status == TaskStatus.IN_PROGRESS
|
||||
assert results[1].status == TaskStatus.FAILED
|
||||
assert "Duplicate project_detail_data" in (results[1].error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_node_reuses_existing_node_by_project_key_and_rebinds_data_id() -> None:
|
||||
collection = DataCollection("remote")
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
handler.api_client = _MockApiClient()
|
||||
|
||||
existing = _build_project_detail_node(
|
||||
node_id="detail-node-old",
|
||||
data_id="virtual:project_detail_data:project-1:plan_construction",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
)
|
||||
await collection.add(existing)
|
||||
|
||||
created = handler._create_node(
|
||||
{
|
||||
"id": "remote-detail-99",
|
||||
"project_id": "project-1",
|
||||
"key": "plan_construction",
|
||||
"value": [{"date": "2026-01-01", "capacity": 21.0}],
|
||||
}
|
||||
)
|
||||
|
||||
assert created.node_id == "detail-node-old"
|
||||
assert created.data_id == "remote-detail-99"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_tasks_queries_all_info_once_per_project_for_multiple_keys(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
collection = DataCollection("remote")
|
||||
project = _build_project_node(
|
||||
node_id="p-node",
|
||||
project_id="project-1",
|
||||
all_info={"project_detail": []},
|
||||
)
|
||||
await collection.add(project)
|
||||
|
||||
handler = ProjectDetailApiHandler()
|
||||
handler.set_collection(collection)
|
||||
client = _MockApiClient()
|
||||
handler.api_client = client
|
||||
|
||||
node1 = _build_project_detail_node(
|
||||
node_id="detail-node-1",
|
||||
data_id="local-detail-1",
|
||||
project_id="project-1",
|
||||
key="plan_construction",
|
||||
)
|
||||
node2 = _build_project_detail_node(
|
||||
node_id="detail-node-2",
|
||||
data_id="local-detail-2",
|
||||
project_id="project-1",
|
||||
key="actual_construction",
|
||||
)
|
||||
|
||||
create_results = await handler.create_all([node1, node2])
|
||||
task_ids = [r.task_id for r in create_results if r.task_id]
|
||||
assert len(task_ids) == 2
|
||||
|
||||
client.push_logs[task_ids[0]] = {"code": 200, "data": {"biz_id": "project-1"}}
|
||||
client.push_logs[task_ids[1]] = {"code": 200, "data": {"biz_id": "project-1"}}
|
||||
|
||||
project.context["all_info"] = {
|
||||
"project_detail": [
|
||||
{
|
||||
"id": "remote-detail-1",
|
||||
"project_id": "project-1",
|
||||
"key": "plan_construction",
|
||||
"value": [{"date": "2026-01-01", "capacity": 11.0}],
|
||||
},
|
||||
{
|
||||
"id": "remote-detail-2",
|
||||
"project_id": "project-1",
|
||||
"key": "actual_construction",
|
||||
"value": [{"date": "2026-01-01", "capacity": 22.0}],
|
||||
},
|
||||
]
|
||||
}
|
||||
_patch_project_all_info_lookup(
|
||||
monkeypatch,
|
||||
collection=collection,
|
||||
call_recorder=client.all_info_calls,
|
||||
)
|
||||
|
||||
polled = await handler.poll_tasks(task_ids)
|
||||
|
||||
assert polled[task_ids[0]].status == TaskStatus.SUCCESS
|
||||
assert polled[task_ids[0]].data_id == "remote-detail-1"
|
||||
assert polled[task_ids[1]].status == TaskStatus.SUCCESS
|
||||
assert polled[task_ids[1]].data_id == "remote-detail-2"
|
||||
assert client.all_info_calls == ["project-1"]
|
||||
Reference in New Issue
Block a user