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.sqlite_backend import SQLitePersistenceBackend 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 = SQLitePersistenceBackend(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_auto_binding_many_to_one_can_bind_stable_pick_and_leave_rest_for_create(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={}, local_orphan_action=OrphanAction.CREATE_REMOTE, allow_many_to_one_auto_bind=True, ) await local.add(build_project_node("l2", "P2", name="B", code="AMB-2")) await local.add(build_project_node("l1", "P1", name="A", code="AMB-2")) await remote.add(build_project_node("r1", "RP1", name="A", code="AMB-2")) await strategy.bind() assert await bm.get_remote_id("test_project", "l1") == "r1" assert await bm.get_remote_id("test_project", "l2") is None assert await bm.get_local_id("test_project", "r1") == "l1" @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