diff --git a/docs/testing.md b/docs/testing.md index 97181cd..fd9e467 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,12 +22,12 @@ │ │ ├── test_events.py # 17 tests │ │ ├── test_context_builder.py │ │ ├── test_compressor.py -│ │ ├── test_registry.py # registries + context provider registry +│ │ ├── test_registry.py # registries, backend discovery, context provider registry │ │ ├── test_planning.py │ │ ├── test_agent_context_size.py │ │ └── test_agent_stream_guard.py │ ├── llm/ -│ │ └── test_ollama.py # timeout/error classification +│ │ └── test_ollama.py # timeout/error classification + fallback timeout wiring │ ├── memory/ │ │ ├── test_store.py │ │ └── test_extractor.py @@ -35,7 +35,9 @@ │ │ ├── test_filesystem.py │ │ ├── test_code_exec.py │ │ ├── test_terminal.py -│ │ └── test_share_file.py +│ │ ├── test_share_file.py +│ │ └── test_content_publish.py +│ └── test_content_store.py │ ├── profiles/ │ │ └── test_base.py │ └── config/ @@ -93,7 +95,7 @@ |-------|--------|-------|--------| | 1 | `navi.core.events` | 17 | ✅ Done | | 1 | `navi.core.compressor` | 14 | ✅ Done | -| 1 | `navi.core.registry` + `ContextProviderRegistry` | 12 | ✅ Done | +| 1 | `navi.core.registry` + `ContextProviderRegistry` | 13 | ✅ Done | | 1 | `navi.core.context_builder` | 9 | ✅ Done | | 1 | `navi.profiles.base` | 9 | ✅ Done | | 2 | `navi.memory.store` | 18 | ✅ Done | @@ -106,9 +108,9 @@ | 5 | `navi.tools.code_exec` | 5 | ✅ Done | | 5 | `navi.tools.terminal` | 4 | ✅ Done | | 5 | `navi.tools.share_file` | 4 | ✅ Basic | -| 5 | `navi.tools.content_publish` | 0 | ⬜ Planned | -| 5 | `navi.content_store` | 0 | ⬜ Planned | -| 5 | `navi.llm.ollama` | 2 | ✅ Basic | +| 5 | `navi.tools.content_publish` | 4 | ✅ Basic | +| 5 | `navi.content_store` | 3 | ✅ Basic | +| 5 | `navi.llm.ollama` + fallback timeout wiring | 3 | ✅ Basic | | 6 | `webclient/api` | 8 | ✅ Done | | 6 | `webclient/stores/chat` | 23 | ✅ Done | | 6 | `webclient/stores/sessions` | 6 | ✅ Done | @@ -130,12 +132,12 @@ | Priority | Area | Target tests | Status | |---|---|---|---| -| P0 | `navi.content_store.ensure_tables()` | creates `session_content`, creates `idx_session_content_file`, is idempotent when index already exists | ⬜ | -| P0 | `navi.content_store.publish()` | repeated publish of same `(session_id, filename)` updates one row instead of creating duplicates | ⬜ 🔴 | +| P0 | `navi.content_store.ensure_tables()` | creates `session_content`, creates `idx_session_content_file`, is idempotent when index already exists | 🟨 | +| P0 | `navi.content_store.publish()` | repeated publish of same `(session_id, filename)` updates one row instead of creating duplicates | 🟨 🔴 | | P0 | `navi.main` startup | registries are initialized before `_check_embed()` so memory has an embedding backend | ⬜ 🔴 | -| P0 | `navi.core.registry._discover_backends()` | primary Ollama backend receives HTTP timeout >= `LLM_COMPLETE_TIMEOUT` and `LLM_STREAM_FIRST_CHUNK_TIMEOUT` | ⬜ 🔴 | -| P0 | `navi.llm.fallback.FallbackOllamaBackend` | per-server `OllamaBackend` clients receive the same expanded timeout | ⬜ 🔴 | -| P1 | `navi.tools.content_publish` | missing file, directory instead of file, successful publish metadata, filename path stripping | ⬜ | +| P0 | `navi.core.registry._discover_backends()` | primary Ollama backend receives HTTP timeout >= `LLM_COMPLETE_TIMEOUT` and `LLM_STREAM_FIRST_CHUNK_TIMEOUT` | ✅ 🔴 | +| P0 | `navi.llm.fallback.FallbackOllamaBackend` | per-server `OllamaBackend` clients receive the same expanded timeout | ✅ 🔴 | +| P1 | `navi.tools.content_publish` | missing file, directory instead of file, successful publish metadata, filename path stripping | ✅ | | P1 | `navi.tools.share_file` | duplicate filename collision produces numbered output without overwrite | ⬜ | | P1 | `navi.api.routes.sessions` file endpoints | upload duplicate naming, forbidden extension, download path traversal, content disposition | 🟨 | diff --git a/tests/unit/core/test_registry.py b/tests/unit/core/test_registry.py index 0e65b52..bed9e98 100644 --- a/tests/unit/core/test_registry.py +++ b/tests/unit/core/test_registry.py @@ -80,6 +80,32 @@ assert sorted(reg.all_keys()) == ["a", "b"] +class TestBackendDiscovery: + def test_primary_ollama_uses_expanded_timeout(self, monkeypatch): + import navi.core.registry as registry_mod + + captured = {} + + class FakeOllamaBackend: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(registry_mod.settings, "ollama_backends_file", "") + monkeypatch.setattr(registry_mod.settings, "openai_api_key", "") + monkeypatch.setattr(registry_mod.settings, "ollama_request_timeout", 30) + monkeypatch.setattr(registry_mod.settings, "llm_complete_timeout", 120) + monkeypatch.setattr(registry_mod.settings, "llm_stream_first_chunk_timeout", 180) + + import navi.llm.ollama as ollama_mod + + monkeypatch.setattr(ollama_mod, "OllamaBackend", FakeOllamaBackend) + + discovered = registry_mod._discover_backends() + + assert discovered[0][0] == "ollama" + assert captured["timeout"] == 180 + + class TestContextProviderRegistry: def test_all_returns_registered_providers(self): reg = ContextProviderRegistry() diff --git a/tests/unit/llm/test_ollama.py b/tests/unit/llm/test_ollama.py index 10d6b00..e3ed91b 100644 --- a/tests/unit/llm/test_ollama.py +++ b/tests/unit/llm/test_ollama.py @@ -3,6 +3,7 @@ import httpx from navi.exceptions import LLMConnectionError +from navi.llm.fallback import FallbackOllamaBackend, ServerEntry from navi.llm.ollama import _classify_error @@ -17,3 +18,26 @@ assert isinstance(err, LLMConnectionError) assert str(err) == "ReadTimeout" + + +def test_fallback_client_uses_expanded_timeout(monkeypatch): + import navi.llm.fallback as fallback_mod + import navi.config as config_mod + + captured = {} + + class FakeOllamaBackend: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(fallback_mod, "OllamaBackend", FakeOllamaBackend) + monkeypatch.setattr(config_mod.settings, "ollama_request_timeout", 30) + monkeypatch.setattr(config_mod.settings, "llm_complete_timeout", 120) + monkeypatch.setattr(config_mod.settings, "llm_stream_first_chunk_timeout", 180) + + backend = FallbackOllamaBackend([ServerEntry(host="http://ollama.test")]) + backend._get_client(ServerEntry(host="http://ollama.test", api_key="secret")) + + assert captured["host"] == "http://ollama.test" + assert captured["api_key"] == "secret" + assert captured["timeout"] == 180 diff --git a/tests/unit/test_content_store.py b/tests/unit/test_content_store.py new file mode 100644 index 0000000..121800a --- /dev/null +++ b/tests/unit/test_content_store.py @@ -0,0 +1,65 @@ +"""Unit tests for session content metadata store.""" + +import pytest + +import navi.content_store as content_store +from tests.conftest_factory import FakeConnection, FakePool + + +class TestEnsureTables: + async def test_creates_session_content_table_and_indexes(self, monkeypatch): + conn = FakeConnection() + + async def fake_pool(): + return FakePool(conn) + + monkeypatch.setattr(content_store, "_get_db_pool", fake_pool) + + await content_store.ensure_tables() + + executed = "\n".join(call[1] for call in conn.calls if call[0] == "execute") + assert "CREATE TABLE IF NOT EXISTS session_content" in executed + assert "idx_session_content_session" in executed + assert "CREATE UNIQUE INDEX IF NOT EXISTS idx_session_content_file" in executed + assert "ON session_content (session_id, filename)" in executed + + +class TestPublish: + @pytest.fixture(autouse=True) + def _session_dir(self, monkeypatch, tmp_path): + monkeypatch.setattr(content_store.settings, "session_files_dir", str(tmp_path / "sessions")) + monkeypatch.setattr(content_store.settings, "public_url", "http://localhost:8000") + + async def test_upserts_by_session_and_filename(self, monkeypatch, tmp_path): + conn = FakeConnection() + conn.enqueue("INSERT 0 1") + + async def fake_pool(): + return FakePool(conn) + + monkeypatch.setattr(content_store, "_get_db_pool", fake_pool) + sess_dir = tmp_path / "sessions" / "sess-1" + sess_dir.mkdir(parents=True) + (sess_dir / "logo.svg").write_text("") + + info = await content_store.publish( + session_id="sess-1", + filename="logo.svg", + title="Logo", + ) + + assert info["id"] == "sess-1_logo.svg" + assert info["url"] == "http://localhost:8000/sessions/sess-1/files/logo.svg" + call = conn.calls[0] + assert call[0] == "execute" + assert "ON CONFLICT (session_id, filename) DO UPDATE" in call[1] + assert call[2][1:5] == ("sess-1", "logo.svg", "svg", "Logo") + + async def test_missing_file_raises(self, monkeypatch, tmp_path): + async def fake_pool(): + return FakePool(FakeConnection()) + + monkeypatch.setattr(content_store, "_get_db_pool", fake_pool) + + with pytest.raises(FileNotFoundError): + await content_store.publish(session_id="sess-1", filename="missing.svg") diff --git a/tests/unit/tools/test_content_publish.py b/tests/unit/tools/test_content_publish.py new file mode 100644 index 0000000..6471647 --- /dev/null +++ b/tests/unit/tools/test_content_publish.py @@ -0,0 +1,78 @@ +"""Unit tests for content_publish tool.""" + +import pytest + +import navi.tools.content_publish as content_publish_mod +from navi.tools.base import current_session_id +from navi.tools.content_publish import ContentPublishTool + + +class TestContentPublishTool: + @pytest.fixture + def tool(self, monkeypatch, tmp_path): + monkeypatch.setattr(content_publish_mod.settings, "session_files_dir", str(tmp_path / "sessions")) + token = current_session_id.set("sess-1") + try: + yield ContentPublishTool() + finally: + current_session_id.reset(token) + + async def test_requires_active_session(self): + token = current_session_id.set(None) + try: + result = await ContentPublishTool().execute({"filename": "logo.svg"}) + finally: + current_session_id.reset(token) + + assert not result.success + assert result.error == "no_session" + + async def test_missing_file_reports_session_dir(self, tool, tmp_path): + result = await tool.execute({"filename": "missing.svg"}) + + assert not result.success + assert result.error == "not_found" + assert str(tmp_path / "sessions" / "sess-1") in result.output + assert "SESSION_FILES_DIR" in result.output + + async def test_rejects_directory(self, tool, tmp_path): + sess_dir = tmp_path / "sessions" / "sess-1" + (sess_dir / "folder").mkdir(parents=True) + + result = await tool.execute({"filename": "folder"}) + + assert not result.success + assert result.error == "not_a_file" + + async def test_strips_path_components_and_publishes(self, tool, monkeypatch, tmp_path): + sess_dir = tmp_path / "sessions" / "sess-1" + sess_dir.mkdir(parents=True) + (sess_dir / "logo.svg").write_text("") + calls = [] + + async def fake_publish(**kwargs): + calls.append(kwargs) + return { + "id": "content-id", + "url": "http://localhost:8000/sessions/sess-1/files/logo.svg", + "filename": kwargs["filename"], + "content_type": kwargs["content_type"] or "svg", + "title": kwargs["title"] or kwargs["filename"], + } + + monkeypatch.setattr(content_publish_mod, "publish", fake_publish) + + result = await tool.execute({ + "filename": "../logo.svg", + "title": "Logo", + "content_type": "svg", + }) + + assert result.success + assert calls == [{ + "session_id": "sess-1", + "filename": "logo.svg", + "title": "Logo", + "content_type": "svg", + }] + assert result.metadata["filename"] == "logo.svg"