Files
basicmachines-co-basic-memory/tests/mcp/test_tool_schema.py
Drew Cain 3415fd1014 fix(core): parse picoschema modifier descriptions (#796)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-05-06 12:21:35 -05:00

627 lines
17 KiB
Python

"""Tests for schema MCP tools (validate, infer, diff).
Covers the tool function logic including success paths and error/exception paths.
The success-path tests use the full ASGI stack via the app fixture.
Error-path tests monkeypatch SchemaClient methods to trigger the except branch.
"""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff
from basic_memory.mcp.tools.write_note import write_note
# --- Helpers ---
def _write_schema_file(project_path: Path, filename: str, content: str):
"""Write a markdown file directly to disk (bypasses write_note frontmatter generation)."""
path = project_path / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
PERSON_SCHEMA = """\
---
title: Person
type: schema
entity: person
version: 1
schema:
name: string, full name
role?: string, job title
settings:
validation: warn
---
# Person
Schema for person entities.
"""
PERSON_NOTE = """\
---
title: {name}
type: person
permalink: people/{permalink}
---
# {name}
## Observations
- [name] {name}
- [role] Engineer
"""
# --- Success-path tests (full ASGI stack) ---
@pytest.mark.asyncio
async def test_schema_validate_by_type(app, test_project, sync_service):
"""Validate all notes of a given entity type."""
project_path = Path(test_project.path)
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
_write_schema_file(
project_path,
"people/Alice.md",
PERSON_NOTE.format(name="Alice", permalink="alice"),
)
# Sync so the database picks up the files
await sync_service.sync(project_path)
result = await schema_validate(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Validation: person" in result
assert "Notes: 1" in result
assert "**Alice**" in result
assert "valid" in result
@pytest.mark.asyncio
async def test_schema_validate_json_output(app, test_project, sync_service):
"""JSON output returns a dict with full structured data."""
project_path = Path(test_project.path)
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
_write_schema_file(
project_path,
"people/Alice.md",
PERSON_NOTE.format(name="Alice", permalink="alice"),
)
await sync_service.sync(project_path)
result = await schema_validate(
note_type="person",
project=test_project.name,
output_format="json",
)
assert isinstance(result, dict)
assert result["total_notes"] == 1
assert result["valid_count"] == 1
assert len(result["results"]) == 1
assert result["results"][0]["note_identifier"] == "Alice"
assert result["results"][0]["passed"] is True
@pytest.mark.asyncio
async def test_schema_validate_picoschema_modifier_descriptions(app, test_project, sync_service):
"""Modifier descriptions should not become literal field names."""
project_path = Path(test_project.path)
_write_schema_file(
project_path,
"schemas/PicoTest.md",
"""\
---
title: PicoTest
type: schema
entity: pico_test
schema:
name: string
status(enum, current state): [active, inactive]
tags(array, list of tags): string
settings:
validation: warn
---
# PicoTest
""",
)
_write_schema_file(
project_path,
"pico/PicoTest1.md",
"""\
---
title: PicoTest1
type: pico_test
permalink: pico/pico-test-1
---
# PicoTest1
## Observations
- [name] PicoTest1
- [status] active
- [tags] foo
- [tags] bar
""",
)
await sync_service.sync(project_path)
result = await schema_validate(
note_type="pico_test",
project=test_project.name,
output_format="json",
)
assert isinstance(result, dict)
note_result = result["results"][0]
field_statuses = {fr["field_name"]: fr["status"] for fr in note_result["field_results"]}
assert result["valid_count"] == 1
assert note_result["warnings"] == []
assert note_result["unmatched_observations"] == {}
assert field_statuses == {
"name": "present",
"status": "present",
"tags": "present",
}
@pytest.mark.asyncio
async def test_schema_validate_by_identifier(app, test_project, sync_service):
"""Validate a specific note by identifier."""
project_path = Path(test_project.path)
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
_write_schema_file(
project_path,
"people/Alice.md",
PERSON_NOTE.format(name="Alice", permalink="alice"),
)
await sync_service.sync(project_path)
result = await schema_validate(
identifier="people/alice",
project=test_project.name,
)
assert isinstance(result, str)
assert "**Alice**" in result
assert "valid" in result
@pytest.mark.asyncio
async def test_schema_validate_by_title(app, test_project, sync_service):
"""Validate a specific note by title (not permalink).
Regression test for issue #33: schema_validate(identifier="Note Title")
returned 0 notes because the router only searched by permalink.
"""
project_path = Path(test_project.path)
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
_write_schema_file(
project_path,
"people/Alice.md",
PERSON_NOTE.format(name="Alice", permalink="alice"),
)
await sync_service.sync(project_path)
# Use the title "Alice" instead of the permalink "people/alice"
result = await schema_validate(
identifier="Alice",
project=test_project.name,
)
assert isinstance(result, str)
assert "**Alice**" in result
assert "Notes: 1" in result
assert "valid" in result
@pytest.mark.asyncio
async def test_schema_validate_identifier_no_schema_returns_guidance(
app, test_project, sync_service
):
"""When a note exists but no schema is defined, return guidance.
Regression test for issue #33: when validating a single note by identifier
and no schema exists, the tool should return guidance instead of an empty report.
"""
project_path = Path(test_project.path)
# Create a person note but no schema note
_write_schema_file(
project_path,
"people/Alice.md",
PERSON_NOTE.format(name="Alice", permalink="alice"),
)
await sync_service.sync(project_path)
result = await schema_validate(
identifier="Alice",
project=test_project.name,
)
# Should return guidance string about missing schema
assert isinstance(result, str)
assert "No Schema Found" in result
assert "person" in result
@pytest.mark.asyncio
async def test_schema_infer(app, test_project, sync_service):
"""Infer a schema from existing notes."""
project_path = Path(test_project.path)
for name in ["Alice", "Bob", "Charlie"]:
_write_schema_file(
project_path,
f"people/{name}.md",
PERSON_NOTE.format(name=name, permalink=name.lower()),
)
await sync_service.sync(project_path)
result = await schema_infer(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Inference: person" in result
assert "Notes analyzed: 3" in result
assert "Field Frequencies" in result
assert "**name**" in result
assert "**role**" in result
@pytest.mark.asyncio
async def test_schema_diff(app, test_project, sync_service):
"""Detect drift between schema and actual usage."""
project_path = Path(test_project.path)
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
# Create a person with an extra "hobby" field not in the schema
_write_schema_file(
project_path,
"people/Dave.md",
"""\
---
title: Dave
type: person
permalink: people/dave
---
# Dave
## Observations
- [name] Dave
- [role] Manager
- [hobby] Chess
""",
)
await sync_service.sync(project_path)
result = await schema_diff(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Drift: person" in result
# Dave has a "hobby" field not in the schema, so drift should be detected
assert "**hobby**" in result
# --- write_note metadata → schema workflow ---
@pytest.mark.asyncio
async def test_write_note_metadata_creates_schema_note(app, test_project, sync_service):
"""Create a schema note via write_note(metadata=...), then validate against it.
Proves the end-to-end workflow: write_note → sync → schema_validate.
"""
project_path = Path(test_project.path)
# 1. Create person notes via direct file write (content under test is the schema)
for name in ["Alice", "Bob"]:
_write_schema_file(
project_path,
f"people/{name}.md",
PERSON_NOTE.format(name=name, permalink=name.lower()),
)
# 2. Create the schema note via write_note with metadata
await write_note(
title="Person",
directory="schemas",
note_type="schema",
content="# Person\n\nSchema for person entities.",
metadata={
"entity": "person",
"version": 1,
"schema": {"name": "string", "role?": "string"},
"settings": {"validation": "warn"},
},
project=test_project.name,
)
# 3. Sync picks up person notes written directly to disk
await sync_service.sync(project_path)
# 4. Validate — schema_validate should find the schema and validate person notes
result = await schema_validate(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Validation: person" in result
assert "valid" in result
@pytest.mark.asyncio
async def test_schema_title_mismatch_finds_by_metadata(app, test_project, sync_service):
"""Schema lookup works even when the schema title doesn't match the entity type.
Regression test: the old text-search approach failed when the schema note's title
(e.g. "Employee Schema") didn't textually match the entity type ("employee").
The metadata-based lookup matches on entity_metadata['entity'] instead.
"""
project_path = Path(test_project.path)
# Schema title "Employee Schema" != entity type "employee"
_write_schema_file(
project_path,
"schemas/EmployeeSchema.md",
"""\
---
title: Employee Schema
type: schema
entity: employee
version: 1
schema:
name: string, full name
department?: string, department name
settings:
validation: warn
---
# Employee Schema
Schema for employee entities.
""",
)
# Create employee notes
for name, dept in [("Alice", "Engineering"), ("Bob", "Marketing")]:
_write_schema_file(
project_path,
f"employees/{name}.md",
f"""\
---
title: {name}
type: employee
permalink: employees/{name.lower()}
---
# {name}
## Observations
- [name] {name}
- [department] {dept}
""",
)
await sync_service.sync(project_path)
# Validate — must find "Employee Schema" via entity_metadata['entity'] == "employee"
result = await schema_validate(
note_type="employee",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Validation: employee" in result
assert "Notes: 2" in result
assert "Valid: 2" in result
# Both notes have name + department, schema requires name and optionally department
assert "**Alice**" in result
assert "**Bob**" in result
# --- Empty schema guard ---
@pytest.mark.asyncio
async def test_schema_infer_empty_schema_returns_guidance(app, test_project, sync_service):
"""When notes exist but no fields meet the threshold, return guidance instead of data."""
project_path = Path(test_project.path)
# Create notes with completely different observation categories so no field
# reaches the 25% threshold across all notes
for i, (name, category) in enumerate(
[
("alpha", "color"),
("bravo", "shape"),
("charlie", "size"),
("delta", "weight"),
("echo", "temp"),
]
):
_write_schema_file(
project_path,
f"things/{name}.md",
f"""\
---
title: {name}
type: widget
permalink: things/{name}
---
# {name}
## Observations
- [{category}] some value
""",
)
await sync_service.sync(project_path)
result = await schema_infer(
note_type="widget",
project=test_project.name,
)
# Should return guidance string, not an InferenceReport
assert isinstance(result, str)
assert "No Schema Pattern Found" in result
assert "widget" in result
assert "Suggestions" in result
# --- No schema found guards ---
@pytest.mark.asyncio
async def test_schema_validate_no_notes_returns_guidance(app, test_project, sync_service):
"""When no notes of the requested type exist, return guidance on creating notes."""
result = await schema_validate(
note_type="employee",
project=test_project.name,
)
# Should return guidance about creating notes, not about missing schema
assert isinstance(result, str)
assert "No Notes Found" in result
assert "employee" in result
assert "write_note" in result
assert "search_notes" in result
@pytest.mark.asyncio
async def test_schema_validate_no_schema_returns_guidance(app, test_project, sync_service):
"""When notes exist but no schema is defined, return guidance on creating one."""
project_path = Path(test_project.path)
# Create person notes but no schema note
for name in ["Alice", "Bob"]:
_write_schema_file(
project_path,
f"people/{name}.md",
PERSON_NOTE.format(name=name, permalink=name.lower()),
)
await sync_service.sync(project_path)
result = await schema_validate(
note_type="person",
project=test_project.name,
)
# Should return guidance string, not a ValidationReport
assert isinstance(result, str)
assert "No Schema Found" in result
assert "person" in result
assert "schema_infer" in result
assert "How to Create a Schema" in result
@pytest.mark.asyncio
async def test_schema_diff_no_schema_returns_guidance(app, test_project, sync_service):
"""When no schema exists for the type, return guidance on creating one."""
project_path = Path(test_project.path)
# Create person notes but no schema note
_write_schema_file(
project_path,
"people/Alice.md",
PERSON_NOTE.format(name="Alice", permalink="alice"),
)
await sync_service.sync(project_path)
result = await schema_diff(
note_type="person",
project=test_project.name,
)
# Should return guidance string, not a DriftReport
assert isinstance(result, str)
assert "No Schema Found" in result
assert "person" in result
assert "schema_infer" in result
assert "How to Create a Schema" in result
# --- Error-path tests (monkeypatched SchemaClient) ---
@pytest.mark.asyncio
async def test_schema_validate_error_returns_guidance(app, test_project):
"""When SchemaClient.validate raises, the tool returns a troubleshooting string."""
mock_validate = AsyncMock(side_effect=RuntimeError("connection lost"))
with patch("basic_memory.mcp.clients.schema.SchemaClient.validate", mock_validate):
result = await schema_validate(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Validation Failed" in result
assert "Troubleshooting" in result
@pytest.mark.asyncio
async def test_schema_infer_error_returns_guidance(app, test_project):
"""When SchemaClient.infer raises, the tool returns a troubleshooting string."""
mock_infer = AsyncMock(side_effect=RuntimeError("db unavailable"))
with patch("basic_memory.mcp.clients.schema.SchemaClient.infer", mock_infer):
result = await schema_infer(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Inference Failed" in result
assert "Troubleshooting" in result
@pytest.mark.asyncio
async def test_schema_diff_error_returns_guidance(app, test_project):
"""When SchemaClient.diff raises, the tool returns a troubleshooting string."""
mock_diff = AsyncMock(side_effect=RuntimeError("network error"))
with patch("basic_memory.mcp.clients.schema.SchemaClient.diff", mock_diff):
result = await schema_diff(
note_type="person",
project=test_project.name,
)
assert isinstance(result, str)
assert "Schema Diff Failed" in result
assert "Troubleshooting" in result