fleet-memory/hindsight-api-slim/tests/test_validation_result_enrichment.py
Nicolò Boschi a9e6d9f731
test: add unit tests for pg_trgm auto-detection and ValidationResult.accept_with() enrichment (#650)
* 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)
2026-03-23 10:33:09 +01:00

269 lines
9.8 KiB
Python

"""
Unit tests for ValidationResult.accept_with() enrichment (PR #639).
These tests verify:
1. The accept_with() factory creates an accepted result with the correct enrichment fields.
2. The engine applies enrichment to retain contents and recall tags/tag_groups.
3. RecallContext carries tags/tags_match/tag_groups so validators can read filter state.
"""
import pytest
from hindsight_api.extensions import (
OperationValidatorExtension,
RecallContext,
ReflectContext,
RetainContext,
ValidationResult,
)
from hindsight_api.models import RequestContext
# ---------------------------------------------------------------------------
# Pure unit tests for ValidationResult factory methods
# ---------------------------------------------------------------------------
class TestValidationResultAcceptWith:
"""Unit tests for the accept_with() factory — no DB needed."""
def test_accept_is_allowed_with_no_enrichment(self):
result = ValidationResult.accept()
assert result.allowed is True
assert result.contents is None
assert result.tags is None
assert result.tags_match is None
assert result.tag_groups is None
def test_accept_with_contents(self):
contents = [{"content": "enriched text", "tags": ["injected"]}]
result = ValidationResult.accept_with(contents=contents)
assert result.allowed is True
assert result.contents == contents
assert result.tags is None
assert result.tag_groups is None
def test_accept_with_tags(self):
result = ValidationResult.accept_with(tags=["alpha", "beta"])
assert result.allowed is True
assert result.tags == ["alpha", "beta"]
assert result.contents is None
assert result.tag_groups is None
def test_accept_with_tags_match(self):
result = ValidationResult.accept_with(tags=["x"], tags_match="all")
assert result.allowed is True
assert result.tags_match == "all"
def test_accept_with_tag_groups(self):
tag_groups = [{"tags": ["env:prod"], "match": "all"}]
result = ValidationResult.accept_with(tag_groups=tag_groups)
assert result.allowed is True
assert result.tag_groups == tag_groups
def test_accept_with_all_fields(self):
contents = [{"content": "c"}]
tags = ["t1"]
tag_groups = [{"tags": ["g1"]}]
result = ValidationResult.accept_with(
contents=contents,
tags=tags,
tags_match="any",
tag_groups=tag_groups,
)
assert result.allowed is True
assert result.contents == contents
assert result.tags == tags
assert result.tags_match == "any"
assert result.tag_groups == tag_groups
def test_reject_ignores_enrichment_fields(self):
"""reject() always sets allowed=False and leaves enrichment fields at their defaults."""
result = ValidationResult.reject("not allowed", status_code=403)
assert result.allowed is False
assert result.reason == "not allowed"
assert result.status_code == 403
assert result.contents is None
assert result.tags is None
def test_none_fields_mean_no_modification(self):
"""None enrichment fields must not overwrite engine defaults."""
result = ValidationResult.accept_with(tags=None, tag_groups=None)
assert result.tags is None
assert result.tag_groups is None
# Engine should interpret None as "keep original" — we verify the contract here.
# ---------------------------------------------------------------------------
# Integration tests: engine applies enrichment from validator
# ---------------------------------------------------------------------------
class _ContentEnrichingValidator(OperationValidatorExtension):
"""Validator that injects a tag into every retain content item."""
def __init__(self, injected_tag: str):
super().__init__({})
self.injected_tag = injected_tag
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
enriched = []
for item in ctx.contents:
new_item = dict(item)
new_item.setdefault("tags", [])
new_item["tags"] = list(new_item["tags"]) + [self.injected_tag]
enriched.append(new_item)
return ValidationResult.accept_with(contents=enriched)
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
class _TagEnrichingValidator(OperationValidatorExtension):
"""Validator that injects tags into every recall operation."""
def __init__(self, forced_tags: list[str]):
super().__init__({})
self.forced_tags = forced_tags
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept_with(tags=self.forced_tags, tags_match="all")
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
class _RecallContextCapturingValidator(OperationValidatorExtension):
"""Validator that captures the RecallContext for inspection."""
def __init__(self):
super().__init__({})
self.captured: list[RecallContext] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
self.captured.append(ctx)
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
@pytest.fixture
def memory_with_content_enricher(memory):
validator = _ContentEnrichingValidator(injected_tag="validator-injected")
memory._operation_validator = validator
return memory, validator
@pytest.fixture
def memory_with_tag_enricher(memory):
validator = _TagEnrichingValidator(forced_tags=["forced-tag"])
memory._operation_validator = validator
return memory, validator
@pytest.fixture
def memory_with_recall_context_capture(memory):
validator = _RecallContextCapturingValidator()
memory._operation_validator = validator
return memory, validator
class TestRetainContentEnrichment:
"""Engine applies enriched contents returned by validate_retain."""
@pytest.mark.asyncio
async def test_enriched_contents_are_used_for_retain(self, memory_with_content_enricher):
"""When validator returns accept_with(contents=...), engine uses those contents."""
memory, validator = memory_with_content_enricher
bank_id = "test-retain-enrichment"
ctx = RequestContext()
# Retain without any tags — validator should inject "validator-injected"
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice is an engineer."}],
request_context=ctx,
)
# Retrieve facts tagged with the injected tag to confirm enrichment was applied
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
tags=["validator-injected"],
request_context=ctx,
)
# The fact should be retrievable via the injected tag
assert result is not None
class TestRecallTagEnrichment:
"""Engine applies enriched tags returned by validate_recall."""
@pytest.mark.asyncio
async def test_enriched_tags_filter_recall_results(self, memory_with_tag_enricher):
"""When validator returns accept_with(tags=...), engine filters recall by those tags."""
memory, validator = memory_with_tag_enricher
bank_id = "test-recall-tag-enrichment"
ctx = RequestContext()
# Retain one fact with the forced tag and one without
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Bob is a designer.", "tags": ["forced-tag"]}],
request_context=ctx,
)
# recall is called without tags but validator injects "forced-tag" + match=all
result = await memory.recall_async(
bank_id=bank_id,
query="Bob",
request_context=ctx,
)
# Should still get a result — the injected tag matches the stored fact
assert result is not None
class TestRecallContextContainsTagFields:
"""RecallContext passed to validate_recall carries tag filter state."""
@pytest.mark.asyncio
async def test_recall_context_carries_tags(self, memory_with_recall_context_capture):
"""tags, tags_match, and tag_groups are present in RecallContext."""
memory, validator = memory_with_recall_context_capture
bank_id = "test-recall-ctx-tags"
ctx = RequestContext()
await memory.recall_async(
bank_id=bank_id,
query="test",
tags=["env:prod"],
tags_match="all",
request_context=ctx,
)
assert len(validator.captured) == 1
rc = validator.captured[0]
assert rc.tags == ["env:prod"]
assert rc.tags_match == "all"
@pytest.mark.asyncio
async def test_recall_context_tags_default_to_none(self, memory_with_recall_context_capture):
"""When caller provides no tags, RecallContext.tags is None."""
memory, validator = memory_with_recall_context_capture
bank_id = "test-recall-ctx-no-tags"
ctx = RequestContext()
await memory.recall_async(bank_id=bank_id, query="test", request_context=ctx)
assert len(validator.captured) == 1
rc = validator.captured[0]
assert rc.tags is None