from __future__ import annotations from pathlib import Path import pytest import yaml from pydantic import BaseModel from sync_state_machine.config.multi_project_config import build_multi_project_config_from_file 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.sync_node import SyncNode from sync_state_machine.pipeline.multi_project_pipeline import ( _project_scope_overrides, _resolve_project_bootstrap_node_types, ) def test_build_multi_project_config_reads_explicit_project_bootstrap_node_types(tmp_path: Path) -> None: config_path = tmp_path / "multi.yaml" config_path.write_text( yaml.safe_dump( { "global_config": "run_profiles/global.yaml", "default_project_config": "run_profiles/project.yaml", "project_bootstrap_node_types": ["company", "supplier"], "projects": [ { "local_project_id": "local-project", "remote_project_id": "remote-project", } ], }, allow_unicode=True, sort_keys=False, ), encoding="utf-8", ) config = build_multi_project_config_from_file(project_root=tmp_path, file_path=config_path) assert config.project_bootstrap_node_types == ["company", "supplier"] def test_project_scope_overrides_only_inject_project_handler_filter() -> None: item = type("Item", (), {"local_project_id": "local-project", "remote_project_id": "remote-project"})() overrides = _project_scope_overrides(item, node_types=["project", "contract", "user"]) assert overrides == { "local_datasource": { "handler_configs": { "project": {"data_id_filter": ["local-project"]}, } }, "remote_datasource": { "handler_configs": { "project": {"data_id_filter": ["remote-project"]}, } }, } def test_resolve_project_bootstrap_node_types_prefers_explicit_config() -> None: config = type( "Config", (), {"project_bootstrap_node_types": ["company", "supplier", "company"]}, )() resolved = _resolve_project_bootstrap_node_types( config=config, shared_node_types=["company", "supplier", "project"], ) assert resolved == ["company", "supplier"] class _ProjectSchema(BaseModel): id: str class _ProjectNode(SyncNode[dict]): node_type = "project" schema = _ProjectSchema @pytest.mark.asyncio async def test_binding_manager_prunes_stale_bindings_after_project_scope_cleanup() -> None: persistence = PersistenceBackend(":memory:") await persistence.initialize() try: 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, scope="project_id:p1") local_node = _ProjectNode(node_id="local-project-1", data={"id": "p1"}, data_id="p1") remote_node = _ProjectNode(node_id="remote-project-1", data={"id": "p1"}, data_id="p1") stale_local_node = _ProjectNode(node_id="local-project-2", data={"id": "p2"}, data_id="p2") stale_remote_node = _ProjectNode(node_id="remote-project-2", data={"id": "p2"}, data_id="p2") await local_collection.add(local_node) await local_collection.add(stale_local_node) await remote_collection.add(remote_node) await remote_collection.add(stale_remote_node) await binding_manager.bind("project", "local-project-1", "remote-project-1") await binding_manager.bind("project", "local-project-2", "remote-project-2") await local_collection.filter_by_project_ids(["p1"]) await remote_collection.filter_by_project_ids(["p1"]) deleted = await binding_manager.prune_missing_nodes( local_collection=local_collection, remote_collection=remote_collection, node_types=["project"], ) assert deleted == 1 assert await binding_manager.get_remote_id("project", "local-project-1") == "remote-project-1" assert await binding_manager.get_remote_id("project", "local-project-2") is None finally: await persistence.close()