mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
7be001ca68
Signed-off-by: Drew Cain <groksrc@gmail.com> Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
655 lines
19 KiB
Python
655 lines
19 KiB
Python
"""Tests for note tools that exercise the full stack with SQLite."""
|
|
|
|
from textwrap import dedent
|
|
import pytest
|
|
|
|
from basic_memory.mcp.tools import write_note, read_note, delete_note
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note(app):
|
|
"""Test creating a new note.
|
|
|
|
Should:
|
|
- Create entity with correct type and content
|
|
- Save markdown content
|
|
- Handle tags correctly
|
|
- Return valid permalink
|
|
"""
|
|
result = await write_note.fn(
|
|
title="Test Note",
|
|
folder="test",
|
|
content="# Test\nThis is a test note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert "permalink: test/test-note" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
|
|
# Try reading it back via permalink
|
|
content = await read_note.fn("test/test-note")
|
|
assert (
|
|
dedent("""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
permalink: test/test-note
|
|
tags:
|
|
- test
|
|
- documentation
|
|
---
|
|
|
|
# Test
|
|
This is a test note
|
|
""").strip()
|
|
in content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_no_tags(app):
|
|
"""Test creating a note without tags."""
|
|
result = await write_note.fn(title="Simple Note", folder="test", content="Just some text")
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: test/Simple Note.md" in result
|
|
assert "permalink: test/simple-note" in result
|
|
# Should be able to read it back
|
|
content = await read_note.fn("test/simple-note")
|
|
assert (
|
|
dedent("""
|
|
---
|
|
title: Simple Note
|
|
type: note
|
|
permalink: test/simple-note
|
|
---
|
|
|
|
Just some text
|
|
""").strip()
|
|
in content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_update_existing(app):
|
|
"""Test creating a new note.
|
|
|
|
Should:
|
|
- Create entity with correct type and content
|
|
- Save markdown content
|
|
- Handle tags correctly
|
|
- Return valid permalink
|
|
"""
|
|
result = await write_note.fn(
|
|
title="Test Note",
|
|
folder="test",
|
|
content="# Test\nThis is a test note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert result # Got a valid permalink
|
|
assert "# Created note" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert "permalink: test/test-note" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
|
|
result = await write_note.fn(
|
|
title="Test Note",
|
|
folder="test",
|
|
content="# Test\nThis is an updated note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
assert "# Updated note" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert "permalink: test/test-note" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
|
|
# Try reading it back
|
|
content = await read_note.fn("test/test-note")
|
|
assert (
|
|
dedent(
|
|
"""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
permalink: test/test-note
|
|
tags:
|
|
- test
|
|
- documentation
|
|
---
|
|
|
|
# Test
|
|
This is an updated note
|
|
"""
|
|
).strip()
|
|
== content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_issue_93_write_note_respects_custom_permalink_new_note(app):
|
|
"""Test that write_note respects custom permalinks in frontmatter for new notes (Issue #93)"""
|
|
|
|
# Create a note with custom permalink in frontmatter
|
|
content_with_custom_permalink = dedent("""
|
|
---
|
|
permalink: custom/my-desired-permalink
|
|
---
|
|
|
|
# My New Note
|
|
|
|
This note has a custom permalink specified in frontmatter.
|
|
|
|
- [note] Testing if custom permalink is respected
|
|
""").strip()
|
|
|
|
result = await write_note.fn(
|
|
title="My New Note",
|
|
folder="notes",
|
|
content=content_with_custom_permalink,
|
|
)
|
|
|
|
# Verify the custom permalink is respected
|
|
assert "# Created note" in result
|
|
assert "file_path: notes/My New Note.md" in result
|
|
assert "permalink: custom/my-desired-permalink" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_issue_93_write_note_respects_custom_permalink_existing_note(app):
|
|
"""Test that write_note respects custom permalinks when updating existing notes (Issue #93)"""
|
|
|
|
# Step 1: Create initial note (auto-generated permalink)
|
|
result1 = await write_note.fn(
|
|
title="Existing Note",
|
|
folder="test",
|
|
content="Initial content without custom permalink",
|
|
)
|
|
|
|
assert "# Created note" in result1
|
|
|
|
# Extract the auto-generated permalink
|
|
initial_permalink = None
|
|
for line in result1.split("\n"):
|
|
if line.startswith("permalink:"):
|
|
initial_permalink = line.split(":", 1)[1].strip()
|
|
break
|
|
|
|
assert initial_permalink is not None
|
|
|
|
# Step 2: Update with content that includes custom permalink in frontmatter
|
|
updated_content = dedent("""
|
|
---
|
|
permalink: custom/new-permalink
|
|
---
|
|
|
|
# Existing Note
|
|
|
|
Updated content with custom permalink in frontmatter.
|
|
|
|
- [note] Custom permalink should be respected on update
|
|
""").strip()
|
|
|
|
result2 = await write_note.fn(
|
|
title="Existing Note",
|
|
folder="test",
|
|
content=updated_content,
|
|
)
|
|
|
|
# Verify the custom permalink is respected
|
|
assert "# Updated note" in result2
|
|
assert "permalink: custom/new-permalink" in result2
|
|
assert f"permalink: {initial_permalink}" not in result2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_note_existing(app):
|
|
"""Test deleting a new note.
|
|
|
|
Should:
|
|
- Create entity with correct type and content
|
|
- Return valid permalink
|
|
- Delete the note
|
|
"""
|
|
result = await write_note.fn(
|
|
title="Test Note",
|
|
folder="test",
|
|
content="# Test\nThis is a test note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert result
|
|
|
|
deleted = await delete_note.fn("test/test-note")
|
|
assert deleted is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_note_doesnt_exist(app):
|
|
"""Test deleting a new note.
|
|
|
|
Should:
|
|
- Delete the note
|
|
- verify returns false
|
|
"""
|
|
deleted = await delete_note.fn("doesnt-exist")
|
|
assert deleted is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_tag_array_from_bug_report(app):
|
|
"""Test creating a note with a tag array as reported in issue #38.
|
|
|
|
This reproduces the exact payload from the bug report where Cursor
|
|
was passing an array of tags and getting a type mismatch error.
|
|
"""
|
|
# This is the exact payload from the bug report
|
|
bug_payload = {
|
|
"title": "Title",
|
|
"folder": "folder",
|
|
"content": "CONTENT",
|
|
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"],
|
|
}
|
|
|
|
# Try to call the function with this data directly
|
|
result = await write_note.fn(**bug_payload)
|
|
|
|
assert result
|
|
assert "permalink: folder/title" in result
|
|
assert "Tags" in result
|
|
assert "hipporag" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_verbose(app):
|
|
"""Test creating a new note.
|
|
|
|
Should:
|
|
- Create entity with correct type and content
|
|
- Save markdown content
|
|
- Handle tags correctly
|
|
- Return valid permalink
|
|
"""
|
|
result = await write_note.fn(
|
|
title="Test Note",
|
|
folder="test",
|
|
content="""
|
|
# Test\nThis is a test note
|
|
|
|
- [note] First observation
|
|
- relates to [[Knowledge]]
|
|
|
|
""",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert "# Created note" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert "permalink: test/test-note" in result
|
|
assert "## Observations" in result
|
|
assert "- note: 1" in result
|
|
assert "## Relations" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_preserves_custom_metadata(app, project_config):
|
|
"""Test that updating a note preserves custom metadata fields.
|
|
|
|
Reproduces issue #36 where custom frontmatter fields like Status
|
|
were being lost when updating notes with the write_note tool.
|
|
|
|
Should:
|
|
- Create a note with custom frontmatter
|
|
- Update the note with new content
|
|
- Verify custom frontmatter is preserved
|
|
"""
|
|
# First, create a note with custom metadata using write_note
|
|
await write_note.fn(
|
|
title="Custom Metadata Note",
|
|
folder="test",
|
|
content="# Initial content",
|
|
tags=["test"],
|
|
)
|
|
|
|
# Read the note to get its permalink
|
|
content = await read_note.fn("test/custom-metadata-note")
|
|
|
|
# Now directly update the file with custom frontmatter
|
|
# We need to use a direct file update to add custom frontmatter
|
|
import frontmatter
|
|
|
|
file_path = project_config.home / "test" / "Custom Metadata Note.md"
|
|
post = frontmatter.load(file_path)
|
|
|
|
# Add custom frontmatter
|
|
post["Status"] = "In Progress"
|
|
post["Priority"] = "High"
|
|
post["Version"] = "1.0"
|
|
|
|
# Write the file back
|
|
with open(file_path, "w") as f:
|
|
f.write(frontmatter.dumps(post))
|
|
|
|
# Now update the note using write_note
|
|
result = await write_note.fn(
|
|
title="Custom Metadata Note",
|
|
folder="test",
|
|
content="# Updated content",
|
|
tags=["test", "updated"],
|
|
)
|
|
|
|
# Verify the update was successful
|
|
assert ("Updated note\nfile_path: test/Custom Metadata Note.md") in result
|
|
|
|
# Read the note back and check if custom frontmatter is preserved
|
|
content = await read_note.fn("test/custom-metadata-note")
|
|
|
|
# Custom frontmatter should be preserved
|
|
assert "Status: In Progress" in content
|
|
assert "Priority: High" in content
|
|
# Version might be quoted as '1.0' due to YAML serialization
|
|
assert "Version:" in content # Just check that the field exists
|
|
assert "1.0" in content # And that the value exists somewhere
|
|
|
|
# And new content should be there
|
|
assert "# Updated content" in content
|
|
|
|
# And tags should be updated (without # prefix)
|
|
assert "- test" in content
|
|
assert "- updated" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_preserves_content_frontmatter(app):
|
|
"""Test creating a new note."""
|
|
await write_note.fn(
|
|
title="Test Note",
|
|
folder="test",
|
|
content=dedent(
|
|
"""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
version: 1.0
|
|
author: name
|
|
---
|
|
# Test
|
|
|
|
This is a test note
|
|
"""
|
|
),
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
# Try reading it back via permalink
|
|
content = await read_note.fn("test/test-note")
|
|
assert (
|
|
dedent(
|
|
"""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
permalink: test/test-note
|
|
version: 1.0
|
|
author: name
|
|
tags:
|
|
- test
|
|
- documentation
|
|
---
|
|
|
|
# Test
|
|
|
|
This is a test note
|
|
"""
|
|
).strip()
|
|
in content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_permalink_collision_fix_issue_139(app):
|
|
"""Test fix for GitHub Issue #139: UNIQUE constraint failed: entity.permalink.
|
|
|
|
This reproduces the exact scenario described in the issue:
|
|
1. Create a note with title "Note 1"
|
|
2. Create another note with title "Note 2"
|
|
3. Try to create/replace first note again with same title "Note 1"
|
|
|
|
Before the fix, step 3 would fail with UNIQUE constraint error.
|
|
After the fix, it should either update the existing note or create with unique permalink.
|
|
"""
|
|
# Step 1: Create first note
|
|
result1 = await write_note.fn(
|
|
title="Note 1", folder="test", content="Original content for note 1"
|
|
)
|
|
assert "# Created note" in result1
|
|
assert "permalink: test/note-1" in result1
|
|
|
|
# Step 2: Create second note with different title
|
|
result2 = await write_note.fn(title="Note 2", folder="test", content="Content for note 2")
|
|
assert "# Created note" in result2
|
|
assert "permalink: test/note-2" in result2
|
|
|
|
# Step 3: Try to create/replace first note again
|
|
# This scenario would trigger the UNIQUE constraint failure before the fix
|
|
result3 = await write_note.fn(
|
|
title="Note 1", # Same title as first note
|
|
folder="test", # Same folder as first note
|
|
content="Replacement content for note 1", # Different content
|
|
)
|
|
|
|
# This should not raise a UNIQUE constraint failure error
|
|
# It should succeed and either:
|
|
# 1. Update the existing note (preferred behavior)
|
|
# 2. Create a new note with unique permalink (fallback behavior)
|
|
|
|
assert result3 is not None
|
|
assert "Updated note" in result3 or "Created note" in result3
|
|
|
|
# The result should contain either the original permalink or a unique one
|
|
assert "permalink: test/note-1" in result3 or "permalink: test/note-1-1" in result3
|
|
|
|
# Verify we can read back the content
|
|
if "permalink: test/note-1" in result3:
|
|
# Updated existing note case
|
|
content = await read_note.fn("test/note-1")
|
|
assert "Replacement content for note 1" in content
|
|
else:
|
|
# Created new note with unique permalink case
|
|
content = await read_note.fn("test/note-1-1")
|
|
assert "Replacement content for note 1" in content
|
|
# Original note should still exist
|
|
original_content = await read_note.fn("test/note-1")
|
|
assert "Original content for note 1" in original_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_custom_entity_type(app):
|
|
"""Test creating a note with custom entity_type parameter.
|
|
|
|
This test verifies the fix for Issue #144 where entity_type parameter
|
|
was hardcoded to "note" instead of allowing custom types.
|
|
"""
|
|
result = await write_note.fn(
|
|
title="Test Guide",
|
|
folder="guides",
|
|
content="# Guide Content\nThis is a guide",
|
|
tags=["guide", "documentation"],
|
|
entity_type="guide",
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: guides/Test Guide.md" in result
|
|
assert "permalink: guides/test-guide" in result
|
|
assert "## Tags" in result
|
|
assert "- guide, documentation" in result
|
|
|
|
# Verify the entity type is correctly set in the frontmatter
|
|
content = await read_note.fn("guides/test-guide")
|
|
assert (
|
|
dedent("""
|
|
---
|
|
title: Test Guide
|
|
type: guide
|
|
permalink: guides/test-guide
|
|
tags:
|
|
- guide
|
|
- documentation
|
|
---
|
|
|
|
# Guide Content
|
|
This is a guide
|
|
""").strip()
|
|
in content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_report_entity_type(app):
|
|
"""Test creating a note with entity_type="report"."""
|
|
result = await write_note.fn(
|
|
title="Monthly Report",
|
|
folder="reports",
|
|
content="# Monthly Report\nThis is a monthly report",
|
|
tags=["report", "monthly"],
|
|
entity_type="report",
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: reports/Monthly Report.md" in result
|
|
assert "permalink: reports/monthly-report" in result
|
|
|
|
# Verify the entity type is correctly set in the frontmatter
|
|
content = await read_note.fn("reports/monthly-report")
|
|
assert "type: report" in content
|
|
assert "# Monthly Report" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_config_entity_type(app):
|
|
"""Test creating a note with entity_type="config"."""
|
|
result = await write_note.fn(
|
|
title="System Config",
|
|
folder="config",
|
|
content="# System Configuration\nThis is a config file",
|
|
entity_type="config",
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: config/System Config.md" in result
|
|
assert "permalink: config/system-config" in result
|
|
|
|
# Verify the entity type is correctly set in the frontmatter
|
|
content = await read_note.fn("config/system-config")
|
|
assert "type: config" in content
|
|
assert "# System Configuration" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_entity_type_default_behavior(app):
|
|
"""Test that the entity_type parameter defaults to "note" when not specified.
|
|
|
|
This ensures backward compatibility - existing code that doesn't specify
|
|
entity_type should continue to work as before.
|
|
"""
|
|
result = await write_note.fn(
|
|
title="Default Type Test",
|
|
folder="test",
|
|
content="# Default Type Test\nThis should be type 'note'",
|
|
tags=["test"],
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: test/Default Type Test.md" in result
|
|
assert "permalink: test/default-type-test" in result
|
|
|
|
# Verify the entity type defaults to "note"
|
|
content = await read_note.fn("test/default-type-test")
|
|
assert "type: note" in content
|
|
assert "# Default Type Test" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_update_existing_with_different_entity_type(app):
|
|
"""Test updating an existing note with a different entity_type."""
|
|
# Create initial note as "note" type
|
|
result1 = await write_note.fn(
|
|
title="Changeable Type",
|
|
folder="test",
|
|
content="# Initial Content\nThis starts as a note",
|
|
tags=["test"],
|
|
entity_type="note",
|
|
)
|
|
|
|
assert result1
|
|
assert "# Created note" in result1
|
|
|
|
# Update the same note with a different entity_type
|
|
result2 = await write_note.fn(
|
|
title="Changeable Type",
|
|
folder="test",
|
|
content="# Updated Content\nThis is now a guide",
|
|
tags=["guide"],
|
|
entity_type="guide",
|
|
)
|
|
|
|
assert result2
|
|
assert "# Updated note" in result2
|
|
|
|
# Verify the entity type was updated
|
|
content = await read_note.fn("test/changeable-type")
|
|
assert "type: guide" in content
|
|
assert "# Updated Content" in content
|
|
assert "- guide" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_respects_frontmatter_entity_type(app):
|
|
"""Test that entity_type in frontmatter is respected when parameter is not provided.
|
|
|
|
This verifies that when write_note is called without entity_type parameter,
|
|
but the content includes frontmatter with a 'type' field, that type is respected
|
|
instead of defaulting to 'note'.
|
|
"""
|
|
note = dedent("""
|
|
---
|
|
title: Test Guide
|
|
type: guide
|
|
permalink: guides/test-guide
|
|
tags:
|
|
- guide
|
|
- documentation
|
|
---
|
|
|
|
# Guide Content
|
|
This is a guide
|
|
""").strip()
|
|
|
|
# Call write_note without entity_type parameter - it should respect frontmatter type
|
|
result = await write_note.fn(title="Test Guide", folder="guides", content=note)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert "file_path: guides/Test Guide.md" in result
|
|
assert "permalink: guides/test-guide" in result
|
|
|
|
# Verify the entity type from frontmatter is respected (should be "guide", not "note")
|
|
content = await read_note.fn("guides/test-guide")
|
|
assert "type: guide" in content
|
|
assert "# Guide Content" in content
|
|
assert "- guide" in content
|
|
assert "- documentation" in content
|