mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
4fd9cae293
`write_note(overwrite=True)` caught the 409 from create_entity and then called `knowledge_client.resolve_entity(entity.permalink)` with the default `strict=False`. In workspace-prefixed palaces the client-built permalink omits the workspace slug, so exact permalink lookup misses and the fuzzy fallback could pick an orphan row that shares tokens with the canonical permalink. The update then wrote to the orphan, leaving the canonical row stale. On the next overwrite the permalink uniqueness check in `_resolve_schema_permalink` found duplicate rows and minted `-1`/`-2` suffixes on the canonical entity, accumulating orphans on every re-synthesis run. The 409 came from a `file_service.exists(file_path)` check in `prepare_create_entity_content`, so the file_path is the authoritative key for the canonical row — no fuzzy matching needed. Resolve by file_path with `strict=True`, POSIX-normalized so Windows clients send the form the server stores. Adds a regression test that spies on `resolve_entity` and asserts the identifier and `strict` flag, plus checks that no `-1`/`-2` suffix is minted under the canonical permalink. Reported in basic-memory-bug-report Issue 1. Signed-off-by: Dennis Hempel <uhg.dennnis@gmail.com>
1387 lines
49 KiB
Python
1387 lines
49 KiB
Python
"""Tests for note tools that exercise the full stack with SQLite."""
|
|
|
|
from textwrap import dedent
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from basic_memory import config as config_module
|
|
from basic_memory.mcp.tools import write_note, read_note, delete_note
|
|
from basic_memory.repository.relation_repository import RelationRepository
|
|
from basic_memory.utils import normalize_newlines
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note(app, test_project):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory="test",
|
|
content="# Test\nThis is a test note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert f"permalink: {test_project.name}/test/test-note" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Try reading it back via permalink
|
|
content = await read_note("test/test-note", project=test_project.name)
|
|
expected = normalize_newlines(
|
|
dedent("""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
permalink: {permalink}
|
|
tags:
|
|
- test
|
|
- documentation
|
|
---
|
|
|
|
# Test
|
|
This is a test note
|
|
""")
|
|
.format(permalink=f"{test_project.name}/test/test-note")
|
|
.strip()
|
|
)
|
|
assert expected in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_no_tags(app, test_project):
|
|
"""Test creating a note without tags."""
|
|
result = await write_note(
|
|
project=test_project.name, title="Simple Note", directory="test", content="Just some text"
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: test/Simple Note.md" in result
|
|
assert f"permalink: {test_project.name}/test/simple-note" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
# Should be able to read it back
|
|
content = await read_note("test/simple-note", project=test_project.name)
|
|
expected = normalize_newlines(
|
|
dedent("""
|
|
---
|
|
title: Simple Note
|
|
type: note
|
|
permalink: {permalink}
|
|
---
|
|
|
|
Just some text
|
|
""")
|
|
.format(permalink=f"{test_project.name}/test/simple-note")
|
|
.strip()
|
|
)
|
|
assert expected in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_update_existing(app, test_project):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory="test",
|
|
content="# Test\nThis is a test note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert result # Got a valid permalink
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert f"permalink: {test_project.name}/test/test-note" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory="test",
|
|
content="# Test\nThis is an updated note",
|
|
tags=["test", "documentation"],
|
|
overwrite=True,
|
|
)
|
|
assert "# Updated note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert f"permalink: {test_project.name}/test/test-note" in result
|
|
assert "## Tags" in result
|
|
assert "- test, documentation" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Try reading it back
|
|
content = await read_note("test/test-note", project=test_project.name)
|
|
assert (
|
|
normalize_newlines(
|
|
dedent(
|
|
"""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
permalink: {permalink}
|
|
tags:
|
|
- test
|
|
- documentation
|
|
---
|
|
|
|
# Test
|
|
This is an updated note
|
|
"""
|
|
)
|
|
.format(permalink=f"{test_project.name}/test/test-note")
|
|
.strip()
|
|
)
|
|
== content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_issue_93_write_note_respects_custom_permalink_new_note(app, test_project):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="My New Note",
|
|
directory="notes",
|
|
content=content_with_custom_permalink,
|
|
)
|
|
|
|
# Verify the custom permalink is respected
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: notes/My New Note.md" in result
|
|
assert "permalink: custom/my-desired-permalink" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_issue_93_write_note_respects_custom_permalink_existing_note(app, test_project):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="Existing Note",
|
|
directory="test",
|
|
content="Initial content without custom permalink",
|
|
)
|
|
|
|
assert "# Created note" in result1
|
|
assert f"project: {test_project.name}" in result1
|
|
assert isinstance(result1, str)
|
|
|
|
# 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(
|
|
project=test_project.name,
|
|
title="Existing Note",
|
|
directory="test",
|
|
content=updated_content,
|
|
overwrite=True,
|
|
)
|
|
|
|
# Verify the custom permalink is respected
|
|
assert "# Updated note" in result2
|
|
assert f"project: {test_project.name}" in result2
|
|
assert "permalink: custom/new-permalink" in result2
|
|
assert f"permalink: {initial_permalink}" not in result2
|
|
assert f"[Session: Using project '{test_project.name}']" in result2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_note_existing(app, test_project):
|
|
"""Test deleting a new note.
|
|
|
|
Should:
|
|
- Create entity with correct type and content
|
|
- Return valid permalink
|
|
- Delete the note
|
|
"""
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory="test",
|
|
content="# Test\nThis is a test note",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert result
|
|
assert f"project: {test_project.name}" in result
|
|
|
|
deleted = await delete_note("test/test-note", project=test_project.name)
|
|
assert deleted is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_note_doesnt_exist(app, test_project):
|
|
"""Test deleting a new note.
|
|
|
|
Should:
|
|
- Delete the note
|
|
- verify returns false
|
|
"""
|
|
deleted = await delete_note("doesnt-exist", project=test_project.name)
|
|
assert deleted is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_tag_array_from_bug_report(app, test_project):
|
|
"""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: dict[str, Any] = {
|
|
"project": test_project.name,
|
|
"title": "Title",
|
|
"directory": "folder",
|
|
"content": "CONTENT",
|
|
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"],
|
|
}
|
|
|
|
# Try to call the function with this data directly
|
|
result = await write_note(**bug_payload)
|
|
|
|
assert result
|
|
assert f"project: {test_project.name}" in result
|
|
assert f"permalink: {test_project.name}/folder/title" in result
|
|
assert "Tags" in result
|
|
assert "hipporag" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_verbose(app, test_project, engine_factory):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory="test",
|
|
content="""
|
|
# Test\nThis is a test note
|
|
|
|
- [note] First observation
|
|
- "relates to" [[Knowledge]]
|
|
|
|
""",
|
|
tags=["test", "documentation"],
|
|
)
|
|
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: test/Test Note.md" in result
|
|
assert f"permalink: {test_project.name}/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
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
_, session_maker = engine_factory
|
|
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
|
|
relations = await relation_repository.find_by_type("relates to")
|
|
assert any(relation.to_name == "Knowledge" for relation in relations)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_preserves_custom_metadata(app, project_config, test_project):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="Custom Metadata Note",
|
|
directory="test",
|
|
content="# Initial content",
|
|
tags=["test"],
|
|
)
|
|
|
|
# Read the note to get its permalink
|
|
content = await read_note("test/custom-metadata-note", project=test_project.name)
|
|
|
|
# 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(
|
|
project=test_project.name,
|
|
title="Custom Metadata Note",
|
|
directory="test",
|
|
content="# Updated content",
|
|
tags=["test", "updated"],
|
|
overwrite=True,
|
|
)
|
|
|
|
# Verify the update was successful
|
|
assert (
|
|
"Updated note\nproject: test-project\nfile_path: test/Custom Metadata Note.md"
|
|
) in result
|
|
assert f"project: {test_project.name}" in result
|
|
|
|
# Read the note back and check if custom frontmatter is preserved
|
|
content = await read_note("test/custom-metadata-note", project=test_project.name)
|
|
|
|
# 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_project):
|
|
"""Test creating a new note."""
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory="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("test/test-note", project=test_project.name)
|
|
assert (
|
|
normalize_newlines(
|
|
dedent(
|
|
"""
|
|
---
|
|
title: Test Note
|
|
type: note
|
|
permalink: {permalink}
|
|
version: 1.0
|
|
author: name
|
|
tags:
|
|
- test
|
|
- documentation
|
|
---
|
|
|
|
# Test
|
|
|
|
This is a test note
|
|
"""
|
|
)
|
|
.format(permalink=f"{test_project.name}/test/test-note")
|
|
.strip()
|
|
)
|
|
in content
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
|
|
"""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(
|
|
project=test_project.name,
|
|
title="Note 1",
|
|
directory="test",
|
|
content="Original content for note 1",
|
|
)
|
|
assert "# Created note" in result1
|
|
assert f"project: {test_project.name}" in result1
|
|
assert f"permalink: {test_project.name}/test/note-1" in result1
|
|
|
|
# Step 2: Create second note with different title
|
|
result2 = await write_note(
|
|
project=test_project.name, title="Note 2", directory="test", content="Content for note 2"
|
|
)
|
|
assert "# Created note" in result2
|
|
assert f"project: {test_project.name}" in result2
|
|
assert f"permalink: {test_project.name}/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(
|
|
project=test_project.name,
|
|
title="Note 1", # Same title as first note
|
|
directory="test", # Same folder as first note
|
|
content="Replacement content for note 1", # Different content
|
|
overwrite=True,
|
|
)
|
|
|
|
# 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 f"project: {test_project.name}" in result3
|
|
assert "Updated note" in result3 or "Created note" in result3
|
|
|
|
# The result should contain either the original permalink or a unique one
|
|
assert (
|
|
f"permalink: {test_project.name}/test/note-1" in result3
|
|
or f"permalink: {test_project.name}/test/note-1-1" in result3
|
|
)
|
|
|
|
# Verify we can read back the content
|
|
if f"permalink: {test_project.name}/test/note-1" in result3:
|
|
# Updated existing note case
|
|
content = await read_note("test/note-1", project=test_project.name)
|
|
assert "Replacement content for note 1" in content
|
|
else:
|
|
# Created new note with unique permalink case
|
|
content = await read_note(test_project.name, "test/note-1-1")
|
|
assert "Replacement content for note 1" in content
|
|
# Original note should still exist
|
|
original_content = await read_note(test_project.name, "test/note-1")
|
|
assert "Original content for note 1" in original_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_custom_note_type(app, test_project):
|
|
"""Test creating a note with custom note_type parameter.
|
|
|
|
This test verifies the fix for Issue #144 where note_type parameter
|
|
was hardcoded to "note" instead of allowing custom types.
|
|
"""
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Guide",
|
|
directory="guides",
|
|
content="# Guide Content\nThis is a guide",
|
|
tags=["guide", "documentation"],
|
|
note_type="guide",
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: guides/Test Guide.md" in result
|
|
assert f"permalink: {test_project.name}/guides/test-guide" in result
|
|
assert "## Tags" in result
|
|
assert "- guide, documentation" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Verify the note type is correctly set in the frontmatter
|
|
content = await read_note("guides/test-guide", project=test_project.name)
|
|
expected = normalize_newlines(
|
|
dedent("""
|
|
---
|
|
title: Test Guide
|
|
type: guide
|
|
permalink: {permalink}
|
|
tags:
|
|
- guide
|
|
- documentation
|
|
---
|
|
|
|
# Guide Content
|
|
This is a guide
|
|
""")
|
|
.format(permalink=f"{test_project.name}/guides/test-guide")
|
|
.strip()
|
|
)
|
|
assert expected in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_report_note_type(app, test_project):
|
|
"""Test creating a note with note_type="report"."""
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Monthly Report",
|
|
directory="reports",
|
|
content="# Monthly Report\nThis is a monthly report",
|
|
tags=["report", "monthly"],
|
|
note_type="report",
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: reports/Monthly Report.md" in result
|
|
assert f"permalink: {test_project.name}/reports/monthly-report" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Verify the note type is correctly set in the frontmatter
|
|
content = await read_note("reports/monthly-report", project=test_project.name)
|
|
assert "type: report" in content
|
|
assert "# Monthly Report" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_with_config_note_type(app, test_project):
|
|
"""Test creating a note with note_type="config"."""
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="System Config",
|
|
directory="config",
|
|
content="# System Configuration\nThis is a config file",
|
|
note_type="config",
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: config/System Config.md" in result
|
|
assert f"permalink: {test_project.name}/config/system-config" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Verify the note type is correctly set in the frontmatter
|
|
content = await read_note("config/system-config", project=test_project.name)
|
|
assert "type: config" in content
|
|
assert "# System Configuration" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_note_type_default_behavior(app, test_project):
|
|
"""Test that the note_type parameter defaults to "note" when not specified.
|
|
|
|
This ensures backward compatibility - existing code that doesn't specify
|
|
note_type should continue to work as before.
|
|
"""
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Default Type Test",
|
|
directory="test",
|
|
content="# Default Type Test\nThis should be type 'note'",
|
|
tags=["test"],
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: test/Default Type Test.md" in result
|
|
assert f"permalink: {test_project.name}/test/default-type-test" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Verify the note type defaults to "note"
|
|
content = await read_note("test/default-type-test", project=test_project.name)
|
|
assert "type: note" in content
|
|
assert "# Default Type Test" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_update_existing_with_different_note_type(app, test_project):
|
|
"""Test updating an existing note with a different note_type."""
|
|
# Create initial note as "note" type
|
|
result1 = await write_note(
|
|
project=test_project.name,
|
|
title="Changeable Type",
|
|
directory="test",
|
|
content="# Initial Content\nThis starts as a note",
|
|
tags=["test"],
|
|
note_type="note",
|
|
)
|
|
|
|
assert result1
|
|
assert "# Created note" in result1
|
|
assert f"project: {test_project.name}" in result1
|
|
|
|
# Update the same note with a different note_type
|
|
result2 = await write_note(
|
|
project=test_project.name,
|
|
title="Changeable Type",
|
|
directory="test",
|
|
content="# Updated Content\nThis is now a guide",
|
|
tags=["guide"],
|
|
note_type="guide",
|
|
overwrite=True,
|
|
)
|
|
|
|
assert result2
|
|
assert "# Updated note" in result2
|
|
assert f"project: {test_project.name}" in result2
|
|
|
|
# Verify the note type was updated
|
|
content = await read_note("test/changeable-type", project=test_project.name)
|
|
assert "type: guide" in content
|
|
assert "# Updated Content" in content
|
|
assert "- guide" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_respects_frontmatter_note_type(app, test_project):
|
|
"""Test that note_type in frontmatter is respected when parameter is not provided.
|
|
|
|
This verifies that when write_note is called without note_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 note_type parameter - it should respect frontmatter type
|
|
result = await write_note(
|
|
project=test_project.name, title="Test Guide", directory="guides", content=note
|
|
)
|
|
|
|
assert result
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: guides/Test Guide.md" in result
|
|
assert "permalink: guides/test-guide" in result
|
|
assert f"[Session: Using project '{test_project.name}']" in result
|
|
|
|
# Verify the note type from frontmatter is respected (should be "guide", not "note")
|
|
content = await read_note("guides/test-guide", project=test_project.name)
|
|
assert "type: guide" in content
|
|
assert "# Guide Content" in content
|
|
assert "- guide" in content
|
|
assert "- documentation" in content
|
|
|
|
|
|
class TestWriteNoteSecurityValidation:
|
|
"""Test write_note security validation features."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_blocks_path_traversal_unix(self, app, test_project):
|
|
"""Test that Unix-style path traversal attacks are blocked in folder parameter."""
|
|
# Test various Unix-style path traversal patterns
|
|
attack_folders = [
|
|
"../",
|
|
"../../",
|
|
"../../../",
|
|
"../secrets",
|
|
"../../etc",
|
|
"../../../etc/passwd_folder",
|
|
"notes/../../../etc",
|
|
"folder/../../outside",
|
|
"../../../../malicious",
|
|
]
|
|
|
|
for attack_folder in attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory=attack_folder,
|
|
content="# Test Content\nThis should be blocked by security validation.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
assert attack_folder in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_blocks_path_traversal_windows(self, app, test_project):
|
|
"""Test that Windows-style path traversal attacks are blocked in folder parameter."""
|
|
# Test various Windows-style path traversal patterns
|
|
attack_folders = [
|
|
"..\\",
|
|
"..\\..\\",
|
|
"..\\..\\..\\",
|
|
"..\\secrets",
|
|
"..\\..\\Windows",
|
|
"..\\..\\..\\Windows\\System32",
|
|
"notes\\..\\..\\..\\Windows",
|
|
"\\\\server\\share",
|
|
"\\\\..\\..\\Windows",
|
|
]
|
|
|
|
for attack_folder in attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory=attack_folder,
|
|
content="# Test Content\nThis should be blocked by security validation.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
assert attack_folder in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_blocks_absolute_paths(self, app, test_project):
|
|
"""Test that absolute paths are blocked in folder parameter."""
|
|
# Test various absolute path patterns
|
|
attack_folders = [
|
|
"/etc",
|
|
"/home/user",
|
|
"/var/log",
|
|
"/root",
|
|
"C:\\Windows",
|
|
"C:\\Users\\user",
|
|
"D:\\secrets",
|
|
"/tmp/malicious",
|
|
"/usr/local/evil",
|
|
]
|
|
|
|
for attack_folder in attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory=attack_folder,
|
|
content="# Test Content\nThis should be blocked by security validation.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
assert attack_folder in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_blocks_home_directory_access(self, app, test_project):
|
|
"""Test that home directory access patterns are blocked in folder parameter."""
|
|
# Test various home directory access patterns
|
|
attack_folders = [
|
|
"~",
|
|
"~/",
|
|
"~/secrets",
|
|
"~/.ssh",
|
|
"~/Documents",
|
|
"~\\AppData",
|
|
"~\\Desktop",
|
|
"~/.env_folder",
|
|
]
|
|
|
|
for attack_folder in attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory=attack_folder,
|
|
content="# Test Content\nThis should be blocked by security validation.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
assert attack_folder in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_blocks_mixed_attack_patterns(self, app, test_project):
|
|
"""Test that mixed legitimate/attack patterns are blocked in folder parameter."""
|
|
# Test mixed patterns that start legitimate but contain attacks
|
|
attack_folders = [
|
|
"notes/../../../etc",
|
|
"docs/../../.env_folder",
|
|
"legitimate/path/../../.ssh",
|
|
"project/folder/../../../Windows",
|
|
"valid/folder/../../home/user",
|
|
"assets/../../../tmp/evil",
|
|
]
|
|
|
|
for attack_folder in attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Test Note",
|
|
directory=attack_folder,
|
|
content="# Test Content\nThis should be blocked by security validation.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_allows_safe_folder_paths(self, app, test_project):
|
|
"""Test that legitimate folder paths are still allowed."""
|
|
# Test various safe folder patterns
|
|
safe_folders = [
|
|
"notes",
|
|
"docs",
|
|
"projects/2025",
|
|
"archive/old-notes",
|
|
"deep/nested/directory/structure",
|
|
"folder/subfolder",
|
|
"research/ml",
|
|
"meeting-notes",
|
|
]
|
|
|
|
for safe_folder in safe_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title=f"Test Note in {safe_folder.replace('/', '-')}",
|
|
directory=safe_folder,
|
|
content="# Test Content\nThis should work normally with security validation.",
|
|
tags=["test", "security"],
|
|
)
|
|
|
|
# Should succeed (not a security error)
|
|
assert isinstance(result, str)
|
|
assert "# Error" not in result
|
|
assert "paths must stay within project boundaries" not in result
|
|
# Should be normal successful creation/update
|
|
assert ("# Created note" in result) or ("# Updated note" in result)
|
|
assert safe_folder in result # Should show in file_path
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_empty_folder_security(self, app, test_project):
|
|
"""Test that empty folder parameter is handled securely."""
|
|
# Empty folder should be allowed (creates in root)
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Root Note",
|
|
directory="",
|
|
content="# Root Note\nThis note should be created in the project root.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
# Empty folder should not trigger security error
|
|
assert "# Error" not in result
|
|
assert "paths must stay within project boundaries" not in result
|
|
# Should succeed normally
|
|
assert ("# Created note" in result) or ("# Updated note" in result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_none_folder_security(self, app, test_project):
|
|
"""Test that default folder behavior works securely when folder is omitted."""
|
|
# The write_note function requires folder parameter, but we can test with empty string
|
|
# which effectively creates in project root
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Root Folder Note",
|
|
directory="", # Empty string instead of None since folder is required
|
|
content="# Root Folder Note\nThis note should be created in the project root.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
# Empty folder should not trigger security error
|
|
assert "# Error" not in result
|
|
assert "paths must stay within project boundaries" not in result
|
|
# Should succeed normally
|
|
assert ("# Created note" in result) or ("# Updated note" in result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_current_directory_references_security(self, app, test_project):
|
|
"""Test that current directory references are handled securely."""
|
|
# Test current directory references (should be safe)
|
|
safe_folders = [
|
|
"./notes",
|
|
"folder/./subfolder",
|
|
"./folder/subfolder",
|
|
]
|
|
|
|
for safe_folder in safe_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title=f"Current Dir Test {safe_folder.replace('/', '-').replace('.', 'dot')}",
|
|
directory=safe_folder,
|
|
content="# Current Directory Test\nThis should work with current directory references.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
# Should NOT contain security error message
|
|
assert "# Error" not in result
|
|
assert "paths must stay within project boundaries" not in result
|
|
# Should succeed normally
|
|
assert ("# Created note" in result) or ("# Updated note" in result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_security_with_all_parameters(self, app, test_project):
|
|
"""Test security validation works with all write_note parameters."""
|
|
# Test that security validation is applied even when all other parameters are provided
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Security Test with All Params",
|
|
directory="../../../etc/malicious",
|
|
content="# Malicious Content\nThis should be blocked by security validation.",
|
|
tags=["malicious", "test"],
|
|
note_type="guide",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
assert "../../../etc/malicious" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_security_logging(self, app, test_project, caplog):
|
|
"""Test that security violations are properly logged."""
|
|
# Attempt path traversal attack
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Security Logging Test",
|
|
directory="../../../etc/passwd_folder",
|
|
content="# Test Content\nThis should trigger security logging.",
|
|
)
|
|
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
|
|
# Check that security violation was logged
|
|
# Note: This test may need adjustment based on the actual logging setup
|
|
# The security validation should generate a warning log entry
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_preserves_functionality_with_security(self, app, test_project):
|
|
"""Test that security validation doesn't break normal note creation functionality."""
|
|
# Create a note with all features to ensure security validation doesn't interfere
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Full Feature Security Test",
|
|
directory="security-tests",
|
|
content=dedent("""
|
|
# Full Feature Security Test
|
|
|
|
This note tests that security validation doesn't break normal functionality.
|
|
|
|
## Observations
|
|
- [security] Path validation working correctly #security
|
|
- [feature] All features still functional #test
|
|
|
|
## Relations
|
|
- relates_to [[Security Implementation]]
|
|
- depends_on [[Path Validation]]
|
|
|
|
Additional content with various formatting.
|
|
""").strip(),
|
|
tags=["security", "test", "full-feature"],
|
|
note_type="guide",
|
|
)
|
|
|
|
# Should succeed normally
|
|
assert isinstance(result, str)
|
|
assert "# Error" not in result
|
|
assert "paths must stay within project boundaries" not in result
|
|
assert "# Created note" in result
|
|
assert "file_path: security-tests/Full Feature Security Test.md" in result
|
|
assert f"permalink: {test_project.name}/security-tests/full-feature-security-test" in result
|
|
|
|
# Should process observations and relations
|
|
assert "## Observations" in result
|
|
assert "## Relations" in result
|
|
assert "## Tags" in result
|
|
|
|
# Should show proper counts
|
|
assert "security: 1" in result
|
|
assert "feature: 1" in result
|
|
|
|
|
|
class TestWriteNoteSecurityEdgeCases:
|
|
"""Test edge cases for write_note security validation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_unicode_folder_attacks(self, app, test_project):
|
|
"""Test that Unicode-based path traversal attempts are blocked."""
|
|
# Test Unicode path traversal attempts
|
|
unicode_attack_folders = [
|
|
"notes/文档/../../../etc", # Chinese characters
|
|
"docs/café/../../secrets", # Accented characters
|
|
"files/αβγ/../../../malicious", # Greek characters
|
|
]
|
|
|
|
for attack_folder in unicode_attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Unicode Attack Test",
|
|
directory=attack_folder,
|
|
content="# Unicode Attack\nThis should be blocked.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_very_long_attack_folder(self, app, test_project):
|
|
"""Test handling of very long attack folder paths."""
|
|
# Create a very long path traversal attack
|
|
long_attack_folder = "../" * 1000 + "etc/malicious"
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Long Attack Test",
|
|
directory=long_attack_folder,
|
|
content="# Long Attack\nThis should be blocked.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_case_variations_attacks(self, app, test_project):
|
|
"""Test that case variations don't bypass security."""
|
|
# Test case variations (though case sensitivity depends on filesystem)
|
|
case_attack_folders = [
|
|
"../ETC",
|
|
"../Etc/SECRETS",
|
|
"..\\WINDOWS",
|
|
"~/SECRETS",
|
|
]
|
|
|
|
for attack_folder in case_attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Case Variation Attack Test",
|
|
directory=attack_folder,
|
|
content="# Case Attack\nThis should be blocked.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_whitespace_in_attack_folders(self, app, test_project):
|
|
"""Test that whitespace doesn't help bypass security."""
|
|
# Test attack folders with various whitespace
|
|
whitespace_attack_folders = [
|
|
" ../../../etc ",
|
|
"\t../../../secrets\t",
|
|
" ..\\..\\Windows ",
|
|
"notes/ ../../ malicious",
|
|
]
|
|
|
|
for attack_folder in whitespace_attack_folders:
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Whitespace Attack Test",
|
|
directory=attack_folder,
|
|
content="# Whitespace Attack\nThis should be blocked.",
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
# The attack should still be blocked even with whitespace
|
|
if ".." in attack_folder.strip() or "~" in attack_folder.strip():
|
|
assert "# Error" in result
|
|
assert "paths must stay within project boundaries" in result
|
|
|
|
|
|
class TestWriteNoteOverwriteGuard:
|
|
"""Test the write_note overwrite guard feature (Issue #625)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_blocks_overwrite_by_default(self, app, test_project):
|
|
"""Second write_note to same title/directory returns error, original content untouched."""
|
|
# Create initial note
|
|
result1 = await write_note(
|
|
project=test_project.name,
|
|
title="Guard Test",
|
|
directory="guard",
|
|
content="# Guard Test\n\nOriginal content",
|
|
)
|
|
assert "# Created note" in result1
|
|
|
|
# Second write without overwrite should be blocked
|
|
result2 = await write_note(
|
|
project=test_project.name,
|
|
title="Guard Test",
|
|
directory="guard",
|
|
content="# Guard Test\n\nReplacement content",
|
|
)
|
|
assert "# Error: Note already exists" in result2
|
|
assert "Guard Test" in result2
|
|
assert "edit_note" in result2
|
|
|
|
# Original content should be untouched
|
|
content = await read_note("guard/guard-test", project=test_project.name)
|
|
assert "Original content" in content
|
|
assert "Replacement content" not in content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_overwrite_false_explicit(self, app, test_project):
|
|
"""Explicit overwrite=False behaves same as default."""
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="Explicit False",
|
|
directory="guard",
|
|
content="# Explicit False\n\nOriginal",
|
|
)
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Explicit False",
|
|
directory="guard",
|
|
content="# Explicit False\n\nReplacement",
|
|
overwrite=False,
|
|
)
|
|
assert "# Error: Note already exists" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_overwrite_true_replaces(self, app, test_project):
|
|
"""Explicit overwrite=True performs the update."""
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="Overwrite True",
|
|
directory="guard",
|
|
content="# Overwrite True\n\nOriginal content",
|
|
)
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Overwrite True",
|
|
directory="guard",
|
|
content="# Overwrite True\n\nReplacement content",
|
|
overwrite=True,
|
|
)
|
|
assert "# Updated note" in result
|
|
|
|
# Verify content was replaced
|
|
content = await read_note("guard/overwrite-true", project=test_project.name)
|
|
assert "Replacement content" in content
|
|
assert "Original content" not in content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_overwrite_error_json_format(self, app, test_project):
|
|
"""JSON output returns structured error with NOTE_ALREADY_EXISTS."""
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="JSON Guard",
|
|
directory="guard",
|
|
content="# JSON Guard\n\nOriginal",
|
|
)
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="JSON Guard",
|
|
directory="guard",
|
|
content="# JSON Guard\n\nReplacement",
|
|
output_format="json",
|
|
)
|
|
assert isinstance(result, dict)
|
|
assert result["error"] == "NOTE_ALREADY_EXISTS"
|
|
assert result["action"] == "conflict"
|
|
assert result["title"] == "JSON Guard"
|
|
assert result["permalink"] is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_config_overwrite_default_true(
|
|
self, app, test_project, app_config, config_manager
|
|
):
|
|
"""Config write_note_overwrite_default=True restores old upsert behavior."""
|
|
# Set config to allow overwrites by default
|
|
app_config.write_note_overwrite_default = True
|
|
config_module._CONFIG_CACHE = app_config
|
|
# Pin mtime+size to the on-disk file so the cache guard sees a match
|
|
# and keeps our injected config instead of re-reading from disk.
|
|
_st = config_manager.config_file.stat()
|
|
config_module._CONFIG_MTIME = _st.st_mtime
|
|
config_module._CONFIG_SIZE = _st.st_size
|
|
|
|
try:
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="Config Default",
|
|
directory="guard",
|
|
content="# Config Default\n\nOriginal",
|
|
)
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Config Default",
|
|
directory="guard",
|
|
content="# Config Default\n\nReplacement via config default",
|
|
)
|
|
# Should succeed as update because config default is True
|
|
assert "# Updated note" in result
|
|
|
|
content = await read_note("guard/config-default", project=test_project.name)
|
|
assert "Replacement via config default" in content
|
|
finally:
|
|
# Restore config
|
|
app_config.write_note_overwrite_default = False
|
|
config_module._CONFIG_CACHE = app_config
|
|
_st = config_manager.config_file.stat()
|
|
config_module._CONFIG_MTIME = _st.st_mtime
|
|
config_module._CONFIG_SIZE = _st.st_size
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_new_note_unaffected(self, app, test_project):
|
|
"""Guard only triggers on conflict — new notes are created normally."""
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Brand New Note",
|
|
directory="guard",
|
|
content="# Brand New Note\n\nFresh content",
|
|
tags=["new"],
|
|
)
|
|
assert "# Created note" in result
|
|
assert f"project: {test_project.name}" in result
|
|
assert "file_path: guard/Brand New Note.md" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_overwrite_resolves_by_file_path_strictly(
|
|
self, app, test_project, entity_repository, monkeypatch
|
|
):
|
|
"""Regression: overwrite=True must resolve the conflicting entity by
|
|
file_path with strict=True, not by permalink with fuzzy fallback.
|
|
|
|
Bug shape: in workspace-prefixed palaces the client-built permalink
|
|
omits the workspace slug, so resolve_entity(permalink) with the default
|
|
strict=False would fall through to fuzzy search and could pick an
|
|
orphan row sharing tokens with the canonical permalink. The update
|
|
then wrote to the orphan, the canonical row stayed stale, and the
|
|
next overwrite minted a -1/-2 suffix because the permalink uniqueness
|
|
check found duplicate rows.
|
|
|
|
The 409 we catch came from a file_service.exists(file_path) check,
|
|
so file_path is the authoritative key — strict resolution against it
|
|
is safe even when permalinks are workspace-prefixed elsewhere.
|
|
"""
|
|
# Spy on the resolve_entity call to assert the identifier and strict flag.
|
|
from basic_memory.mcp.clients import knowledge as knowledge_mod
|
|
|
|
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def spy_resolve(self, identifier, *, strict=False):
|
|
captured["identifier"] = identifier
|
|
captured["strict"] = strict
|
|
return await original_resolve(self, identifier, strict=strict)
|
|
|
|
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
|
|
|
|
# Create then overwrite the canonical note.
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="Overview",
|
|
directory="features/foo",
|
|
content="# Overview\n\nVersion A",
|
|
)
|
|
canonical_permalink = f"{test_project.name}/features/foo/overview"
|
|
canonical = await entity_repository.get_by_permalink(canonical_permalink)
|
|
assert canonical is not None
|
|
canonical_id = canonical.id
|
|
|
|
result = await write_note(
|
|
project=test_project.name,
|
|
title="Overview",
|
|
directory="features/foo",
|
|
content="# Overview\n\nVersion B",
|
|
overwrite=True,
|
|
)
|
|
assert "# Updated note" in result
|
|
|
|
# The overwrite path resolved by file_path with strict=True — not by
|
|
# permalink with the default fuzzy fallback.
|
|
assert captured.get("identifier") == "features/foo/Overview.md"
|
|
assert captured.get("strict") is True
|
|
|
|
# And the canonical row was updated in place — no duplicate -1/-2 row.
|
|
canonical_after = await entity_repository.get_by_permalink(canonical_permalink)
|
|
assert canonical_after is not None
|
|
assert canonical_after.id == canonical_id
|
|
|
|
content = await read_note(canonical_permalink, project=test_project.name)
|
|
assert "Version B" in content
|
|
assert "Version A" not in content
|
|
|
|
for suffix in ("-1", "-2"):
|
|
stray = await entity_repository.get_by_permalink(f"{canonical_permalink}{suffix}")
|
|
assert stray is None, (
|
|
f"overwrite=True minted a stray '{suffix}' suffix on the canonical permalink"
|
|
)
|