* test: add unit tests for pg_trgm auto-detection and ValidationResult.accept_with() enrichment Two recent PRs landed without dedicated tests: - #626/#649 (pg_trgm fallback in EntityResolver): add 5 mocked unit tests covering the trigram→full fallback, single-check guarantee, and sticky downgrade behaviour. - #639 (accept_with() enrichment): add 7 pure unit tests for the factory method plus 5 integration tests verifying the engine applies enriched contents (retain) and tags/tag_groups (recall) returned by validators. Also verifies RecallContext carries tag filter state. * fix: remove 504 from reflect OpenAPI spec to fix progenitor Rust client build progenitor-impl-0.11.2 panics with `assertion failed: response_types.len() <= 1` when an endpoint declares more than one response type. PR #643 added `responses={504: ...}` to the reflect decorator, which injected a second response type into the generated OpenAPI spec and broke the Rust client build. Remove the `responses=` kwarg — the 504 is still raised at runtime via JSONResponse(status_code=504), it just won't appear in the OpenAPI schema. Regenerate openapi.json accordingly. * chore: sync generated files and ruff formatting (lint + docs skill)
163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""
|
|
Unit tests for EntityResolver pg_trgm auto-detection (PR #626/#649).
|
|
|
|
These tests verify:
|
|
1. When entity_lookup="trigram" and pg_trgm IS available, the trigram path is used.
|
|
2. When entity_lookup="trigram" and pg_trgm is NOT available, the resolver falls back
|
|
to entity_lookup="full" and uses the full-scan path.
|
|
3. The pg_trgm check is only performed once (_pg_trgm_checked flag prevents re-checking).
|
|
4. When entity_lookup="full" from the start, the trgm check is never performed.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hindsight_api.engine.entity_resolver import EntityResolver
|
|
|
|
|
|
def _make_conn(pg_trgm_available: bool) -> MagicMock:
|
|
"""Create a minimal mock asyncpg connection for the pg_trgm availability check."""
|
|
conn = MagicMock()
|
|
conn.fetchval = AsyncMock(return_value=pg_trgm_available)
|
|
conn.fetch = AsyncMock(return_value=[])
|
|
conn.executemany = AsyncMock()
|
|
conn.fetchrow = AsyncMock(return_value=None)
|
|
return conn
|
|
|
|
|
|
def _make_resolver(entity_lookup: str = "trigram") -> EntityResolver:
|
|
"""Return an EntityResolver with a None pool (not needed for unit tests)."""
|
|
return EntityResolver(pool=None, entity_lookup=entity_lookup) # type: ignore[arg-type]
|
|
|
|
|
|
class TestPgTrgmAutoDetection:
|
|
"""Unit tests for pg_trgm detection logic inside _resolve_entities_batch_impl."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_full_when_pg_trgm_unavailable(self):
|
|
"""When pg_trgm is absent the resolver switches to 'full' and calls the full-scan path."""
|
|
resolver = _make_resolver(entity_lookup="trigram")
|
|
conn = _make_conn(pg_trgm_available=False)
|
|
|
|
with (
|
|
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
|
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
|
):
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="test-bank",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
|
|
# Trigram path must NOT be called
|
|
mock_trgm.assert_not_called()
|
|
# Full-scan path must be called as the fallback
|
|
mock_full.assert_called_once()
|
|
# Strategy is permanently downgraded
|
|
assert resolver.entity_lookup == "full"
|
|
assert resolver._pg_trgm_checked is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_trigram_when_pg_trgm_available(self):
|
|
"""When pg_trgm is present the trigram path is used."""
|
|
resolver = _make_resolver(entity_lookup="trigram")
|
|
conn = _make_conn(pg_trgm_available=True)
|
|
|
|
with (
|
|
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
|
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
|
):
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="test-bank",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
|
|
mock_trgm.assert_called_once()
|
|
mock_full.assert_not_called()
|
|
assert resolver.entity_lookup == "trigram"
|
|
assert resolver._pg_trgm_checked is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pg_trgm_check_performed_only_once(self):
|
|
"""The fetchval check is only issued on the first call; subsequent calls skip it."""
|
|
resolver = _make_resolver(entity_lookup="trigram")
|
|
conn = _make_conn(pg_trgm_available=True)
|
|
|
|
with patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])):
|
|
# First call — check is issued
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="test-bank",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
# Second call — check must NOT be issued again
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="test-bank",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
|
|
# fetchval (the pg_trgm availability query) should be called exactly once
|
|
assert conn.fetchval.call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_strategy_skips_pg_trgm_check(self):
|
|
"""When entity_lookup='full' from the start, no pg_trgm check is ever issued."""
|
|
resolver = _make_resolver(entity_lookup="full")
|
|
conn = _make_conn(pg_trgm_available=False)
|
|
|
|
with patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])):
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="test-bank",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
|
|
# fetchval should never be called when entity_lookup is already "full"
|
|
conn.fetchval.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_is_sticky_across_calls(self):
|
|
"""After falling back to 'full', subsequent calls also use the full path."""
|
|
resolver = _make_resolver(entity_lookup="trigram")
|
|
conn = _make_conn(pg_trgm_available=False)
|
|
|
|
with (
|
|
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
|
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
|
):
|
|
# First call triggers the fallback
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="b",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
# Second call — _pg_trgm_checked is True so no re-check; entity_lookup=="full"
|
|
await resolver._resolve_entities_batch_impl(
|
|
conn=conn,
|
|
bank_id="b",
|
|
entities_data=[],
|
|
context="",
|
|
unit_event_date=None,
|
|
)
|
|
|
|
# Trigram path is never called
|
|
mock_trgm.assert_not_called()
|
|
# Full-scan path is called both times
|
|
assert mock_full.call_count == 2
|
|
# pg_trgm check was issued exactly once
|
|
assert conn.fetchval.call_count == 1
|