Files
ecm_sync_system/tests/integration/test_strategy_business_points_migrated.py
T
strepsiades 4a9c444b10 first commit
2026-03-09 16:31:42 +08:00

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