first commit
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import copy
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from sync_state_machine.common.binding import BindingManager
|
||||
from sync_state_machine.common.collection import DataCollection
|
||||
from sync_state_machine.common.persistence import PersistenceBackend
|
||||
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
|
||||
from sync_state_machine.pipeline.full_sync_pipeline import FullSyncPipeline
|
||||
from sync_state_machine.sync_system.strategy_ops.compare_ops import (
|
||||
build_data_id_normalization_map,
|
||||
normalized_data_for_compare,
|
||||
collect_payload_diffs,
|
||||
)
|
||||
from sync_state_machine.sync_system.config import OrphanAction, UpdateDirection
|
||||
from tests.fixtures.mock_domain import (
|
||||
InMemoryDataSource,
|
||||
InMemoryNodeHandler,
|
||||
TestContractNode,
|
||||
TestContractSchema,
|
||||
TestContractStrategy,
|
||||
TestProjectNode,
|
||||
TestProjectSchema,
|
||||
TestProjectStrategy,
|
||||
build_contract_node,
|
||||
build_project_node,
|
||||
register_test_domain,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def pipeline_bundle():
|
||||
register_test_domain()
|
||||
|
||||
db = Path(tempfile.mkdtemp(prefix="sync_state_machine_pipeline_") ) / "state.db"
|
||||
persistence = PersistenceBackend(str(db))
|
||||
await persistence.initialize()
|
||||
|
||||
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
|
||||
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
|
||||
binding_manager = BindingManager(persistence, auto_persist=False)
|
||||
|
||||
local_project_nodes = [
|
||||
build_project_node("lp_norm", "P-NORM", name="Project Normal", code="NORM"),
|
||||
build_project_node("lp_warn", "P-WARN", name="Project Warning", code="WARN"),
|
||||
build_project_node("lp_auto", "P-AUTO", name="Project Auto", code="AUTO-1"),
|
||||
build_project_node("lp_create_ok", "P-COK", name="Project Create OK", code="CREATE_OK"),
|
||||
build_project_node("lp_create_fail", "P-CFAIL", name="Project Create Fail", code="CREATE_FAIL"),
|
||||
build_project_node("lp_create_async", "P-CASYNC", name="Project Create Async", code="CREATE_ASYNC"),
|
||||
build_project_node("lp_update", "P-UPD", name="Project Update New", code="UPD-1"),
|
||||
]
|
||||
remote_project_nodes = [
|
||||
build_project_node("rp_norm", "RP-NORM", name="Project Normal", code="NORM"),
|
||||
build_project_node("rp_warn", "RP-WARN", name="Project Warning", code="WARN", data_present=False),
|
||||
build_project_node("rp_auto", "RP-AUTO", name="Project Auto", code="AUTO-1"),
|
||||
build_project_node("rp_update", "RP-UPD", name="Project Update Old", code="UPD-1"),
|
||||
]
|
||||
|
||||
local_contract_nodes = [
|
||||
build_contract_node(
|
||||
"lc_dep_ok",
|
||||
"C-DEP-OK",
|
||||
name="Contract Dep OK",
|
||||
code="C-DEP-OK",
|
||||
project_ref="P-NORM",
|
||||
),
|
||||
build_contract_node(
|
||||
"lc_dep_err",
|
||||
"C-DEP-ERR",
|
||||
name="Contract Dep Err",
|
||||
code="C-DEP-ERR",
|
||||
project_ref="P-MISSING",
|
||||
),
|
||||
build_contract_node(
|
||||
"lc_upd_skip",
|
||||
"C-UPD-SKIP",
|
||||
name="Contract Update Skip New",
|
||||
code="C-UPD-SKIP",
|
||||
project_ref="P-NORM",
|
||||
),
|
||||
build_contract_node(
|
||||
"lc_upd_fail",
|
||||
"C-UPD-FAIL",
|
||||
name="Contract Update Fail New",
|
||||
code="C-UPD-FAIL",
|
||||
project_ref="P-NORM",
|
||||
),
|
||||
]
|
||||
remote_contract_nodes = [
|
||||
build_contract_node(
|
||||
"rc_upd_skip",
|
||||
"RC-UPD-SKIP",
|
||||
name="Contract Update Skip Old",
|
||||
code="C-UPD-SKIP",
|
||||
project_ref="RP-NORM",
|
||||
),
|
||||
build_contract_node(
|
||||
"rc_upd_fail",
|
||||
"RC-UPD-FAIL",
|
||||
name="Contract Update Fail Old",
|
||||
code="C-UPD-FAIL",
|
||||
project_ref="RP-NORM",
|
||||
),
|
||||
]
|
||||
|
||||
local_ds = InMemoryDataSource(poll_max_retries=2, poll_interval=0)
|
||||
remote_ds = InMemoryDataSource(poll_max_retries=2, poll_interval=0)
|
||||
|
||||
local_ds.register_handler(
|
||||
InMemoryNodeHandler(
|
||||
node_type="test_project",
|
||||
node_class=TestProjectNode,
|
||||
schema=TestProjectSchema,
|
||||
load_nodes=local_project_nodes,
|
||||
)
|
||||
)
|
||||
local_ds.register_handler(
|
||||
InMemoryNodeHandler(
|
||||
node_type="test_contract",
|
||||
node_class=TestContractNode,
|
||||
schema=TestContractSchema,
|
||||
load_nodes=local_contract_nodes,
|
||||
)
|
||||
)
|
||||
|
||||
remote_ds.register_handler(
|
||||
InMemoryNodeHandler(
|
||||
node_type="test_project",
|
||||
node_class=TestProjectNode,
|
||||
schema=TestProjectSchema,
|
||||
load_nodes=remote_project_nodes,
|
||||
create_mode_by_code={
|
||||
"CREATE_OK": "success",
|
||||
"CREATE_FAIL": "failed",
|
||||
"CREATE_ASYNC": "in_progress_success",
|
||||
},
|
||||
update_mode_by_code={"UPD-1": "success"},
|
||||
)
|
||||
)
|
||||
remote_ds.register_handler(
|
||||
InMemoryNodeHandler(
|
||||
node_type="test_contract",
|
||||
node_class=TestContractNode,
|
||||
schema=TestContractSchema,
|
||||
load_nodes=remote_contract_nodes,
|
||||
create_mode_by_code={"C-DEP-OK": "success"},
|
||||
update_mode_by_code={
|
||||
"C-UPD-SKIP": "skipped",
|
||||
"C-UPD-FAIL": "failed",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await binding_manager.bind("test_project", "lp_norm", "rp_norm")
|
||||
await binding_manager.bind("test_project", "lp_warn", "rp_warn")
|
||||
await binding_manager.bind("test_project", "lp_update", "rp_update")
|
||||
await binding_manager.bind("test_contract", "lc_upd_skip", "rc_upd_skip")
|
||||
await binding_manager.bind("test_contract", "lc_upd_fail", "rc_upd_fail")
|
||||
await binding_manager.persist()
|
||||
|
||||
project_strategy = TestProjectStrategy("test_project", local_collection, remote_collection, binding_manager)
|
||||
project_strategy.update_config(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
contract_strategy = TestContractStrategy("test_contract", local_collection, remote_collection, binding_manager)
|
||||
contract_strategy.update_config(
|
||||
auto_bind=False,
|
||||
depend_fields={"project_ref": "test_project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
pipeline = FullSyncPipeline(
|
||||
local_datasource=local_ds,
|
||||
remote_datasource=remote_ds,
|
||||
strategies=[project_strategy, contract_strategy],
|
||||
node_types=["test_project", "test_contract"],
|
||||
load_order=["test_project", "test_contract"],
|
||||
sync_order=["test_project", "test_contract"],
|
||||
persistence=persistence,
|
||||
local_collection=local_collection,
|
||||
remote_collection=remote_collection,
|
||||
binding_manager=binding_manager,
|
||||
enable_persistence=True,
|
||||
)
|
||||
|
||||
yield pipeline, local_collection, remote_collection, binding_manager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_pipeline_multinode_mock_covers_business_points(pipeline_bundle):
|
||||
pipeline, local_collection, remote_collection, binding_manager = pipeline_bundle
|
||||
|
||||
stats = await pipeline.run()
|
||||
|
||||
assert stats["post_check_passed"] is True
|
||||
|
||||
assert local_collection.get("lp_norm").binding_status == BindingStatus.NORMAL
|
||||
assert local_collection.get("lp_warn").binding_status == BindingStatus.WARNING
|
||||
assert remote_collection.get("rp_warn").binding_status == BindingStatus.ABNORMAL
|
||||
assert local_collection.get("lc_dep_err").binding_status == BindingStatus.DEPENDENCY_ERROR
|
||||
|
||||
assert local_collection.get("lp_norm").context.get("bind_data_id") == remote_collection.get("rp_norm").data_id
|
||||
assert remote_collection.get("rp_norm").context.get("bind_data_id") == local_collection.get("lp_norm").data_id
|
||||
|
||||
assert await binding_manager.get_remote_id("test_project", "lp_auto") == "rp_auto"
|
||||
|
||||
project_nodes = remote_collection.filter(node_type="test_project")
|
||||
create_ok_nodes = [n for n in project_nodes if (n.get_data() or {}).get("code") == "CREATE_OK"]
|
||||
create_fail_nodes = [n for n in project_nodes if (n.get_data() or {}).get("code") == "CREATE_FAIL"]
|
||||
create_async_nodes = [n for n in project_nodes if (n.get_data() or {}).get("code") == "CREATE_ASYNC"]
|
||||
|
||||
assert len(create_ok_nodes) == 1
|
||||
assert len(create_fail_nodes) == 1
|
||||
assert len(create_async_nodes) == 1
|
||||
|
||||
assert create_ok_nodes[0].status == SyncStatus.PENDING
|
||||
assert create_ok_nodes[0].action == SyncAction.NONE
|
||||
assert create_fail_nodes[0].status == SyncStatus.FAILED
|
||||
assert create_async_nodes[0].status == SyncStatus.PENDING
|
||||
assert create_async_nodes[0].action == SyncAction.NONE
|
||||
|
||||
assert remote_collection.get("rc_upd_skip").status == SyncStatus.SKIPPED
|
||||
assert remote_collection.get("rc_upd_skip").action == SyncAction.NONE
|
||||
assert remote_collection.get("rc_upd_fail").status == SyncStatus.FAILED
|
||||
|
||||
assert local_collection.get("lc_dep_ok").binding_status == BindingStatus.WARNING
|
||||
assert await binding_manager.get_remote_id("test_contract", "lc_dep_ok") is None
|
||||
|
||||
project_bindings = await binding_manager.get_all_records("test_project")
|
||||
contract_bindings = await binding_manager.get_all_records("test_contract")
|
||||
assert len(project_bindings) >= 7
|
||||
assert len(contract_bindings) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_reloads_after_create_update_writes(pipeline_bundle):
|
||||
pipeline, _, _, _ = pipeline_bundle
|
||||
|
||||
load_counts: dict[tuple[str, str], int] = {}
|
||||
|
||||
def attach_counter(datasource, side: str, node_type: str):
|
||||
handler = datasource.get_handler(node_type)
|
||||
original_load = handler.load
|
||||
|
||||
async def _wrapped_load():
|
||||
key = (side, node_type)
|
||||
load_counts[key] = load_counts.get(key, 0) + 1
|
||||
return await original_load()
|
||||
|
||||
handler.load = _wrapped_load # type: ignore[method-assign]
|
||||
|
||||
for side_name, ds in (("local", pipeline.local_datasource), ("remote", pipeline.remote_datasource)):
|
||||
attach_counter(ds, side_name, "test_project")
|
||||
attach_counter(ds, side_name, "test_contract")
|
||||
|
||||
await pipeline.run()
|
||||
|
||||
assert load_counts[("local", "test_project")] >= 3
|
||||
assert load_counts[("remote", "test_project")] >= 3
|
||||
assert load_counts[("local", "test_contract")] >= 1
|
||||
assert load_counts[("remote", "test_contract")] >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_emits_consistency_stats(pipeline_bundle):
|
||||
pipeline, _, _, _ = pipeline_bundle
|
||||
|
||||
stats = await pipeline.run()
|
||||
|
||||
assert "consistency" in stats
|
||||
assert "test_project" in stats["consistency"]
|
||||
assert "test_contract" in stats["consistency"]
|
||||
|
||||
project_consistency = stats["consistency"]["test_project"]
|
||||
contract_consistency = stats["consistency"]["test_contract"]
|
||||
|
||||
assert isinstance(project_consistency["ok"], bool)
|
||||
assert isinstance(contract_consistency["ok"], bool)
|
||||
assert isinstance(project_consistency["checked_pairs"], int)
|
||||
assert isinstance(contract_consistency["checked_pairs"], int)
|
||||
assert isinstance(project_consistency["mismatch_count"], int)
|
||||
assert isinstance(contract_consistency["mismatch_count"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_overrides_bind_data_id_from_bindings(pipeline_bundle):
|
||||
pipeline, local_collection, remote_collection, _ = pipeline_bundle
|
||||
|
||||
await pipeline.run()
|
||||
|
||||
existing_persistence = local_collection.persistence
|
||||
assert existing_persistence is not None
|
||||
persistence = PersistenceBackend(existing_persistence.db_path)
|
||||
await persistence.initialize()
|
||||
|
||||
await persistence.conn.execute(
|
||||
"UPDATE nodes SET bind_data_id = ?, context = ? WHERE collection_id = ? AND node_id = ?",
|
||||
("STALE", json.dumps({"bind_data_id": "STALE"}), "local", "lp_norm"),
|
||||
)
|
||||
await persistence.conn.execute(
|
||||
"UPDATE nodes SET bind_data_id = ?, context = ? WHERE collection_id = ? AND node_id = ?",
|
||||
("STALE", json.dumps({"bind_data_id": "STALE"}), "local", "lp_create_fail"),
|
||||
)
|
||||
await persistence.conn.commit()
|
||||
|
||||
local_reloaded = DataCollection("local", persistence=persistence, auto_persist=False)
|
||||
remote_reloaded = DataCollection("remote", persistence=persistence, auto_persist=False)
|
||||
await local_reloaded.load_from_persistence()
|
||||
await remote_reloaded.load_from_persistence()
|
||||
|
||||
assert local_reloaded.get("lp_norm").context.get("bind_data_id") == remote_reloaded.get("rp_norm").data_id
|
||||
assert remote_reloaded.get("rp_norm").context.get("bind_data_id") == local_reloaded.get("lp_norm").data_id
|
||||
assert "bind_data_id" not in local_reloaded.get("lp_create_fail").context
|
||||
|
||||
await persistence.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_check_normalizes_id_fields_and_none_empty_equivalence(pipeline_bundle):
|
||||
pipeline, local_collection, remote_collection, _ = pipeline_bundle
|
||||
|
||||
await pipeline.run()
|
||||
|
||||
local_node = local_collection.get("lp_norm")
|
||||
remote_node = remote_collection.get("rp_norm")
|
||||
local_ref = local_collection.get("lp_warn")
|
||||
remote_ref = remote_collection.get("rp_warn")
|
||||
|
||||
assert local_node is not None and remote_node is not None
|
||||
assert local_ref is not None and remote_ref is not None
|
||||
assert local_node.get_data() is not None and remote_node.get_data() is not None
|
||||
|
||||
local_payload_seed = local_node.get_data() or {}
|
||||
remote_payload_seed = remote_node.get_data() or {}
|
||||
local_payload_seed["contract_id"] = local_ref.data_id
|
||||
remote_payload_seed["contract_id"] = remote_ref.data_id
|
||||
local_payload_seed["contract_type"] = None
|
||||
remote_payload_seed["contract_type"] = "GP"
|
||||
local_payload_seed["plan_start_date"] = None
|
||||
remote_payload_seed["plan_start_date"] = ""
|
||||
local_node.set_data(local_payload_seed, validate=False)
|
||||
remote_node.set_data(remote_payload_seed, validate=False)
|
||||
|
||||
data_id_map = await build_data_id_normalization_map(
|
||||
node_type="test_project",
|
||||
local_collection=local_collection,
|
||||
remote_collection=remote_collection,
|
||||
binding_manager=pipeline.binding_manager,
|
||||
)
|
||||
local_payload = normalized_data_for_compare(local_node.get_data(), data_id_map)
|
||||
remote_payload = normalized_data_for_compare(remote_node.get_data(), data_id_map)
|
||||
diff_map = collect_payload_diffs(local_payload, remote_payload)
|
||||
|
||||
assert "contract_id" not in diff_map
|
||||
assert "contract_type" in diff_map
|
||||
assert "plan_start_date" not in diff_map
|
||||
|
||||
|
||||
class _MutableProjectRemoteHandler(InMemoryNodeHandler):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
node_type="test_project",
|
||||
node_class=TestProjectNode,
|
||||
schema=TestProjectSchema,
|
||||
load_nodes=[],
|
||||
)
|
||||
self._store_by_node_id: dict[str, dict] = {}
|
||||
self.update_calls = 0
|
||||
|
||||
async def load(self):
|
||||
nodes = []
|
||||
for row in self._store_by_node_id.values():
|
||||
nodes.append(
|
||||
TestProjectNode(
|
||||
node_id=row["node_id"],
|
||||
data_id=row["data_id"],
|
||||
data=copy.deepcopy(row["data"]),
|
||||
origin_data=copy.deepcopy(row["data"]),
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
|
||||
async def create_all(self, nodes):
|
||||
for node in nodes:
|
||||
node_data = node.get_data() or {}
|
||||
new_data_id = f"RID-{node.node_id}"
|
||||
self._store_by_node_id[node.node_id] = {
|
||||
"node_id": node.node_id,
|
||||
"data_id": new_data_id,
|
||||
"data": {
|
||||
"id": new_data_id,
|
||||
"name": "REMOTE-STUB",
|
||||
"code": node_data.get("code", ""),
|
||||
},
|
||||
}
|
||||
from sync_state_machine.datasource.task_result import TaskResult
|
||||
return [
|
||||
TaskResult.success(node_id=node.node_id, data_id=f"RID-{node.node_id}")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def update_all(self, nodes):
|
||||
self.update_calls += len(nodes)
|
||||
from sync_state_machine.datasource.task_result import TaskResult
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
node_data = node.get_data() or {}
|
||||
existing = self._store_by_node_id.get(node.node_id)
|
||||
if existing is None:
|
||||
existing_data_id = node.data_id or f"RID-{node.node_id}"
|
||||
else:
|
||||
existing_data_id = existing["data_id"]
|
||||
self._store_by_node_id[node.node_id] = {
|
||||
"node_id": node.node_id,
|
||||
"data_id": existing_data_id,
|
||||
"data": {
|
||||
"id": existing_data_id,
|
||||
"name": node_data.get("name", ""),
|
||||
"code": node_data.get("code", ""),
|
||||
},
|
||||
}
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=existing_data_id))
|
||||
return results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_create_then_update_completes_in_single_run_with_reload():
|
||||
register_test_domain()
|
||||
|
||||
db = Path(tempfile.mkdtemp(prefix="sync_state_machine_pipeline_reload_") ) / "state.db"
|
||||
persistence = PersistenceBackend(str(db))
|
||||
await persistence.initialize()
|
||||
|
||||
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
|
||||
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
|
||||
binding_manager = BindingManager(persistence, auto_persist=False)
|
||||
|
||||
local_ds = InMemoryDataSource(poll_max_retries=2, poll_interval=0)
|
||||
remote_ds = InMemoryDataSource(poll_max_retries=2, poll_interval=0)
|
||||
|
||||
local_ds.register_handler(
|
||||
InMemoryNodeHandler(
|
||||
node_type="test_project",
|
||||
node_class=TestProjectNode,
|
||||
schema=TestProjectSchema,
|
||||
load_nodes=[
|
||||
build_project_node("lp_create_then_update", "P-CREATE-UPDATE", name="Project Final", code="P-CU"),
|
||||
],
|
||||
)
|
||||
)
|
||||
remote_handler = _MutableProjectRemoteHandler()
|
||||
remote_ds.register_handler(remote_handler)
|
||||
|
||||
project_strategy = TestProjectStrategy("test_project", local_collection, remote_collection, binding_manager)
|
||||
project_strategy.update_config(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
pipeline = FullSyncPipeline(
|
||||
local_datasource=local_ds,
|
||||
remote_datasource=remote_ds,
|
||||
strategies=[project_strategy],
|
||||
node_types=["test_project"],
|
||||
load_order=["test_project"],
|
||||
sync_order=["test_project"],
|
||||
persistence=persistence,
|
||||
local_collection=local_collection,
|
||||
remote_collection=remote_collection,
|
||||
binding_manager=binding_manager,
|
||||
enable_persistence=True,
|
||||
)
|
||||
|
||||
stats = await pipeline.run()
|
||||
|
||||
records = await binding_manager.get_all_records("test_project")
|
||||
assert len(records) == 1
|
||||
local_id = records[0].local_id
|
||||
remote_id = records[0].remote_id
|
||||
assert remote_id is not None
|
||||
|
||||
local_node = local_collection.get_by_node_id(local_id)
|
||||
remote_node = remote_collection.get_by_node_id(remote_id)
|
||||
assert local_node is not None
|
||||
assert remote_node is not None
|
||||
|
||||
assert remote_handler.update_calls >= 1
|
||||
assert (remote_node.get_data() or {}).get("name") == "Project Final"
|
||||
assert stats["consistency"]["test_project"]["ok"] is True
|
||||
Reference in New Issue
Block a user