396 lines
14 KiB
Python
396 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
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.sync_system.resolve_ids import IDResolver
|
|
from sync_state_machine.sync_system.config import OrphanAction, UpdateDirection
|
|
from sync_state_machine.sync_system.strategy_ops.bind_ops import (
|
|
phase_auto_binding,
|
|
phase_core_state,
|
|
phase_dependency_check,
|
|
)
|
|
from tests.fixtures.mock_domain import (
|
|
TestContractNode,
|
|
TestProjectNode,
|
|
TestContractStrategy,
|
|
TestProjectStrategy,
|
|
build_contract_node,
|
|
build_project_node,
|
|
register_test_domain,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def setup_env():
|
|
register_test_domain()
|
|
db = Path(tempfile.mkdtemp(prefix="sync_state_machine_biz_") ) / "state.db"
|
|
persistence = PersistenceBackend(str(db))
|
|
await persistence.initialize()
|
|
|
|
local = DataCollection("local", persistence=persistence, auto_persist=False)
|
|
remote = DataCollection("remote", persistence=persistence, auto_persist=False)
|
|
binding_manager = BindingManager(persistence, auto_persist=False)
|
|
|
|
yield local, remote, binding_manager, persistence
|
|
await persistence.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"name,has_binding,local_data,remote_data,expected_local,expected_remote",
|
|
[
|
|
("bind_yes_yes_yes", True, True, True, BindingStatus.NORMAL, BindingStatus.NORMAL),
|
|
("bind_yes_yes_no", True, True, False, BindingStatus.WARNING, BindingStatus.ABNORMAL),
|
|
("bind_yes_no_yes", True, False, True, BindingStatus.ABNORMAL, BindingStatus.WARNING),
|
|
("bind_yes_no_no", True, False, False, BindingStatus.ABNORMAL, BindingStatus.ABNORMAL),
|
|
("bind_no_local_only", False, True, None, BindingStatus.MISSING, None),
|
|
("bind_no_remote_only", False, None, True, None, BindingStatus.MISSING),
|
|
],
|
|
)
|
|
async def test_core_binding_matrix_migrated(
|
|
setup_env,
|
|
name: str,
|
|
has_binding: bool,
|
|
local_data: bool | None,
|
|
remote_data: bool | None,
|
|
expected_local: BindingStatus | None,
|
|
expected_remote: BindingStatus | None,
|
|
):
|
|
local, remote, bm, _ = setup_env
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
|
|
if local_data is not None:
|
|
await local.add(
|
|
build_project_node(
|
|
f"{name}_l",
|
|
f"L-{name}",
|
|
name=f"Local {name}",
|
|
code=f"LC-{name}",
|
|
data_present=local_data,
|
|
)
|
|
)
|
|
if remote_data is not None:
|
|
await remote.add(
|
|
build_project_node(
|
|
f"{name}_r",
|
|
f"R-{name}",
|
|
name=f"Remote {name}",
|
|
code=f"RC-{name}",
|
|
data_present=remote_data,
|
|
)
|
|
)
|
|
|
|
if has_binding:
|
|
await bm.bind("test_project", f"{name}_l", f"{name}_r")
|
|
|
|
await phase_core_state(
|
|
strategy,
|
|
local.filter(node_type="test_project"),
|
|
remote.filter(node_type="test_project"),
|
|
)
|
|
|
|
if expected_local is not None:
|
|
assert local.get(f"{name}_l").binding_status == expected_local
|
|
if expected_remote is not None:
|
|
assert remote.get(f"{name}_r").binding_status == expected_remote
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_resolution_migrated(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
project_strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
project_strategy.update_config(depend_fields={})
|
|
|
|
await local.add(build_project_node("proj_l", "P-1", name="Project A", code="P-A"))
|
|
await remote.add(build_project_node("proj_r", "P-1-R", name="Project A", code="P-A"))
|
|
await bm.bind("test_project", "proj_l", "proj_r")
|
|
|
|
await phase_core_state(
|
|
project_strategy,
|
|
local.filter(node_type="test_project"),
|
|
remote.filter(node_type="test_project"),
|
|
)
|
|
assert local.get("proj_l").binding_status == BindingStatus.NORMAL
|
|
|
|
contract_strategy = TestContractStrategy("test_contract", local, remote, bm)
|
|
contract_strategy.update_config(
|
|
depend_fields={"project_ref": "test_project"},
|
|
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
|
remote_orphan_action=OrphanAction.NONE,
|
|
update_direction=UpdateDirection.PUSH,
|
|
)
|
|
|
|
await local.add(
|
|
build_contract_node(
|
|
"contract_ok",
|
|
"C-OK",
|
|
name="Contract OK",
|
|
code="C-OK",
|
|
project_ref="P-1",
|
|
)
|
|
)
|
|
await local.add(
|
|
build_contract_node(
|
|
"contract_bad",
|
|
"C-BAD",
|
|
name="Contract Bad",
|
|
code="C-BAD",
|
|
project_ref="P-NOPE",
|
|
)
|
|
)
|
|
|
|
local_contracts = local.filter(node_type="test_contract")
|
|
remote_contracts = remote.filter(node_type="test_contract")
|
|
await phase_core_state(contract_strategy, local_contracts, remote_contracts)
|
|
await phase_dependency_check(contract_strategy, local_contracts, remote_contracts)
|
|
|
|
assert local.get("contract_ok").binding_status == BindingStatus.MISSING
|
|
assert local.get("contract_ok").depend_ids == ["proj_l"]
|
|
assert local.get("contract_bad").binding_status == BindingStatus.DEPENDENCY_ERROR
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_binding_migrated(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(auto_bind=True, auto_bind_fields=["code"], depend_fields={})
|
|
|
|
await local.add(build_project_node("l1", "P1", name="A", code="AUTO-1"))
|
|
await remote.add(build_project_node("r1", "RP1", name="A", code="AUTO-1"))
|
|
|
|
await strategy.bind()
|
|
|
|
assert await bm.get_remote_id("test_project", "l1") == "r1"
|
|
assert await bm.get_local_id("test_project", "r1") == "l1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_binding_skip_on_ambiguous_migrated(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(auto_bind=True, auto_bind_fields=["code"], depend_fields={})
|
|
|
|
await local.add(build_project_node("l1", "P1", name="A", code="AMB-1"))
|
|
await local.add(build_project_node("l2", "P2", name="A", code="AMB-1"))
|
|
await remote.add(build_project_node("r1", "RP1", name="A", code="AMB-1"))
|
|
|
|
await strategy.bind()
|
|
|
|
assert await bm.get_remote_id("test_project", "l1") is None
|
|
assert await bm.get_remote_id("test_project", "l2") is None
|
|
assert await bm.get_local_id("test_project", "r1") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_bind_data_id_works_when_auto_bind_disabled(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(
|
|
auto_bind=False,
|
|
auto_bind_fields=[],
|
|
pre_bind_data_id=[{"local_data_id": "PRE-DID-1", "remote_data_id": "PRE-DID-R-1"}],
|
|
depend_fields={},
|
|
)
|
|
|
|
await local.add(build_project_node("pre_l1", "PRE-DID-1", name="Local A", code="LOCAL-ONLY"))
|
|
await remote.add(build_project_node("pre_r1", "PRE-DID-R-1", name="Remote A", code="REMOTE-ONLY"))
|
|
|
|
await strategy.bind()
|
|
|
|
assert await bm.get_remote_id("test_project", "pre_l1") == "pre_r1"
|
|
assert await bm.get_local_id("test_project", "pre_r1") == "pre_l1"
|
|
assert local.get("pre_l1").binding_status == BindingStatus.NORMAL
|
|
assert remote.get("pre_r1").binding_status == BindingStatus.NORMAL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_check_data_missing_goes_abnormal(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestContractStrategy("test_contract", local, remote, bm)
|
|
strategy.update_config(depend_fields={"project_ref": "test_project"})
|
|
|
|
await local.add(
|
|
build_contract_node(
|
|
"contract_no_data",
|
|
"C-NODATA",
|
|
name="Contract No Data",
|
|
code="C-NODATA",
|
|
project_ref="P-1",
|
|
data_present=False,
|
|
)
|
|
)
|
|
|
|
local_contracts = local.filter(node_type="test_contract")
|
|
await phase_core_state(strategy, local_contracts, [])
|
|
await phase_dependency_check(strategy, local_contracts, [])
|
|
|
|
assert local.get("contract_no_data").binding_status == BindingStatus.ABNORMAL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_binding_key_missing_migrated(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(auto_bind=True, auto_bind_fields=["code", "never_exists"], depend_fields={})
|
|
|
|
await local.add(build_project_node("km_l", "KM-L", name="KM", code="KM-1"))
|
|
|
|
await strategy.bind()
|
|
|
|
node = local.get("km_l")
|
|
assert node.binding_status == BindingStatus.WARNING
|
|
assert "缺少业务键字段" in (node.error or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_binding_key_id_resolve_fail_migrated(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(
|
|
auto_bind=True,
|
|
auto_bind_fields=["id", "code"],
|
|
depend_fields={"id": "test_project"},
|
|
)
|
|
|
|
await local.add(build_project_node("kid_l", "KID-L", name="KID", code="KID-1"))
|
|
|
|
local_nodes = local.filter(node_type="test_project")
|
|
await phase_core_state(strategy, local_nodes, [])
|
|
await phase_auto_binding(strategy, local_nodes, [])
|
|
|
|
node = local.get("kid_l")
|
|
assert node.binding_status == BindingStatus.DEPENDENCY_ERROR
|
|
assert "业务键中的ID字段解析失败" in (node.error or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_prepare_e21_on_id_resolve_fail(setup_env, monkeypatch: pytest.MonkeyPatch):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(
|
|
auto_bind=True,
|
|
auto_bind_fields=["code"],
|
|
depend_fields={"dummy_id": "test_project"},
|
|
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
|
remote_orphan_action=OrphanAction.NONE,
|
|
update_direction=UpdateDirection.NONE,
|
|
)
|
|
|
|
async def _always_fail_resolve_and_validate(self, data, node_type, depend_fields, to_remote):
|
|
return None, "forced resolve fail"
|
|
|
|
monkeypatch.setattr(IDResolver, "resolve_and_validate", _always_fail_resolve_and_validate)
|
|
|
|
node = TestProjectNode(
|
|
node_id="create_fail_src",
|
|
data_id="C-CREATE-FAIL",
|
|
data={"id": "C-CREATE-FAIL", "name": "Create Fail", "code": "CF-1"},
|
|
binding_status=BindingStatus.UNCHECKED,
|
|
action=SyncAction.NONE,
|
|
status=SyncStatus.PENDING,
|
|
)
|
|
await local.add(node)
|
|
await strategy.bind()
|
|
|
|
created_nodes = await strategy.create()
|
|
|
|
assert created_nodes == []
|
|
failed_node = local.get("create_fail_src")
|
|
assert failed_node is not None
|
|
assert failed_node.binding_status == BindingStatus.PEER_CREATING
|
|
assert "spawn_failed" in (failed_node.error or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_prepare_target_missing_data_id_goes_precondition_failed(setup_env):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(update_direction=UpdateDirection.PUSH, depend_fields={})
|
|
|
|
local_src = TestProjectNode(
|
|
node_id="upd_src",
|
|
data_id="UPD-SRC",
|
|
data={"id": "UPD-SRC", "name": "Project New", "code": "UPD-1"},
|
|
binding_status=BindingStatus.NORMAL,
|
|
action=SyncAction.NONE,
|
|
status=SyncStatus.PENDING,
|
|
)
|
|
remote_target = TestProjectNode(
|
|
node_id="upd_tgt",
|
|
data_id="",
|
|
data={"id": "UPD-TGT", "name": "Project Old", "code": "UPD-1"},
|
|
binding_status=BindingStatus.NORMAL,
|
|
action=SyncAction.NONE,
|
|
status=SyncStatus.PENDING,
|
|
)
|
|
await local.add(local_src)
|
|
await remote.add(remote_target)
|
|
await bm.bind("test_project", "upd_src", "upd_tgt")
|
|
|
|
updated_nodes = await strategy.update()
|
|
|
|
assert updated_nodes == []
|
|
target = remote.get("upd_tgt")
|
|
assert target.status == SyncStatus.PRECONDITION_FAILED
|
|
assert target.action == SyncAction.UPDATE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_prepare_e30b_on_id_resolve_fail(setup_env, monkeypatch: pytest.MonkeyPatch):
|
|
local, remote, bm, _ = setup_env
|
|
|
|
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
|
strategy.update_config(
|
|
depend_fields={"dummy_id": "test_project"},
|
|
update_direction=UpdateDirection.PUSH,
|
|
)
|
|
|
|
async def _always_fail_resolve_and_validate(self, data, node_type, depend_fields, to_remote):
|
|
return None, "forced resolve fail"
|
|
|
|
monkeypatch.setattr(IDResolver, "resolve_and_validate", _always_fail_resolve_and_validate)
|
|
|
|
local_src = TestProjectNode(
|
|
node_id="upd_b_src",
|
|
data_id="UPDB-SRC",
|
|
data={"id": "UPDB-SRC", "name": "Project New", "code": "UPDB-1"},
|
|
binding_status=BindingStatus.NORMAL,
|
|
action=SyncAction.NONE,
|
|
status=SyncStatus.PENDING,
|
|
)
|
|
remote_target = TestProjectNode(
|
|
node_id="upd_b_tgt",
|
|
data_id="UPDB-TGT",
|
|
data={"id": "UPDB-TGT", "name": "Project Old", "code": "UPDB-1"},
|
|
binding_status=BindingStatus.NORMAL,
|
|
action=SyncAction.NONE,
|
|
status=SyncStatus.PENDING,
|
|
)
|
|
await local.add(local_src)
|
|
await remote.add(remote_target)
|
|
await bm.bind("test_project", "upd_b_src", "upd_b_tgt")
|
|
|
|
updated_nodes = await strategy.update()
|
|
|
|
assert updated_nodes == []
|
|
target = remote.get("upd_b_tgt")
|
|
assert target.status == SyncStatus.PRECONDITION_FAILED
|
|
assert target.action == SyncAction.UPDATE
|