Files
br-acc/api/tests/unit/test_patterns.py

140 lines
5.2 KiB
Python

from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from icarus.config import settings
from icarus.models.pattern import PATTERN_METADATA
from icarus.services.pattern_service import PATTERN_QUERIES
@pytest.fixture(autouse=True)
def _enable_patterns(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "patterns_enabled", True)
def test_all_patterns_have_metadata() -> None:
for pattern_id in PATTERN_QUERIES:
assert pattern_id in PATTERN_METADATA, f"Missing metadata for {pattern_id}"
def test_all_patterns_have_query_files() -> None:
from icarus.services.neo4j_service import CypherLoader
for _pattern_id, query_name in PATTERN_QUERIES.items():
try:
CypherLoader.load(query_name)
except FileNotFoundError:
pytest.fail(f"Missing .cypher file for pattern {query_name}.cypher")
finally:
CypherLoader.clear_cache()
def test_pattern_metadata_has_required_fields() -> None:
for pid, meta in PATTERN_METADATA.items():
assert "name_pt" in meta, f"{pid} missing name_pt"
assert "name_en" in meta, f"{pid} missing name_en"
assert "desc_pt" in meta, f"{pid} missing desc_pt"
assert "desc_en" in meta, f"{pid} missing desc_en"
@pytest.mark.anyio
async def test_list_patterns_endpoint(client: AsyncClient) -> None:
response = await client.get("/api/v1/patterns/")
assert response.status_code == 200
data = response.json()
assert "patterns" in data
assert len(data["patterns"]) == 4
ids = {p["id"] for p in data["patterns"]}
assert "sanctioned_still_receiving" in ids
assert "debtor_contracts" in ids
@pytest.mark.anyio
async def test_patterns_endpoint_returns_503_when_disabled(
client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "patterns_enabled", False)
response = await client.get("/api/v1/patterns/")
assert response.status_code == 503
assert "temporarily unavailable" in response.json()["detail"]
@pytest.mark.anyio
async def test_invalid_pattern_returns_404(client: AsyncClient) -> None:
response = await client.get("/api/v1/patterns/test-id/nonexistent_pattern")
assert response.status_code == 404
assert "Pattern not found" in response.json()["detail"]
@pytest.mark.anyio
async def test_patterns_endpoint_forwards_include_probable(client: AsyncClient) -> None:
with patch("icarus.routers.patterns.run_all_patterns", new_callable=AsyncMock) as mock_run_all:
mock_run_all.return_value = []
response = await client.get("/api/v1/patterns/test-id?include_probable=true")
assert response.status_code == 200
mock_run_all.assert_awaited_once()
_driver, _entity_id, _lang = mock_run_all.await_args.args
assert _entity_id == "test-id"
assert mock_run_all.await_args.kwargs["include_probable"] is True
@pytest.mark.anyio
async def test_specific_pattern_endpoint_forwards_include_probable(client: AsyncClient) -> None:
with patch("icarus.routers.patterns.run_pattern", new_callable=AsyncMock) as mock_run_one:
mock_run_one.return_value = []
response = await client.get(
"/api/v1/patterns/test-id/debtor_contracts?include_probable=true",
)
assert response.status_code == 200
mock_run_one.assert_awaited_once()
_session, _pattern_name, _entity_id, _lang = mock_run_one.await_args.args
assert _pattern_name == "debtor_contracts"
assert _entity_id == "test-id"
assert mock_run_one.await_args.kwargs["include_probable"] is True
def test_patrimony_query_guards_divide_by_zero() -> None:
"""pattern_patrimony.cypher must require patrimonio_declarado > 0 to avoid div-by-zero."""
from icarus.services.neo4j_service import CypherLoader
try:
cypher = CypherLoader.load("pattern_patrimony")
finally:
CypherLoader.clear_cache()
assert "patrimonio_declarado > 0" in cypher, (
"pattern_patrimony.cypher missing 'patrimonio_declarado > 0' guard — "
"ratio computation will divide by zero"
)
def test_pattern_queries_use_parameter_binding() -> None:
"""All pattern .cypher files must use $entity_id parameter binding, not string interpolation."""
from icarus.services.neo4j_service import CypherLoader
from icarus.services.pattern_service import PATTERN_QUERIES
for _pattern_id, query_name in PATTERN_QUERIES.items():
try:
cypher = CypherLoader.load(query_name)
finally:
CypherLoader.clear_cache()
assert "$entity_id" in cypher, (
f"{query_name}.cypher missing $entity_id parameter binding"
)
# No f-string or .format() injection patterns
assert "${" not in cypher, (
f"{query_name}.cypher uses string interpolation (unsafe)"
)
def test_no_banned_words_in_pattern_metadata() -> None:
banned = {"suspicious", "corrupt", "criminal", "fraudulent", "illegal", "guilty"}
for pid, meta in PATTERN_METADATA.items():
for key, value in meta.items():
for word in banned:
assert word not in value.lower(), (
f"Banned word '{word}' in {pid}.{key}: {value}"
)