haiku.rag/tests/test_settings.py
Yiorgis Gozadinos 5c9639bbbb
Move ConfigMismatchError to the store exceptions module
The CLI reports this error, and importing it from the settings repository
pulled lancedb onto the CLI's import path. Deferring the import into cli()
bought nothing, since cli() runs on every invocation: it cost about 1.9
seconds on a cold start, `--help` included.

It now sits beside the other store exceptions, in a module that imports
nothing, and every importer points there.
2026-08-24 10:03:46 +03:00

285 lines
11 KiB
Python

import logging
import pytest
from haiku.rag.config import AppConfig, get_config
from haiku.rag.store.exceptions import ConfigMismatchError
@pytest.mark.asyncio
async def test_settings_table_populated_on_store_init(temp_db_path):
"""Test that settings table is populated with current config when store is initialized."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
async with Store(temp_db_path, create=True) as store:
settings_repo = SettingsRepository(store)
db_settings = await settings_repo.get_current_settings()
config_dict = get_config().model_dump(mode="json")
# Remove version from db_settings since it's added automatically
db_settings_without_version = {
k: v for k, v in db_settings.items() if k != "version"
}
assert db_settings_without_version == config_dict
@pytest.mark.asyncio
async def test_settings_save_and_retrieve(temp_db_path):
"""Test saving and retrieving settings after config change."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
async with Store(temp_db_path, create=True) as store:
settings_repo = SettingsRepository(store)
original_chunk_size = get_config().processing.chunk_size
get_config().processing.chunk_size = 2 * original_chunk_size
await settings_repo.save_current_settings()
retrieved_settings = await settings_repo.get_current_settings()
assert retrieved_settings["processing"]["chunk_size"] == 2 * original_chunk_size
get_config().processing.chunk_size = original_chunk_size
@pytest.mark.asyncio
async def test_set_haiku_version_recreates_row_from_store_config(temp_db_path):
"""Recreating a missing settings row stamps the store's own config, not the
process-global one."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
config = AppConfig()
config.processing.chunk_size = get_config().processing.chunk_size + 512
async with Store(temp_db_path, config=config, create=True) as store:
settings_repo = SettingsRepository(store)
await store.settings_table.delete("id = 'settings'")
assert await settings_repo.get_current_settings() == {}
assert await store.get_haiku_version() == "0.0.0"
await store.set_haiku_version("1.2.3")
recreated = await settings_repo.get_current_settings()
assert recreated["version"] == "1.2.3"
assert recreated["processing"]["chunk_size"] == config.processing.chunk_size
assert await store.get_haiku_version() == "1.2.3"
class TestValidateConfigCompatibility:
"""Tests for validate_config_compatibility method."""
@pytest.mark.asyncio
async def test_empty_settings_does_not_write(self, temp_db_path):
"""Validation never writes on open, even when the settings row is missing."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
async with Store(temp_db_path, create=True, skip_validation=True) as store:
settings_repo = SettingsRepository(store)
# Clear settings to simulate empty state
await store.settings_table.delete("id = 'settings'")
assert await settings_repo.get_current_settings() == {}
# Validation must not write — nothing to validate against
await settings_repo.validate_config_compatibility()
assert await settings_repo.get_current_settings() == {}
@pytest.mark.asyncio
async def test_compatible_config_no_error(self, temp_db_path):
"""Compatible config does not raise error."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
async with Store(temp_db_path, create=True) as store:
settings_repo = SettingsRepository(store)
# Should not raise - same config
await settings_repo.validate_config_compatibility()
@pytest.mark.asyncio
async def test_provider_drift_read_only_warns_without_writing(
self, temp_db_path, caplog, monkeypatch
):
"""Provider drift (vector_dim matches) on a read-only store warns and continues.
Same model served by a different stack (Ollama vs vLLM via openai-compat)
legitimately differs in `provider`. A read-only open surfaces the change
but must never modify the stored settings.
"""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
# haiku.rag.logging.get_logger() sets propagate=False on the
# `haiku.rag` logger. caplog's handler attaches to root by default,
# so without restoring propagation the records never reach it.
monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True)
async with Store(temp_db_path, create=True):
pass
new_config = AppConfig()
new_config.embeddings.model.provider = "openai"
async with Store(
temp_db_path, config=new_config, skip_validation=True, read_only=True
) as store2:
settings_repo = SettingsRepository(store2)
with caplog.at_level(logging.WARNING):
await settings_repo.validate_config_compatibility()
# Warning surfaced the change
assert any(
"provider" in r.getMessage()
and "ollama" in r.getMessage()
and "openai" in r.getMessage()
for r in caplog.records
)
# Stored settings are untouched
saved = await settings_repo.get_current_settings()
assert saved["embeddings"]["model"]["provider"] == "ollama"
@pytest.mark.asyncio
async def test_provider_drift_writable_raises_without_writing(
self, temp_db_path, caplog, monkeypatch
):
"""Provider drift on a writable store warns and raises, without writing."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import (
ConfigMismatchError,
SettingsRepository,
)
monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True)
async with Store(temp_db_path, create=True):
pass
new_config = AppConfig()
new_config.embeddings.model.provider = "openai"
async with Store(
temp_db_path, config=new_config, skip_validation=True
) as store2:
settings_repo = SettingsRepository(store2)
with caplog.at_level(logging.WARNING):
with pytest.raises(ConfigMismatchError):
await settings_repo.validate_config_compatibility()
assert any(
"provider" in r.getMessage()
and "ollama" in r.getMessage()
and "openai" in r.getMessage()
for r in caplog.records
)
# Stored settings are untouched despite the writable open
saved = await settings_repo.get_current_settings()
assert saved["embeddings"]["model"]["provider"] == "ollama"
@pytest.mark.asyncio
async def test_model_drift_read_only_warns_without_writing(
self, temp_db_path, caplog, monkeypatch
):
"""Model name drift (vector_dim matches) on a read-only store warns, no write."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True)
async with Store(temp_db_path, create=True):
pass
new_config = AppConfig()
new_config.embeddings.model.name = "different-model"
async with Store(
temp_db_path, config=new_config, skip_validation=True, read_only=True
) as store2:
settings_repo = SettingsRepository(store2)
with caplog.at_level(logging.WARNING):
await settings_repo.validate_config_compatibility()
assert any(
"model" in r.getMessage() and "different-model" in r.getMessage()
for r in caplog.records
)
saved = await settings_repo.get_current_settings()
assert saved["embeddings"]["model"]["name"] != "different-model"
@pytest.mark.asyncio
async def test_vector_dim_mismatch_raises_error(self, temp_db_path):
"""Different vector dimension raises ConfigMismatchError."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
# Create store with default config
async with Store(temp_db_path, create=True):
pass
# Create new config with different vector dimension
new_config = AppConfig()
new_config.embeddings.model.vector_dim = 9999
async with Store(
temp_db_path, config=new_config, skip_validation=True
) as store2:
settings_repo = SettingsRepository(store2)
with pytest.raises(ConfigMismatchError) as exc_info:
await settings_repo.validate_config_compatibility()
assert "vector dimension" in str(exc_info.value)
assert "9999" in str(exc_info.value)
@pytest.mark.asyncio
async def test_vector_dim_mismatch_raises_error_read_only(self, temp_db_path):
"""vector_dim mismatch raises even read-only — search cannot work."""
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
async with Store(temp_db_path, create=True):
pass
new_config = AppConfig()
new_config.embeddings.model.vector_dim = 9999
async with Store(
temp_db_path, config=new_config, skip_validation=True, read_only=True
) as store2:
settings_repo = SettingsRepository(store2)
with pytest.raises(ConfigMismatchError) as exc_info:
await settings_repo.validate_config_compatibility()
assert "9999" in str(exc_info.value)
@pytest.mark.asyncio
async def test_save_current_settings_recreates_a_deleted_row(temp_db_path):
from haiku.rag.store.engine import Store
from haiku.rag.store.repositories.settings import SettingsRepository
async with Store(temp_db_path, create=True, skip_validation=True) as store:
settings_repo = SettingsRepository(store)
await store.settings_table.delete("id = 'settings'")
assert await settings_repo.get_current_settings() == {}
await settings_repo.save_current_settings()
recreated = await settings_repo.get_current_settings()
assert (
recreated["embeddings"]
== store._config.model_dump(mode="json")["embeddings"]
)