The CLI decides only what it knows — that --db and --database are the same thing said twice, and whether a command reads more than one — and hands the resolved scope down. Nothing rewrites the configuration, so a named database keeps the name results and citations carry, and a remote one opens the URI it was configured with rather than the local path standing in for it. HaikuRAGApp, ChatApp and InspectorApp take that scope and nothing else. Selection reaches the client through a private constructor, so the public signature still takes a path or names.
417 lines
16 KiB
Python
417 lines
16 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from haiku.rag.client import HaikuRAG
|
|
from haiku.rag.store import ReadOnlyError
|
|
from haiku.rag.store.engine import Store
|
|
from haiku.rag.store.models import Chunk, Document
|
|
from haiku.rag.store.repositories.chunk import ChunkRepository
|
|
from haiku.rag.store.repositories.document import DocumentRepository
|
|
from haiku.rag.store.repositories.settings import SettingsRepository
|
|
from tests.conftest import for_path
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def vcr_cassette_dir():
|
|
return str(Path(__file__).parent.parent / "cassettes" / "test_read_only")
|
|
|
|
|
|
class TestReadOnlyError:
|
|
def test_read_only_error_is_exception(self):
|
|
"""ReadOnlyError should be a subclass of Exception."""
|
|
assert issubclass(ReadOnlyError, Exception)
|
|
|
|
def test_read_only_error_can_be_raised(self):
|
|
"""ReadOnlyError can be raised and caught."""
|
|
with pytest.raises(ReadOnlyError) as exc_info:
|
|
raise ReadOnlyError("Cannot modify database in read-only mode")
|
|
assert "read-only" in str(exc_info.value)
|
|
|
|
|
|
class TestStoreReadOnly:
|
|
@pytest.mark.asyncio
|
|
async def test_store_read_only_raises_on_empty_directory(self, tmp_path):
|
|
"""Opening an empty directory in read-only mode raises ReadOnlyError."""
|
|
empty_dir = tmp_path / "empty_db"
|
|
empty_dir.mkdir()
|
|
|
|
with pytest.raises(
|
|
ReadOnlyError, match="Cannot create tables in read-only mode"
|
|
):
|
|
async with Store(
|
|
empty_dir,
|
|
read_only=True,
|
|
skip_validation=True,
|
|
skip_migration_check=True,
|
|
):
|
|
pass
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_default_is_not_read_only(self, temp_db_path):
|
|
"""Store defaults to not read-only."""
|
|
async with Store(temp_db_path, create=True) as store:
|
|
assert store.is_read_only is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_can_be_created_read_only(self, temp_db_path):
|
|
"""Store can be created with read_only=True."""
|
|
# First create a normal store to initialize the database
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
# Now open in read-only mode
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
assert store.is_read_only is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assert_writable_raises_when_read_only(self, temp_db_path):
|
|
"""_assert_writable() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
with pytest.raises(ReadOnlyError):
|
|
store._assert_writable()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assert_writable_passes_when_not_read_only(self, temp_db_path):
|
|
"""_assert_writable() does not raise when read_only=False."""
|
|
async with Store(temp_db_path, create=True) as store:
|
|
store._assert_writable() # Should not raise
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vacuum_raises_when_read_only(self, temp_db_path):
|
|
"""vacuum() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
with pytest.raises(ReadOnlyError):
|
|
await store.vacuum()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_haiku_version_raises_when_read_only(self, temp_db_path):
|
|
"""set_haiku_version() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
with pytest.raises(ReadOnlyError):
|
|
await store.set_haiku_version("1.0.0")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recreate_embeddings_table_raises_when_read_only(self, temp_db_path):
|
|
"""recreate_embeddings_table() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
with pytest.raises(ReadOnlyError):
|
|
await store.recreate_embeddings_table()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_transaction_raises_when_read_only(self, temp_db_path):
|
|
"""write_transaction() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
with pytest.raises(ReadOnlyError):
|
|
async with store.write_transaction():
|
|
pass # pragma: no cover - entering already raised
|
|
|
|
|
|
class TestDocumentRepositoryReadOnly:
|
|
@pytest.mark.asyncio
|
|
async def test_create_raises_when_read_only(self, temp_db_path):
|
|
"""DocumentRepository.create() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = DocumentRepository(store)
|
|
doc = Document(content="test content")
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.create(doc)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_raises_when_read_only(self, temp_db_path):
|
|
"""DocumentRepository.update() raises ReadOnlyError when read_only=True."""
|
|
# First create a document
|
|
async with Store(temp_db_path, create=True) as store:
|
|
repo = DocumentRepository(store)
|
|
doc = Document(content="test content")
|
|
created_doc = await repo.create(doc)
|
|
|
|
# Try to update in read-only mode
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = DocumentRepository(store)
|
|
created_doc.content = "updated content"
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.update(created_doc)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_raises_when_read_only(self, temp_db_path):
|
|
"""DocumentRepository.delete() raises ReadOnlyError when read_only=True."""
|
|
# First create a document
|
|
async with Store(temp_db_path, create=True) as store:
|
|
repo = DocumentRepository(store)
|
|
doc = Document(content="test content")
|
|
created_doc = await repo.create(doc)
|
|
assert created_doc.id is not None
|
|
doc_id = created_doc.id
|
|
|
|
# Try to delete in read-only mode
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = DocumentRepository(store)
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.delete(doc_id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_all_raises_when_read_only(self, temp_db_path):
|
|
"""DocumentRepository.delete_all() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = DocumentRepository(store)
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.delete_all()
|
|
|
|
|
|
class TestChunkRepositoryReadOnly:
|
|
@pytest.mark.asyncio
|
|
async def test_create_raises_when_read_only(self, temp_db_path):
|
|
"""ChunkRepository.create() raises ReadOnlyError when read_only=True."""
|
|
# First create a document to have a valid document_id
|
|
async with Store(temp_db_path, create=True) as store:
|
|
doc_repo = DocumentRepository(store)
|
|
doc = Document(content="test content")
|
|
created_doc = await doc_repo.create(doc)
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = ChunkRepository(store)
|
|
chunk = Chunk(
|
|
content="test chunk",
|
|
document_id=created_doc.id,
|
|
embedding=[0.0] * store.embedder._vector_dim,
|
|
)
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.create(chunk)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_by_document_id_raises_when_read_only(self, temp_db_path):
|
|
"""ChunkRepository.delete_by_document_id() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = ChunkRepository(store)
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.delete_by_document_id("some-id")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_all_raises_when_read_only(self, temp_db_path):
|
|
"""ChunkRepository.delete_all() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = ChunkRepository(store)
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.delete_all()
|
|
|
|
|
|
class TestSettingsRepositoryReadOnly:
|
|
@pytest.mark.asyncio
|
|
async def test_save_current_settings_raises_when_read_only(self, temp_db_path):
|
|
"""SettingsRepository.save_current_settings() raises ReadOnlyError when read_only=True."""
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with Store(temp_db_path, read_only=True) as store:
|
|
repo = SettingsRepository(store)
|
|
|
|
with pytest.raises(ReadOnlyError):
|
|
await repo.save_current_settings()
|
|
|
|
|
|
class TestClientReadOnly:
|
|
@pytest.mark.asyncio
|
|
async def test_client_default_is_not_read_only(self, temp_db_path):
|
|
"""Client defaults to not read-only."""
|
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
assert client.is_read_only is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_can_be_created_read_only(self, temp_db_path):
|
|
"""Client can be created with read_only=True."""
|
|
async with HaikuRAG(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
|
assert client.is_read_only is True
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_client_create_document_raises_when_read_only(self, temp_db_path):
|
|
"""Client.create_document() raises ReadOnlyError when read_only=True."""
|
|
async with HaikuRAG(temp_db_path, create=True):
|
|
pass
|
|
|
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
|
with pytest.raises(ReadOnlyError):
|
|
await client.create_document("test content")
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_client_delete_document_raises_when_read_only(self, temp_db_path):
|
|
"""Client.delete_document() raises ReadOnlyError when read_only=True."""
|
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
doc = await client.create_document("test content")
|
|
assert doc.id is not None
|
|
doc_id = doc.id
|
|
|
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
|
with pytest.raises(ReadOnlyError):
|
|
await client.delete_document(doc_id)
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_client_search_works_when_read_only(self, temp_db_path):
|
|
"""Client.search() works in read-only mode."""
|
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
await client.create_document("test content about cats")
|
|
|
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
|
results = await client.search("cats")
|
|
assert len(results) > 0
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_client_list_documents_works_when_read_only(self, temp_db_path):
|
|
"""Client.list_documents() works in read-only mode."""
|
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
await client.create_document("test content")
|
|
|
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
|
docs = await client.list_documents()
|
|
assert len(docs) == 1
|
|
|
|
|
|
class TestAppReadVerbsDoNotWrite:
|
|
@pytest.mark.asyncio
|
|
async def test_read_verb_leaves_settings_and_version_unchanged_on_drift(
|
|
self, temp_db_path
|
|
):
|
|
"""A read CLI verb opens read-only: drift warns but never writes."""
|
|
from haiku.rag.app import HaikuRAGApp
|
|
from haiku.rag.config import AppConfig
|
|
|
|
async with Store(temp_db_path, create=True) as store:
|
|
stored_name_before = (
|
|
await SettingsRepository(store).get_current_settings()
|
|
)["embeddings"]["model"]["name"]
|
|
version_before = await store.get_haiku_version()
|
|
|
|
drift = AppConfig()
|
|
drift.embeddings.model.name = "different-model"
|
|
|
|
# list is a read verb — must open read-only and not raise on drift
|
|
app = HaikuRAGApp(scope=for_path(temp_db_path, drift), config=drift)
|
|
await app.list_documents()
|
|
|
|
async with Store(temp_db_path, skip_validation=True, read_only=True) as store:
|
|
stored_name_after = (
|
|
await SettingsRepository(store).get_current_settings()
|
|
)["embeddings"]["model"]["name"]
|
|
version_after = await store.get_haiku_version()
|
|
|
|
assert stored_name_after == stored_name_before
|
|
assert version_after == version_before
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_works_with_mismatched_embeddings_config(self, temp_db_path):
|
|
"""list skips the embeddings compatibility check and never writes."""
|
|
from haiku.rag.app import HaikuRAGApp
|
|
from haiku.rag.config import AppConfig
|
|
|
|
async with Store(temp_db_path, create=True) as store:
|
|
await DocumentRepository(store).create(Document(content="test content"))
|
|
settings_before = await SettingsRepository(store).get_current_settings()
|
|
|
|
drift = AppConfig()
|
|
drift.embeddings.model.name = "different-model"
|
|
drift.embeddings.model.vector_dim = 4096
|
|
|
|
app = HaikuRAGApp(scope=for_path(temp_db_path, drift), config=drift)
|
|
await app.list_documents()
|
|
|
|
async with Store(temp_db_path, skip_validation=True, read_only=True) as store:
|
|
settings_after = await SettingsRepository(store).get_current_settings()
|
|
assert settings_after == settings_before
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_works_with_mismatched_embeddings_config(self, temp_db_path):
|
|
"""get skips the embeddings compatibility check."""
|
|
from haiku.rag.app import HaikuRAGApp
|
|
from haiku.rag.config import AppConfig
|
|
|
|
async with Store(temp_db_path, create=True) as store:
|
|
doc = await DocumentRepository(store).create(
|
|
Document(content="test content")
|
|
)
|
|
|
|
drift = AppConfig()
|
|
drift.embeddings.model.vector_dim = 4096
|
|
|
|
app = HaikuRAGApp(scope=for_path(temp_db_path, drift), config=drift)
|
|
assert doc.id is not None
|
|
await app.get_document(doc.id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_visualize_works_with_mismatched_embeddings_config(
|
|
self, temp_db_path
|
|
):
|
|
"""visualize skips the embeddings compatibility check."""
|
|
from haiku.rag.app import HaikuRAGApp
|
|
from haiku.rag.config import AppConfig
|
|
|
|
async with Store(temp_db_path, create=True):
|
|
pass
|
|
|
|
drift = AppConfig()
|
|
drift.embeddings.model.vector_dim = 4096
|
|
|
|
app = HaikuRAGApp(scope=for_path(temp_db_path, drift), config=drift)
|
|
await app.visualize_chunk("missing-chunk-id")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_verb_raises_on_drift_without_writing(self, temp_db_path):
|
|
"""A write CLI verb opens writable: drift raises before any write."""
|
|
from haiku.rag.app import HaikuRAGApp
|
|
from haiku.rag.config import AppConfig
|
|
from haiku.rag.store.exceptions import ConfigMismatchError
|
|
|
|
async with Store(temp_db_path, create=True) as store:
|
|
stored_name_before = (
|
|
await SettingsRepository(store).get_current_settings()
|
|
)["embeddings"]["model"]["name"]
|
|
|
|
drift = AppConfig()
|
|
drift.embeddings.model.name = "different-model"
|
|
|
|
app = HaikuRAGApp(scope=for_path(temp_db_path, drift), config=drift)
|
|
with pytest.raises(ConfigMismatchError):
|
|
await app.add_document_from_text("hello")
|
|
|
|
async with Store(temp_db_path, skip_validation=True, read_only=True) as store:
|
|
stored_name_after = (
|
|
await SettingsRepository(store).get_current_settings()
|
|
)["embeddings"]["model"]["name"]
|
|
|
|
assert stored_name_after == stored_name_before
|