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 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)