mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
9b199c6dcb
Add configurable cache_dir, threads, and parallel settings for FastEmbed to support cloud deployments where defaults fail. Cache embedding providers at the process level to avoid re-creating heavy ONNX model instances. - Add semantic_embedding_cache_dir, semantic_embedding_threads, and semantic_embedding_parallel config fields - Thread-safe provider cache with double-checked locking in factory - Forward runtime knobs through to TextEmbedding and embed() calls - Fix if/elif chain in factory for correct error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
902 lines
27 KiB
Python
902 lines
27 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 pathlib import Path
|
|
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
|
|
from basic_memory.services.file_service import FileService
|
|
|
|
|
|
# --- 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",
|
|
note_type="person",
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Alice Smith
|
|
- [role] Engineer
|
|
- [expertise] Python
|
|
|
|
## Relations
|
|
- works_at [[Acme Corp]]
|
|
"""),
|
|
),
|
|
EntitySchema(
|
|
title="Bob",
|
|
directory="people",
|
|
note_type="person",
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Bob Jones
|
|
- [role] Designer
|
|
- [expertise] UI/UX
|
|
|
|
## Relations
|
|
- works_at [[Acme Corp]]
|
|
"""),
|
|
),
|
|
EntitySchema(
|
|
title="Carol",
|
|
directory="people",
|
|
note_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={"note_type": "person"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["note_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={"note_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={"note_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",
|
|
note_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={"note_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_with_explicit_schema_reference_by_permalink_slug(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
):
|
|
"""Validate resolves explicit schema refs by exact schema identifier (non-fuzzy)."""
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Strict Person V2",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "person",
|
|
"schema": {"name": "string", "role": "string"},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Strict schema for person notes
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Frank",
|
|
directory="people",
|
|
note_type="person",
|
|
entity_metadata={
|
|
# Explicit schema identifier does not equal entity metadata field ("person")
|
|
# and must resolve by schema identifier, not fuzzy text search.
|
|
"schema": "strict-person-v2",
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Frank Nguyen
|
|
- [role] Engineer
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"identifier": note_entity.permalink},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_notes"] == 1
|
|
assert len(data["results"]) == 1
|
|
assert data["results"][0]["schema_entity"] == "person"
|
|
assert data["results"][0]["passed"] is True
|
|
|
|
|
|
@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",
|
|
note_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={"note_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={"note_type": "nonexistent"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_notes"] == 0
|
|
assert data["total_entities"] == 0
|
|
assert data["results"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_total_entities_without_schema(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
):
|
|
"""Validate reports total_entities even when no schema exists (total_notes == 0)."""
|
|
await create_person_entities(entity_service, search_service)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"note_type": "person"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# No schema -> no notes validated
|
|
assert data["total_notes"] == 0
|
|
# But entities of this type do exist
|
|
assert data["total_entities"] == 3
|
|
assert data["results"] == []
|
|
|
|
|
|
# --- Frontmatter Validation Tests ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_with_frontmatter_rules_passes(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
):
|
|
"""Validate a note whose frontmatter matches settings.frontmatter rules."""
|
|
# Schema note with frontmatter validation rules
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Person Schema FM",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "person_fm",
|
|
"schema": {"name": "string"},
|
|
"settings": {
|
|
"validation": "warn",
|
|
"frontmatter": {
|
|
"tags?(array)": "string",
|
|
"status?(enum)": ["draft", "published"],
|
|
},
|
|
},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema with frontmatter rules
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Note with matching frontmatter
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Grace",
|
|
directory="people",
|
|
note_type="person_fm",
|
|
entity_metadata={
|
|
"tags": ["engineer", "python"],
|
|
"status": "published",
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Grace Hopper
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"identifier": note_entity.permalink},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_notes"] == 1
|
|
result = data["results"][0]
|
|
assert result["passed"] is True
|
|
assert result["warnings"] == []
|
|
# Should have field results for "name" (observation) + "tags" + "status" (frontmatter)
|
|
field_names = [fr["field_name"] for fr in result["field_results"]]
|
|
assert "tags" in field_names
|
|
assert "status" in field_names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_frontmatter_missing_required_key_warns(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
):
|
|
"""Missing required frontmatter key produces a warning."""
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Person Schema FM2",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "person_fm2",
|
|
"schema": {"name": "string"},
|
|
"settings": {
|
|
"validation": "warn",
|
|
"frontmatter": {
|
|
"status": "string", # required
|
|
},
|
|
},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema requiring status frontmatter
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Note without the required "status" frontmatter key
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Hank",
|
|
directory="people",
|
|
note_type="person_fm2",
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Hank Pym
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"identifier": note_entity.permalink},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
result = data["results"][0]
|
|
assert result["passed"] is True
|
|
assert any("status" in w for w in result["warnings"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_frontmatter_enum_mismatch_warns(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
):
|
|
"""Frontmatter enum mismatch produces a warning."""
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Person Schema FM3",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "person_fm3",
|
|
"schema": {"name": "string"},
|
|
"settings": {
|
|
"validation": "warn",
|
|
"frontmatter": {
|
|
"status?(enum)": ["draft", "published"],
|
|
},
|
|
},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema with enum frontmatter
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Note with invalid enum value in frontmatter
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Iris",
|
|
directory="people",
|
|
note_type="person_fm3",
|
|
entity_metadata={
|
|
"status": "archived", # not in [draft, published]
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Iris West
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"identifier": note_entity.permalink},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
result = data["results"][0]
|
|
assert result["passed"] is True
|
|
# Should have a warning about the enum mismatch
|
|
assert any("status" in w for w in result["warnings"])
|
|
# The field result should show enum_mismatch
|
|
status_fr = next(fr for fr in result["field_results"] if fr["field_name"] == "status")
|
|
assert status_fr["status"] == "enum_mismatch"
|
|
|
|
|
|
# --- 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["note_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",
|
|
note_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["note_type"] == "person"
|
|
assert isinstance(data["new_fields"], list)
|
|
assert isinstance(data["dropped_fields"], list)
|
|
assert isinstance(data["cardinality_changes"], list)
|
|
|
|
|
|
# --- File-based schema frontmatter tests ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_reads_schema_from_file_not_database(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
file_service: FileService,
|
|
):
|
|
"""Validate uses schema frontmatter from the file, not stale database metadata.
|
|
|
|
Simulates the core bug from #634: user edits a schema file to change
|
|
validation mode from 'warn' to 'strict', but the file watcher hasn't
|
|
synced. The database still has 'warn', but validation should use 'strict'
|
|
from the file.
|
|
"""
|
|
# Create schema entity — DB gets validation=warn
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Editable Schema",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "editable_type",
|
|
"schema": {"name": "string", "role": "string"},
|
|
"settings": {"validation": "warn"},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema that will be edited on disk
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Overwrite the file on disk with validation=strict
|
|
file_path = Path(file_service.base_path) / schema_entity.file_path
|
|
file_path.write_text(
|
|
dedent("""\
|
|
---
|
|
title: Editable Schema
|
|
permalink: schemas/editable-schema
|
|
type: schema
|
|
entity: editable_type
|
|
schema:
|
|
name: string
|
|
role: string
|
|
settings:
|
|
validation: strict
|
|
---
|
|
|
|
# Editable Schema
|
|
|
|
## Observations
|
|
- [note] Schema that will be edited on disk
|
|
""")
|
|
)
|
|
|
|
# Create a note missing "role" — strict mode should produce errors, not warnings
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="TestNote",
|
|
directory="notes",
|
|
note_type="editable_type",
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Test Person
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"identifier": note_entity.permalink},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_notes"] == 1
|
|
result = data["results"][0]
|
|
# strict mode: missing required field is an error, not a warning
|
|
assert result["passed"] is False
|
|
assert any("role" in e for e in result["errors"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_falls_back_to_db_on_incomplete_frontmatter(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
file_service: FileService,
|
|
):
|
|
"""Validate falls back to database metadata when file has incomplete frontmatter.
|
|
|
|
Simulates a mid-edit state where the user has removed the 'schema' key
|
|
from the file. The validator should use the last-known-good metadata
|
|
from the database rather than failing with a 500.
|
|
"""
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Incomplete Schema",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "incomplete_type",
|
|
"schema": {"name": "string"},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema that will have incomplete frontmatter
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Overwrite file with frontmatter missing the 'schema' key
|
|
file_path = Path(file_service.base_path) / schema_entity.file_path
|
|
file_path.write_text(
|
|
dedent("""\
|
|
---
|
|
title: Incomplete Schema
|
|
permalink: schemas/incomplete-schema
|
|
type: schema
|
|
entity: incomplete_type
|
|
---
|
|
|
|
# Incomplete Schema
|
|
|
|
## Observations
|
|
- [note] Mid-edit state
|
|
""")
|
|
)
|
|
|
|
# Create a note to validate against this schema
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="FallbackNote",
|
|
directory="notes",
|
|
note_type="incomplete_type",
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Test Fallback
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"note_type": "incomplete_type"},
|
|
)
|
|
|
|
# Should not 500 — falls back to DB metadata and validates successfully
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_notes"] == 1
|
|
result = data["results"][0]
|
|
assert result["passed"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_falls_back_to_db_on_missing_file(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
file_service: FileService,
|
|
):
|
|
"""Validate falls back to database metadata when schema file is missing.
|
|
|
|
Simulates a race condition where the file has been deleted but the
|
|
database still has the entity. The validator should use DB metadata
|
|
rather than failing entirely.
|
|
"""
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Missing File Schema",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "missing_file_type",
|
|
"schema": {"name": "string"},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema whose file will be deleted
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Delete the schema file from disk
|
|
file_path = Path(file_service.base_path) / schema_entity.file_path
|
|
file_path.unlink()
|
|
|
|
# Create a note to validate
|
|
note_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="OrphanNote",
|
|
directory="notes",
|
|
note_type="missing_file_type",
|
|
content=dedent("""\
|
|
## Observations
|
|
- [name] Test Orphan
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(note_entity)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/schema/validate",
|
|
params={"note_type": "missing_file_type"},
|
|
)
|
|
|
|
# Should not 500 — falls back to DB metadata and validates
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_notes"] == 1
|
|
result = data["results"][0]
|
|
assert result["passed"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diff_falls_back_to_db_on_missing_file(
|
|
client: AsyncClient,
|
|
test_project: Project,
|
|
v2_project_url: str,
|
|
entity_service,
|
|
search_service,
|
|
file_service: FileService,
|
|
):
|
|
"""Diff endpoint falls back to DB metadata when schema file is missing."""
|
|
schema_entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Diff Missing Schema",
|
|
directory="schemas",
|
|
note_type="schema",
|
|
entity_metadata={
|
|
"entity": "diff_missing_type",
|
|
"schema": {"name": "string", "role": "string"},
|
|
},
|
|
content=dedent("""\
|
|
## Observations
|
|
- [note] Schema for diff fallback test
|
|
"""),
|
|
)
|
|
)
|
|
await search_service.index_entity(schema_entity)
|
|
|
|
# Delete the schema file
|
|
file_path = Path(file_service.base_path) / schema_entity.file_path
|
|
file_path.unlink()
|
|
|
|
# Create person entities
|
|
await create_person_entities(entity_service, search_service)
|
|
|
|
response = await client.get(
|
|
f"{v2_project_url}/schema/diff/diff_missing_type",
|
|
)
|
|
|
|
# Should not 500 — falls back to DB metadata for schema resolution
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["note_type"] == "diff_missing_type"
|