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
349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""
|
|
Unit tests for svc-kg service
|
|
Tests actual business logic: Neo4j operations, SHACL validation,
|
|
bitemporal data handling, and RDF export
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
# Add the project root to the path so we can import from apps
|
|
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
|
|
# Import the actual service code
|
|
from apps.svc_kg.main import KGSettings, _is_safe_query, _validate_node
|
|
|
|
# pylint: disable=wrong-import-position,import-error,too-few-public-methods
|
|
# pylint: disable=global-statement,raise-missing-from,unused-argument
|
|
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
# pylint: disable=too-many-locals,import-outside-toplevel
|
|
# mypy: disable-error-code=union-attr
|
|
|
|
|
|
class TestKGSettings:
|
|
"""Test KGSettings configuration"""
|
|
|
|
def test_default_settings(self) -> None:
|
|
"""Test default KGSettings values"""
|
|
settings = KGSettings()
|
|
|
|
# Test service configuration
|
|
assert settings.service_name == "svc-kg"
|
|
|
|
# Test query limits
|
|
assert settings.max_results == 1000
|
|
assert settings.max_depth == 10
|
|
assert settings.query_timeout == 30
|
|
|
|
# Test validation configuration
|
|
assert settings.validate_on_write is True
|
|
assert settings.shapes_file == "schemas/shapes.ttl"
|
|
|
|
def test_custom_settings(self) -> None:
|
|
"""Test custom KGSettings values"""
|
|
custom_settings = KGSettings(
|
|
max_results=500,
|
|
max_depth=5,
|
|
query_timeout=60,
|
|
validate_on_write=False,
|
|
shapes_file="custom/shapes.ttl",
|
|
)
|
|
|
|
assert custom_settings.max_results == 500
|
|
assert custom_settings.max_depth == 5
|
|
assert custom_settings.query_timeout == 60
|
|
assert custom_settings.validate_on_write is False
|
|
assert custom_settings.shapes_file == "custom/shapes.ttl"
|
|
|
|
|
|
class TestQuerySafety:
|
|
"""Test query safety validation"""
|
|
|
|
def test_safe_queries(self) -> None:
|
|
"""Test queries that should be considered safe"""
|
|
safe_queries = [
|
|
"MATCH (n:Person) RETURN n",
|
|
"MATCH (n:Company) WHERE n.name = 'ACME' RETURN n",
|
|
"MATCH (p:Person)-[:WORKS_FOR]->(c:Company) RETURN p, c",
|
|
"CREATE (n:Person {name: 'John', age: 30})",
|
|
"MERGE (n:Company {name: 'ACME'}) RETURN n",
|
|
"MATCH (n:Person) SET n.updated = timestamp() RETURN n",
|
|
]
|
|
|
|
for query in safe_queries:
|
|
assert _is_safe_query(query), f"Query should be safe: {query}"
|
|
|
|
def test_unsafe_queries(self) -> None:
|
|
"""Test queries that should be considered unsafe"""
|
|
unsafe_queries = [
|
|
"MATCH (n) DELETE n", # Delete all nodes
|
|
"DROP INDEX ON :Person(name)", # Schema modification
|
|
"CREATE INDEX ON :Person(name)", # Schema modification
|
|
"CALL db.schema.visualization()", # System procedure
|
|
"CALL apoc.export.json.all('file.json', {})", # APOC procedure
|
|
"LOAD CSV FROM 'file:///etc/passwd' AS line RETURN line", # File access
|
|
"CALL dbms.procedures()", # System information
|
|
"MATCH (n) DETACH DELETE n", # Delete all nodes and relationships
|
|
]
|
|
|
|
for query in unsafe_queries:
|
|
assert not _is_safe_query(query), f"Query should be unsafe: {query}"
|
|
|
|
def test_query_safety_case_insensitive(self) -> None:
|
|
"""Test query safety is case insensitive"""
|
|
unsafe_queries = [
|
|
"match (n) delete n",
|
|
"MATCH (N) DELETE N",
|
|
"Match (n) Delete n",
|
|
"drop index on :Person(name)",
|
|
"DROP INDEX ON :PERSON(NAME)",
|
|
]
|
|
|
|
for query in unsafe_queries:
|
|
assert not _is_safe_query(query), f"Query should be unsafe: {query}"
|
|
|
|
def test_query_safety_with_comments(self) -> None:
|
|
"""Test query safety with comments"""
|
|
queries_with_comments = [
|
|
"// This is a comment\nMATCH (n:Person) RETURN n",
|
|
"/* Multi-line comment */\nMATCH (n:Person) RETURN n",
|
|
"MATCH (n:Person) RETURN n // End comment",
|
|
]
|
|
|
|
for query in queries_with_comments:
|
|
# Comments don't affect safety - depends on actual query
|
|
result = _is_safe_query(query)
|
|
assert isinstance(result, bool)
|
|
|
|
|
|
class TestNodeValidation:
|
|
"""Test SHACL node validation"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_node_with_validator(self) -> None:
|
|
"""Test node validation when SHACL validator is available"""
|
|
# Mock the SHACL validator
|
|
with patch("apps.svc_kg.main.shacl_validator") as mock_validator:
|
|
mock_validator.validate_graph = AsyncMock(
|
|
return_value={
|
|
"conforms": True,
|
|
"violations_count": 0,
|
|
"results_text": "",
|
|
}
|
|
)
|
|
|
|
properties = {"name": "John Doe", "age": 30, "email": "john@example.com"}
|
|
|
|
result = await _validate_node("Person", properties)
|
|
assert result is True
|
|
|
|
# Verify validator was called
|
|
mock_validator.validate_graph.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_node_validation_failure(self) -> None:
|
|
"""Test node validation failure"""
|
|
# Mock the SHACL validator to return validation errors
|
|
with patch("apps.svc_kg.main.shacl_validator") as mock_validator:
|
|
mock_validator.validate_graph = AsyncMock(
|
|
return_value={
|
|
"conforms": False,
|
|
"violations_count": 1,
|
|
"results_text": "Name is required",
|
|
}
|
|
)
|
|
|
|
properties = {"age": 30} # Missing required name
|
|
|
|
result = await _validate_node("Person", properties)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_node_no_validator(self) -> None:
|
|
"""Test node validation when no SHACL validator is available"""
|
|
# Mock no validator available
|
|
with patch("apps.svc_kg.main.shacl_validator", None):
|
|
properties = {"name": "John Doe", "age": 30}
|
|
|
|
result = await _validate_node("Person", properties)
|
|
# Should return True when no validator is available
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_node_validator_exception(self) -> None:
|
|
"""Test node validation when validator raises exception"""
|
|
# Mock the SHACL validator to raise an exception
|
|
with patch("apps.svc_kg.main.shacl_validator") as mock_validator:
|
|
mock_validator.validate_graph = AsyncMock(
|
|
side_effect=Exception("Validation error")
|
|
)
|
|
|
|
properties = {"name": "John Doe", "age": 30}
|
|
|
|
result = await _validate_node("Person", properties)
|
|
# Should return True when validation fails with exception (to not block operations)
|
|
assert result is True
|
|
|
|
|
|
class TestBitemporalDataHandling:
|
|
"""Test bitemporal data handling concepts"""
|
|
|
|
def test_bitemporal_properties(self) -> None:
|
|
"""Test bitemporal property structure"""
|
|
# Test the concept of bitemporal properties
|
|
# In a real implementation, this would test actual bitemporal logic
|
|
|
|
# Valid time: when the fact was true in reality
|
|
# Transaction time: when the fact was recorded in the database
|
|
|
|
bitemporal_properties = {
|
|
"name": "John Doe",
|
|
"valid_from": "2024-01-01T00:00:00Z",
|
|
"valid_to": "9999-12-31T23:59:59Z", # Current/ongoing
|
|
"transaction_from": "2024-01-15T10:30:00Z",
|
|
"transaction_to": "9999-12-31T23:59:59Z", # Current version
|
|
"retracted_at": None, # Not retracted
|
|
}
|
|
|
|
# Test required bitemporal fields are present
|
|
assert "valid_from" in bitemporal_properties
|
|
assert "valid_to" in bitemporal_properties
|
|
assert "transaction_from" in bitemporal_properties
|
|
assert "transaction_to" in bitemporal_properties
|
|
assert "retracted_at" in bitemporal_properties
|
|
|
|
# Test that current version has future end times
|
|
assert bitemporal_properties["valid_to"] == "9999-12-31T23:59:59Z"
|
|
assert bitemporal_properties["transaction_to"] == "9999-12-31T23:59:59Z"
|
|
assert bitemporal_properties["retracted_at"] is None
|
|
|
|
def test_retracted_properties(self) -> None:
|
|
"""Test retracted bitemporal properties"""
|
|
retracted_properties = {
|
|
"name": "John Doe",
|
|
"valid_from": "2024-01-01T00:00:00Z",
|
|
"valid_to": "2024-06-30T23:59:59Z", # No longer valid
|
|
"transaction_from": "2024-01-15T10:30:00Z",
|
|
"transaction_to": "2024-07-01T09:00:00Z", # Superseded
|
|
"retracted_at": "2024-07-01T09:00:00Z", # Retracted
|
|
}
|
|
|
|
# Test retracted properties
|
|
assert retracted_properties["retracted_at"] is not None
|
|
assert retracted_properties["valid_to"] != "9999-12-31T23:59:59Z"
|
|
assert retracted_properties["transaction_to"] != "9999-12-31T23:59:59Z"
|
|
|
|
|
|
class TestRDFExportConcepts:
|
|
"""Test RDF export format concepts"""
|
|
|
|
def test_supported_rdf_formats(self) -> None:
|
|
"""Test supported RDF formats concepts"""
|
|
# Test RDF format concepts (not actual implementation)
|
|
supported_formats = ["turtle", "rdf/xml", "n-triples", "json-ld"]
|
|
|
|
# Test that common RDF formats are supported
|
|
assert "turtle" in supported_formats
|
|
assert "rdf/xml" in supported_formats
|
|
assert "n-triples" in supported_formats
|
|
assert "json-ld" in supported_formats
|
|
|
|
def test_rdf_format_validation(self) -> None:
|
|
"""Test RDF format validation logic concepts"""
|
|
valid_formats = ["turtle", "rdf/xml", "n-triples", "json-ld"]
|
|
|
|
# Test format validation concepts
|
|
for format_name in valid_formats:
|
|
assert format_name in valid_formats
|
|
|
|
# Test invalid formats
|
|
invalid_formats = ["invalid", "xml", "json", "yaml"]
|
|
for invalid_format in invalid_formats:
|
|
assert invalid_format not in valid_formats
|
|
|
|
|
|
class TestKnowledgeGraphConcepts:
|
|
"""Test knowledge graph concepts and patterns"""
|
|
|
|
def test_entity_relationship_patterns(self) -> None:
|
|
"""Test common entity-relationship patterns"""
|
|
# Test typical tax domain entities and relationships
|
|
|
|
# Person entity
|
|
person_properties = {
|
|
"id": "person_123",
|
|
"name": "John Doe",
|
|
"type": "Individual",
|
|
"utr": "1234567890",
|
|
"nino": "AB123456C",
|
|
}
|
|
|
|
# Company entity
|
|
company_properties = {
|
|
"id": "company_456",
|
|
"name": "ACME Corp Ltd",
|
|
"type": "Company",
|
|
"company_number": "12345678",
|
|
"utr": "0987654321",
|
|
}
|
|
|
|
# Income entity
|
|
income_properties = {
|
|
"id": "income_789",
|
|
"amount": 50000.0,
|
|
"currency": "GBP",
|
|
"tax_year": "2023-24",
|
|
"type": "employment_income",
|
|
}
|
|
|
|
# Test entity structure
|
|
for entity in [person_properties, company_properties, income_properties]:
|
|
assert "id" in entity
|
|
assert "type" in entity
|
|
|
|
# Test relationship concepts
|
|
relationships = [
|
|
{"from": "person_123", "to": "company_456", "type": "EMPLOYED_BY"},
|
|
{"from": "person_123", "to": "income_789", "type": "RECEIVES"},
|
|
{"from": "income_789", "to": "company_456", "type": "PAID_BY"},
|
|
]
|
|
|
|
for relationship in relationships:
|
|
assert "from" in relationship
|
|
assert "to" in relationship
|
|
assert "type" in relationship
|
|
|
|
def test_tax_domain_entities(self) -> None:
|
|
"""Test tax domain specific entities"""
|
|
tax_entities = {
|
|
"TaxpayerProfile": {
|
|
"required_fields": ["utr", "name", "tax_year"],
|
|
"optional_fields": ["nino", "address", "phone"],
|
|
},
|
|
"IncomeItem": {
|
|
"required_fields": ["amount", "currency", "tax_year", "source"],
|
|
"optional_fields": ["description", "date_received"],
|
|
},
|
|
"ExpenseItem": {
|
|
"required_fields": ["amount", "currency", "category", "tax_year"],
|
|
"optional_fields": ["description", "receipt_reference"],
|
|
},
|
|
"TaxCalculation": {
|
|
"required_fields": ["tax_year", "total_income", "total_tax"],
|
|
"optional_fields": ["allowances", "reliefs", "schedule"],
|
|
},
|
|
}
|
|
|
|
# Test that each entity type has required structure
|
|
for entity_type, schema in tax_entities.items():
|
|
assert "required_fields" in schema
|
|
assert "optional_fields" in schema
|
|
assert len(schema["required_fields"]) > 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__])
|