"""Tests for v2 schema router endpoints.
Tests the integration layer where ORM entities are converted to NoteData
and passed through the schema engine (infer, validate, diff).
Note: EntityType uses BeforeValidator(to_snake_case) so "Person" becomes "person"
in the database. All query params must use the stored (snake_case) form.
"""
from textwrap import dedent
import pytest
from httpx import AsyncClient
from basic_memory.models import Project
from basic_memory.schemas.base import Entity as EntitySchema
# --- Helpers ---
async def create_person_entities(entity_service, search_service):
"""Create multiple person entities with observations and relations for testing.
Returns the created entities after indexing them for search.
"""
persons = [
EntitySchema(
title="Alice",
directory="people",
entity_type="person",
content=dedent("""\
## Observations
- [name] Alice Smith
- [role] Engineer
- [expertise] Python
## Relations
- works_at [[Acme Corp]]
"""),
),
EntitySchema(
title="Bob",
directory="people",
entity_type="person",
content=dedent("""\
## Observations
- [name] Bob Jones
- [role] Designer
- [expertise] UI/UX
## Relations
- works_at [[Acme Corp]]
"""),
),
EntitySchema(
title="Carol",
directory="people",
entity_type="person",
content=dedent("""\
## Observations
- [name] Carol Lee
- [role] Manager
## Relations
- works_at [[Globex]]
"""),
),
]
created = []
for schema in persons:
entity, _ = await entity_service.create_or_update_entity(schema)
await search_service.index_entity(entity)
created.append(entity)
return created
# --- Infer Endpoint Tests ---
@pytest.mark.asyncio
async def test_infer_schema(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Infer a schema from person notes with observations and relations."""
await create_person_entities(entity_service, search_service)
response = await client.post(
f"{v2_project_url}/schema/infer",
params={"entity_type": "person"},
)
assert response.status_code == 200
data = response.json()
assert data["entity_type"] == "person"
assert data["notes_analyzed"] == 3
assert isinstance(data["field_frequencies"], list)
assert isinstance(data["suggested_schema"], dict)
assert isinstance(data["suggested_required"], list)
assert isinstance(data["suggested_optional"], list)
# "name" and "role" appear in all 3 notes -> should be in frequencies
freq_names = [f["name"] for f in data["field_frequencies"]]
assert "name" in freq_names
assert "role" in freq_names
# "name" appears in 100% of notes -> should be suggested as required
assert "name" in data["suggested_required"]
assert "role" in data["suggested_required"]
@pytest.mark.asyncio
async def test_infer_schema_no_matching_notes(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
):
"""Infer returns empty result when no notes of the given type exist."""
response = await client.post(
f"{v2_project_url}/schema/infer",
params={"entity_type": "nonexistent"},
)
assert response.status_code == 200
data = response.json()
assert data["notes_analyzed"] == 0
assert data["field_frequencies"] == []
assert data["suggested_schema"] == {}
assert data["suggested_required"] == []
assert data["suggested_optional"] == []
@pytest.mark.asyncio
async def test_infer_schema_includes_relations(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Infer detects relation types as fields alongside observations."""
await create_person_entities(entity_service, search_service)
response = await client.post(
f"{v2_project_url}/schema/infer",
params={"entity_type": "person"},
)
assert response.status_code == 200
data = response.json()
# "works_at" relation appears in all 3 notes -> should be in frequencies
freq_names = [f["name"] for f in data["field_frequencies"]]
assert "works_at" in freq_names
# Check it's identified as a relation source
works_at = next(f for f in data["field_frequencies"] if f["name"] == "works_at")
assert works_at["source"] == "relation"
assert works_at["count"] == 3
# --- Validate Endpoint Tests ---
@pytest.mark.asyncio
async def test_validate_with_inline_schema(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate a person note against an inline schema in its entity_metadata."""
# Create a person entity with an inline schema in entity_metadata.
# The resolver will pick this up as resolution path 1 (inline schema).
entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Dave",
directory="people",
entity_type="person",
entity_metadata={
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [name] Dave Wilson
- [role] Architect
"""),
)
)
await search_service.index_entity(entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"entity_type": "person"},
)
assert response.status_code == 200
data = response.json()
# Entity was found and validated
assert data["total_notes"] == 1
assert isinstance(data["results"], list)
assert len(data["results"]) == 1
result = data["results"][0]
assert result["passed"] is True
assert result["schema_entity"] == "person"
# Both "name" and "role" should be present
field_statuses = {fr["field_name"]: fr["status"] for fr in result["field_results"]}
assert field_statuses["name"] == "present"
assert field_statuses["role"] == "present"
@pytest.mark.asyncio
async def test_validate_missing_required_field(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate detects a missing required field and produces a warning."""
# Schema requires "name" and "role", but note only has "name"
entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Eve",
directory="people",
entity_type="person",
entity_metadata={
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [name] Eve Martinez
"""),
)
)
await search_service.index_entity(entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"entity_type": "person"},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
result = data["results"][0]
# Default validation mode is "warn" so missing required field produces warning, not error
assert result["passed"] is True
assert len(result["warnings"]) > 0
assert any("role" in w for w in result["warnings"])
@pytest.mark.asyncio
async def test_validate_no_matching_notes(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
):
"""Validate returns empty result when no notes of the given type exist."""
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"entity_type": "nonexistent"},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 0
assert data["results"] == []
# --- Diff Endpoint Tests ---
@pytest.mark.asyncio
async def test_diff_no_schema_found(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
):
"""Diff returns empty drift report when no schema exists for the type."""
response = await client.get(
f"{v2_project_url}/schema/diff/nonexistent",
)
assert response.status_code == 200
data = response.json()
assert data["entity_type"] == "nonexistent"
assert data["new_fields"] == []
assert data["dropped_fields"] == []
assert data["cardinality_changes"] == []
@pytest.mark.asyncio
async def test_diff_with_schema_note(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Diff detects new fields and drift when a schema note exists."""
# Create a schema note (type="schema" with entity/schema metadata).
# The search_fn in the router looks for schema notes by searching
# with types=["schema"] and then returns row.metadata from the search index.
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Person Schema",
directory="schemas",
entity_type="schema",
entity_metadata={
"entity": "person",
"version": 1,
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [note] Schema definition for person entities
"""),
)
)
await search_service.index_entity(schema_entity)
# Create person entities with extra fields not in schema
await create_person_entities(entity_service, search_service)
response = await client.get(
f"{v2_project_url}/schema/diff/person",
)
assert response.status_code == 200
data = response.json()
assert data["entity_type"] == "person"
assert isinstance(data["new_fields"], list)
assert isinstance(data["dropped_fields"], list)
assert isinstance(data["cardinality_changes"], list)