haiku.rag/tests/test_settings.py
Yiorgis Gozadinos 2afe1bd28c
Cover store engine and repository paths
Add vector-index creation tests including the warned failure, chunk
repository get_by_id, list_all pagination, blank-query and precomputed-vector
search, the unknown-score-column guard, settings row recreation, and
replace_for_document with no items.
2026-07-26 19:36:37 +03:00

285 lines
11 KiB
Python

import logging
import pytest
from haiku.rag.config import AppConfig, Config
from haiku.rag.store.repositories.settings 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 = 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 = Config.processing.chunk_size
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
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 = 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"]
)