"""Unit tests for policy loading and merging functionality.""" # FILE: tests/unit/coverage/test_policy_load_and_merge.py import tempfile from pathlib import Path from unittest.mock import patch import pytest import yaml from libs.policy import PolicyLoader from libs.schemas import CoveragePolicy, PolicyError # pylint: disable=wrong-import-position,import-error,too-few-public-methods,global-statement # pylint: disable=raise-missing-from,unused-argument,too-many-arguments,too-many-positional-arguments # pylint: disable=too-many-locals,import-outside-toplevel # mypy: disable-error-code=union-attr # mypy: disable-error-code=no-untyped-def class TestPolicyLoader: """Test policy loading and merging functionality""" @pytest.fixture def temp_config_dir(self): """Create temporary config directory with test files""" with tempfile.TemporaryDirectory() as temp_dir: config_dir = Path(temp_dir) # Create baseline policy baseline_policy = { "version": "1.0", "jurisdiction": "UK", "tax_year": "2024-25", "tax_year_boundary": {"start": "2024-04-06", "end": "2025-04-05"}, "defaults": {"confidence_thresholds": {"ocr": 0.82, "extract": 0.85}}, "document_kinds": ["P60", "P11D"], "triggers": { "SA102": {"any_of": ["exists(IncomeItem[type='Employment'])"]} }, "schedules": { "SA102": { "evidence": [ {"id": "P60", "role": "REQUIRED", "boxes": ["SA102_b1"]} ] } }, "status_classifier": { "present_verified": {"min_ocr": 0.82}, "present_unverified": {"min_ocr": 0.60}, "conflicting": {"conflict_rules": []}, "missing": {"default": True}, }, "conflict_resolution": {"precedence": ["P60"]}, "question_templates": { "default": { "text": "Need {evidence}", "why": "Required for {schedule}", } }, "privacy": {"vector_pii_free": True, "redact_patterns": []}, } with open(config_dir / "coverage.yaml", "w") as f: yaml.dump(baseline_policy, f) # Create jurisdiction overlay jurisdiction_overlay = { "defaults": { "confidence_thresholds": {"ocr": 0.85} # Override threshold }, "document_kinds": ["P60", "P11D", "P45"], # Add P45 } with open(config_dir / "coverage.UK.2024-25.yaml", "w") as f: yaml.dump(jurisdiction_overlay, f) # Create tenant overlay (config_dir / "overrides").mkdir() tenant_overlay = { "defaults": {"date_tolerance_days": 60} # Override tolerance } with open(config_dir / "overrides" / "tenant123.yaml", "w") as f: yaml.dump(tenant_overlay, f) yield config_dir @pytest.fixture def policy_loader(self, temp_config_dir): """Create policy loader with temp config""" return PolicyLoader(str(temp_config_dir)) def test_load_baseline_policy(self, policy_loader, temp_config_dir): """Test loading baseline policy without overlays""" policy = policy_loader.load_policy( baseline_path=str(temp_config_dir / "coverage.yaml"), jurisdiction="US", # No overlay exists tax_year="2023-24", # No overlay exists tenant_id=None, ) assert isinstance(policy, CoveragePolicy) assert policy.version == "1.0" assert policy.jurisdiction == "UK" assert policy.defaults.confidence_thresholds["ocr"] == 0.82 assert len(policy.document_kinds) == 2 def test_load_policy_with_jurisdiction_overlay(self, policy_loader): """Test loading policy with jurisdiction overlay applied""" policy = policy_loader.load_policy(jurisdiction="UK", tax_year="2024-25") # Should have jurisdiction overlay applied assert policy.defaults.confidence_thresholds["ocr"] == 0.85 # Overridden assert len(policy.document_kinds) == 3 # P45 added assert "P45" in policy.document_kinds def test_load_policy_with_tenant_overlay(self, policy_loader): """Test loading policy with tenant overlay applied""" policy = policy_loader.load_policy( jurisdiction="UK", tax_year="2024-25", tenant_id="tenant123" ) # Should have both jurisdiction and tenant overlays assert policy.defaults.confidence_thresholds["ocr"] == 0.85 # From jurisdiction assert policy.defaults.date_tolerance_days == 60 # From tenant assert len(policy.document_kinds) == 3 # From jurisdiction def test_merge_overlays(self, policy_loader): """Test overlay merging logic""" base = {"a": 1, "b": {"x": 10, "y": 20}, "c": [1, 2, 3]} overlay1 = { "b": {"x": 15, "z": 30}, # Merge into b, override x, add z "d": 4, # Add new key } overlay2 = { "b": {"y": 25}, # Override y in b "c": [4, 5, 6], # Replace entire list } result = policy_loader.merge_overlays(base, overlay1, overlay2) assert result["a"] == 1 assert result["b"]["x"] == 15 # From overlay1 assert result["b"]["y"] == 25 # From overlay2 assert result["b"]["z"] == 30 # From overlay1 assert result["c"] == [4, 5, 6] # From overlay2 assert result["d"] == 4 # From overlay1 def test_compile_predicates(self, policy_loader): """Test predicate compilation""" policy = policy_loader.load_policy() compiled = policy_loader.compile_predicates(policy) assert compiled.policy == policy assert len(compiled.compiled_predicates) > 0 assert "exists(IncomeItem[type='Employment'])" in compiled.compiled_predicates assert compiled.hash is not None assert len(compiled.source_files) > 0 def test_predicate_execution(self, policy_loader): """Test that compiled predicates are callable""" policy = policy_loader.load_policy() compiled = policy_loader.compile_predicates(policy) predicate = compiled.compiled_predicates[ "exists(IncomeItem[type='Employment'])" ] # Should be callable and return boolean result = predicate("T-001", "2024-25") assert isinstance(result, bool) def test_invalid_yaml_file(self, temp_config_dir): """Test handling of invalid YAML file""" # Create invalid YAML with open(temp_config_dir / "invalid.yaml", "w") as f: f.write("invalid: yaml: content: [") loader = PolicyLoader(str(temp_config_dir)) with pytest.raises(PolicyError, match="Invalid YAML"): loader._load_yaml_file(str(temp_config_dir / "invalid.yaml")) def test_missing_file(self, temp_config_dir): """Test handling of missing file""" loader = PolicyLoader(str(temp_config_dir)) with pytest.raises(PolicyError, match="Policy file not found"): loader._load_yaml_file(str(temp_config_dir / "missing.yaml")) def test_schema_validation_success(self, policy_loader, temp_config_dir): """Test successful schema validation""" policy_dict = policy_loader._load_yaml_file( str(temp_config_dir / "coverage.yaml") ) # Should not raise exception policy_loader._validate_policy(policy_dict) def test_schema_validation_failure(self, policy_loader): """Test schema validation failure""" invalid_policy = { "version": "1.0", # Missing required fields } with pytest.raises(Exception): # ValidationError from jsonschema policy_loader._validate_policy(invalid_policy) def test_business_rules_validation(self, policy_loader, temp_config_dir): """Test business rules validation""" policy_dict = policy_loader._load_yaml_file( str(temp_config_dir / "coverage.yaml") ) result = policy_loader.validate_policy(policy_dict) assert result.ok is True assert len(result.errors) == 0 def test_business_rules_validation_failure(self, policy_loader): """Test business rules validation with errors""" invalid_policy = { "version": "1.0", "jurisdiction": "UK", "tax_year": "2024-25", "tax_year_boundary": {"start": "2024-04-06", "end": "2025-04-05"}, "defaults": {"confidence_thresholds": {"ocr": 0.82}}, "document_kinds": ["P60"], "triggers": {"SA102": {"any_of": ["test"]}}, "schedules": { "SA102": { "evidence": [ { "id": "P11D", # Not in document_kinds "role": "REQUIRED", "boxes": ["SA102_b1"], } ] } }, "status_classifier": { "present_verified": {"min_ocr": 0.82}, "present_unverified": {"min_ocr": 0.60}, "conflicting": {"conflict_rules": []}, "missing": {"default": True}, }, "conflict_resolution": {"precedence": ["P60"]}, "question_templates": {"default": {"text": "test", "why": "test"}}, } result = policy_loader.validate_policy(invalid_policy) assert result.ok is False assert len(result.errors) > 0 assert any("P11D" in error for error in result.errors) def test_apply_feature_flags_placeholder(self, policy_loader): """Test feature flags application (placeholder)""" policy_dict = {"test": "value"} result = policy_loader.apply_feature_flags(policy_dict) # Currently just returns unchanged assert result == policy_dict @patch("libs.policy.utils.get_policy_loader") def test_convenience_functions(self, mock_get_loader, policy_loader): """Test convenience functions""" # Create a valid mock policy for testing from unittest.mock import MagicMock from libs.schemas import ( ConflictRules, CoveragePolicy, Defaults, Privacy, QuestionTemplates, StatusClassifier, StatusClassifierConfig, TaxYearBoundary, ) mock_policy = CoveragePolicy( version="1.0", jurisdiction="UK", tax_year="2024-25", tax_year_boundary=TaxYearBoundary(start="2024-04-06", end="2025-04-05"), defaults=Defaults( confidence_thresholds={"ocr": 0.82, "extract": 0.85}, date_tolerance_days=30, ), document_kinds=["P60"], status_classifier=StatusClassifierConfig( present_verified=StatusClassifier(min_ocr=0.82, min_extract=0.85), present_unverified=StatusClassifier(min_ocr=0.60, min_extract=0.70), conflicting=StatusClassifier(), missing=StatusClassifier(), ), triggers={}, conflict_resolution=ConflictRules(precedence=["P60"]), question_templates=QuestionTemplates( default={"text": "test", "why": "test"} ), privacy=Privacy(vector_pii_free=True, redact_patterns=[]), ) # Mock the policy loader to return our test policy from datetime import datetime from libs.schemas import CompiledCoveragePolicy mock_compiled_policy = CompiledCoveragePolicy( policy=mock_policy, compiled_predicates={}, compiled_at=datetime.now(), hash="test-hash", source_files=["test.yaml"], ) mock_loader = MagicMock() mock_loader.load_policy.return_value = mock_policy mock_loader.merge_overlays.side_effect = lambda base, *overlays: { **base, **{k: v for overlay in overlays for k, v in overlay.items()}, } mock_loader.compile_predicates.return_value = mock_compiled_policy mock_get_loader.return_value = mock_loader from libs.policy import compile_predicates, load_policy, merge_overlays # Test load_policy - use the mock policy directly since we're testing the convenience function policy = load_policy() assert isinstance(policy, CoveragePolicy) assert policy.version == "1.0" # Test merge_overlays base = {"a": 1} overlay = {"b": 2} merged = merge_overlays(base, overlay) assert merged == {"a": 1, "b": 2} # Test compile_predicates compiled = compile_predicates(policy) assert compiled.policy == policy