442 lines
16 KiB
Python
442 lines
16 KiB
Python
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from haiku.rag.client import HaikuRAG
|
|
from haiku.rag.config import AppConfig, MonitorConfig
|
|
from haiku.rag.monitor import FileWatcher
|
|
from haiku.rag.store.models.document import Document
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_upsert_document():
|
|
"""Test FileWatcher._upsert_document method."""
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir) / "test.txt"
|
|
temp_path.write_text("Test content for file watcher")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
mock_doc = Document(id="1", content="Test content", uri=temp_path.as_uri())
|
|
mock_client.create_document_from_source.return_value = mock_doc
|
|
mock_client.get_document_by_uri.return_value = None # No existing document
|
|
|
|
test_config = AppConfig(monitor=MonitorConfig(directories=[temp_path.parent]))
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
result = await watcher._upsert_document(temp_path)
|
|
|
|
assert result is not None
|
|
assert result.id == "1"
|
|
mock_client.get_document_by_uri.assert_called_once_with(temp_path.as_uri())
|
|
mock_client.create_document_from_source.assert_called_once_with(str(temp_path))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_upsert_existing_document():
|
|
"""Test FileWatcher._upsert_document with existing document."""
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir) / "test.txt"
|
|
temp_path.write_text("Test content for file watcher")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
existing_doc = Document(id="1", content="Old content", uri=temp_path.as_uri())
|
|
updated_doc = Document(
|
|
id="1", content="Updated content", uri=temp_path.as_uri()
|
|
)
|
|
|
|
mock_client.get_document_by_uri.return_value = existing_doc
|
|
mock_client.create_document_from_source.return_value = updated_doc
|
|
|
|
test_config = AppConfig(monitor=MonitorConfig(directories=[temp_path.parent]))
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
result = await watcher._upsert_document(temp_path)
|
|
|
|
assert result is not None
|
|
assert result.content == "Updated content"
|
|
mock_client.get_document_by_uri.assert_called_once_with(temp_path.as_uri())
|
|
mock_client.create_document_from_source.assert_called_once_with(str(temp_path))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_delete_document():
|
|
"""Test FileWatcher._delete_document method."""
|
|
temp_path = Path("/tmp/test_file.txt")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
existing_doc = Document(id="1", content="Content to delete", uri=temp_path.as_uri())
|
|
mock_client.get_document_by_uri.return_value = existing_doc
|
|
mock_client.delete_document.return_value = True
|
|
|
|
test_config = AppConfig(monitor=MonitorConfig(directories=[temp_path.parent]))
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
await watcher._delete_document(temp_path)
|
|
|
|
mock_client.get_document_by_uri.assert_called_once_with(temp_path.as_uri())
|
|
mock_client.delete_document.assert_called_once_with("1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_delete_nonexistent_document():
|
|
"""Test FileWatcher._delete_document with non-existent document."""
|
|
temp_path = Path("/tmp/nonexistent_file.txt")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
mock_client.get_document_by_uri.return_value = None
|
|
|
|
test_config = AppConfig(monitor=MonitorConfig(directories=[temp_path.parent]))
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
await watcher._delete_document(temp_path)
|
|
|
|
mock_client.get_document_by_uri.assert_called_once_with(temp_path.as_uri())
|
|
mock_client.delete_document.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_filter_ignore_patterns():
|
|
"""Test FileFilter with ignore patterns."""
|
|
from watchfiles import Change
|
|
|
|
from haiku.rag.monitor import FileFilter
|
|
|
|
filter = FileFilter(ignore_patterns=["*draft*.md", "temp/", "**/archive/**"])
|
|
|
|
# Should ignore draft markdown files
|
|
assert not filter(Change.added, "/path/to/draft-post.md")
|
|
|
|
# Should ignore files in temp/ directory
|
|
assert not filter(Change.added, "/path/temp/notes.txt")
|
|
|
|
# Should ignore files in archive directories
|
|
assert not filter(Change.added, "/path/to/archive/old.pdf")
|
|
|
|
# Should NOT ignore regular markdown files
|
|
assert filter(Change.added, "/path/to/readme.md")
|
|
|
|
# Should NOT ignore files outside temp/
|
|
assert filter(Change.added, "/path/to/notes.txt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_filter_include_patterns():
|
|
"""Test FileFilter with include patterns (whitelist mode)."""
|
|
from watchfiles import Change
|
|
|
|
from haiku.rag.monitor import FileFilter
|
|
|
|
filter = FileFilter(include_patterns=["*.md", "**/docs/**"])
|
|
|
|
# Should include .md files
|
|
assert filter(Change.added, "/path/to/file.md")
|
|
|
|
# Should include files in docs/ directory
|
|
assert filter(Change.added, "/path/to/docs/guide.txt")
|
|
|
|
# Should NOT include .txt files outside docs/
|
|
assert not filter(Change.added, "/path/to/file.txt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_filter_combined_patterns():
|
|
"""Test FileFilter with both include and ignore patterns."""
|
|
from watchfiles import Change
|
|
|
|
from haiku.rag.monitor import FileFilter
|
|
|
|
# Include all markdown files, but ignore drafts
|
|
filter = FileFilter(
|
|
include_patterns=["*.md"], ignore_patterns=["*draft*.md", "archive/"]
|
|
)
|
|
|
|
# Should include regular .md files
|
|
assert filter(Change.added, "/path/to/readme.md")
|
|
|
|
# Should ignore draft .md files (ignore takes precedence after include)
|
|
assert not filter(Change.added, "/path/to/draft-post.md")
|
|
|
|
# Should ignore .md files in archive/ directory
|
|
assert not filter(Change.added, "/path/archive/old.md")
|
|
|
|
# Should NOT include .txt files (not in include patterns)
|
|
assert not filter(Change.added, "/path/to/file.txt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_filter_extension_check():
|
|
"""Test that FileFilter still respects extension filtering."""
|
|
from watchfiles import Change
|
|
|
|
from haiku.rag.monitor import FileFilter
|
|
|
|
filter = FileFilter()
|
|
|
|
# Should include files with supported extensions
|
|
assert filter(Change.added, "/path/to/document.pdf")
|
|
assert filter(Change.added, "/path/to/notes.md")
|
|
|
|
# Should not include files with unsupported extensions
|
|
assert not filter(Change.added, "/path/to/file.xyz")
|
|
assert not filter(Change.added, "/path/to/binary.bin")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_with_ignore_patterns():
|
|
"""Test FileWatcher respects ignore patterns from config."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
draft_file = temp_path / "draft.md"
|
|
readme_file = temp_path / "readme.md"
|
|
|
|
draft_file.write_text("Draft content")
|
|
readme_file.write_text("Readme content")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
mock_doc = Document(id="1", content="Readme", uri=readme_file.as_uri())
|
|
mock_client.create_document_from_source.return_value = mock_doc
|
|
mock_client.get_document_by_uri.return_value = None
|
|
|
|
test_config = AppConfig(
|
|
monitor=MonitorConfig(directories=[temp_path], ignore_patterns=["draft*"])
|
|
)
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
# Run refresh which should only process readme.md, not draft.md
|
|
await watcher.refresh()
|
|
|
|
# Should have only called for the readme file
|
|
assert mock_client.create_document_from_source.call_count == 1
|
|
mock_client.create_document_from_source.assert_called_with(str(readme_file))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_with_include_patterns():
|
|
"""Test FileWatcher respects include patterns from config."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
md_file = temp_path / "readme.md"
|
|
pdf_file = temp_path / "document.pdf"
|
|
py_file = temp_path / "script.py"
|
|
|
|
md_file.write_text("Markdown content")
|
|
pdf_file.write_text("PDF content")
|
|
py_file.write_text("Python content")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
mock_doc = Document(id="1", content="Markdown", uri=md_file.as_uri())
|
|
mock_client.create_document_from_source.return_value = mock_doc
|
|
mock_client.get_document_by_uri.return_value = None
|
|
|
|
test_config = AppConfig(
|
|
monitor=MonitorConfig(directories=[temp_path], include_patterns=["*.md"])
|
|
)
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
# Run refresh which should only process .md file, not .pdf or .py
|
|
await watcher.refresh()
|
|
|
|
# Should have only called for the .md file
|
|
assert mock_client.create_document_from_source.call_count == 1
|
|
mock_client.create_document_from_source.assert_called_with(str(md_file))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_skips_unchanged_document():
|
|
"""Test FileWatcher returns existing document when content hasn't changed."""
|
|
from datetime import datetime
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
test_file = temp_path / "test.txt"
|
|
test_content = "Test content"
|
|
test_file.write_text(test_content)
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
# Existing document with a timestamp
|
|
now = datetime.now()
|
|
existing_doc = Document(
|
|
id="1",
|
|
content=test_content,
|
|
uri=test_file.as_uri(),
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
mock_client.get_document_by_uri.return_value = existing_doc
|
|
# Client returns same document with same timestamp (unchanged)
|
|
mock_client.create_document_from_source.return_value = existing_doc
|
|
|
|
test_config = AppConfig(monitor=MonitorConfig(directories=[temp_path]))
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
result = await watcher._upsert_document(test_file)
|
|
|
|
assert result is not None
|
|
assert result.id == "1"
|
|
# Verify timestamp hasn't changed (document wasn't updated)
|
|
assert result.updated_at == now
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_deletes_orphans():
|
|
"""Test FileWatcher deletes documents whose files no longer exist."""
|
|
import asyncio
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
existing_file = temp_path / "exists.txt"
|
|
existing_file.write_text("Existing file")
|
|
|
|
# Create a document for a file that doesn't exist
|
|
orphan_uri = (temp_path / "deleted.txt").as_uri()
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
orphan_doc = Document(id="orphan-1", content="Orphaned content", uri=orphan_uri)
|
|
existing_doc = Document(
|
|
id="existing-1", content="Existing content", uri=existing_file.as_uri()
|
|
)
|
|
|
|
# Mock list_documents to return both documents
|
|
mock_client.list_documents.return_value = [orphan_doc, existing_doc]
|
|
mock_client.get_document_by_uri.return_value = None
|
|
mock_client.create_document_from_source.return_value = existing_doc
|
|
|
|
test_config = AppConfig(
|
|
monitor=MonitorConfig(directories=[temp_path], delete_orphans=True)
|
|
)
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
# Run refresh which should delete orphan and process existing file
|
|
await watcher.refresh()
|
|
|
|
# Give background task time to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Should have deleted the orphan document
|
|
mock_client.delete_document.assert_called_once_with("orphan-1")
|
|
# Should have processed the existing file
|
|
mock_client.create_document_from_source.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_skips_orphan_deletion_when_disabled():
|
|
"""Test FileWatcher does not delete orphans when delete_orphans is False."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create a document for a file that doesn't exist
|
|
orphan_uri = (temp_path / "deleted.txt").as_uri()
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
orphan_doc = Document(id="orphan-1", content="Orphaned content", uri=orphan_uri)
|
|
|
|
# Mock list_documents to return orphan document
|
|
mock_client.list_documents.return_value = [orphan_doc]
|
|
|
|
test_config = AppConfig(
|
|
monitor=MonitorConfig(directories=[temp_path], delete_orphans=False)
|
|
)
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
# Run refresh
|
|
await watcher.refresh()
|
|
|
|
# Should NOT have deleted the orphan document
|
|
mock_client.delete_document.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_orphan_deletion_respects_patterns():
|
|
"""Test orphan deletion respects include/ignore patterns."""
|
|
import asyncio
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create documents for files that don't exist
|
|
ignored_orphan_uri = (temp_path / "draft.md").as_uri()
|
|
excluded_orphan_uri = (temp_path / "file.pdf").as_uri()
|
|
included_orphan_uri = (temp_path / "readme.md").as_uri()
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
|
|
ignored_doc = Document(
|
|
id="ignored-1", content="Ignored", uri=ignored_orphan_uri
|
|
)
|
|
excluded_doc = Document(
|
|
id="excluded-1", content="Excluded", uri=excluded_orphan_uri
|
|
)
|
|
included_doc = Document(
|
|
id="included-1", content="Included", uri=included_orphan_uri
|
|
)
|
|
|
|
# Mock list_documents to return all orphan documents
|
|
mock_client.list_documents.return_value = [
|
|
ignored_doc,
|
|
excluded_doc,
|
|
included_doc,
|
|
]
|
|
|
|
# Config with patterns: only .md files, but exclude draft*
|
|
test_config = AppConfig(
|
|
monitor=MonitorConfig(
|
|
directories=[temp_path],
|
|
delete_orphans=True,
|
|
include_patterns=["*.md"],
|
|
ignore_patterns=["draft*"],
|
|
)
|
|
)
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
# Run refresh
|
|
await watcher.refresh()
|
|
|
|
# Give background task time to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Should only delete the included orphan (readme.md)
|
|
# - draft.md matches ignore pattern -> NOT deleted
|
|
# - file.pdf doesn't match include pattern -> NOT deleted
|
|
# - readme.md matches include and not ignored -> DELETED
|
|
mock_client.delete_document.assert_called_once_with("included-1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_watcher_orphan_handles_spaces_in_filenames():
|
|
"""Test orphan deletion correctly handles files with spaces in names."""
|
|
import asyncio
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
# Create a file with spaces that exists
|
|
existing_file = temp_path / "my file with spaces.txt"
|
|
existing_file.write_text("Existing file")
|
|
|
|
mock_client = AsyncMock(spec=HaikuRAG)
|
|
# Document with URI that has URL-encoded spaces (%20)
|
|
existing_doc = Document(
|
|
id="existing-1", content="Existing", uri=existing_file.as_uri()
|
|
)
|
|
|
|
# Mock list_documents to return document with encoded spaces
|
|
mock_client.list_documents.return_value = [existing_doc]
|
|
mock_client.get_document_by_uri.return_value = None
|
|
mock_client.create_document_from_source.return_value = existing_doc
|
|
|
|
test_config = AppConfig(
|
|
monitor=MonitorConfig(directories=[temp_path], delete_orphans=True)
|
|
)
|
|
watcher = FileWatcher(client=mock_client, config=test_config)
|
|
|
|
# Run refresh
|
|
await watcher.refresh()
|
|
|
|
# Give background task time to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Should NOT delete the document since file exists
|
|
mock_client.delete_document.assert_not_called()
|