mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
c97733d785
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
10 KiB
Python
348 lines
10 KiB
Python
"""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)
|