Some checks failed
CI/CD Pipeline / Code Quality & Linting (push) Has been cancelled
CI/CD Pipeline / Policy Validation (push) Has been cancelled
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-firm-connectors) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-forms) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-hmrc) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ingestion) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-normalize-map) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ocr) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-indexer) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-reason) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rpa) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (ui-review) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (ui-review) (push) Has been cancelled
CI/CD Pipeline / Generate SBOM (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Notifications (push) Has been cancelled
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""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
|