diff --git a/docs/testing.md b/docs/testing.md index fd9e467..9668891 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -18,6 +18,8 @@ ├── conftest.py ├── conftest_factory.py ├── unit/ +│ ├── api/ +│ │ └── test_session_files.py # upload/download file endpoint logic │ ├── core/ │ │ ├── test_events.py # 17 tests │ │ ├── test_context_builder.py @@ -37,11 +39,12 @@ │ │ ├── test_terminal.py │ │ ├── test_share_file.py │ │ └── test_content_publish.py -│ └── test_content_store.py │ ├── profiles/ │ │ └── test_base.py -│ └── config/ +│ ├── config/ │ └── test_settings.py +│ ├── test_content_store.py +│ └── test_startup.py ├── integration/ │ ├── conftest.py │ ├── test_api_routes.py @@ -101,15 +104,17 @@ | 2 | `navi.memory.store` | 18 | ✅ Done | | 2 | `navi.memory.extractor` | 11 | ✅ Done | | 3 | `navi.api.routes` | 19 | ✅ Done | +| 3 | `navi.api.routes.sessions` file endpoint logic | 5 | ✅ Basic | | 3 | `navi.api.websocket` | 7 | ✅ Done | +| 3 | `navi.main` startup ordering | 1 | ✅ Basic | | 4 | `navi.core.agent` | 9 | ✅ Done | | 4 | `navi.core.planning` | 5 | ✅ Done | | 5 | `navi.tools.filesystem` | 13 | ✅ Done | | 5 | `navi.tools.code_exec` | 5 | ✅ Done | | 5 | `navi.tools.terminal` | 4 | ✅ Done | -| 5 | `navi.tools.share_file` | 4 | ✅ Basic | +| 5 | `navi.tools.share_file` | 5 | ✅ Basic | | 5 | `navi.tools.content_publish` | 4 | ✅ Basic | -| 5 | `navi.content_store` | 3 | ✅ Basic | +| 5 | `navi.content_store` | 5 | ✅ Basic | | 5 | `navi.llm.ollama` + fallback timeout wiring | 3 | ✅ Basic | | 6 | `webclient/api` | 8 | ✅ Done | | 6 | `webclient/stores/chat` | 23 | ✅ Done | @@ -132,14 +137,14 @@ | 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.main` startup | registries are initialized before `_check_embed()` so memory has an embedding backend | ⬜ 🔴 | +| 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 | ✅ | -| 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 | 🟨 | +| 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 | ✅ | ### Phase 8 — Agent Loop Behavior diff --git a/tests/unit/api/test_session_files.py b/tests/unit/api/test_session_files.py new file mode 100644 index 0000000..14561e9 --- /dev/null +++ b/tests/unit/api/test_session_files.py @@ -0,0 +1,103 @@ +"""Unit tests for session file API endpoint logic.""" + +import pytest +from fastapi import HTTPException + +import navi.api.routes.sessions as sessions_mod +import navi.session_files as session_files_mod +from navi.core.session import InMemorySessionStore + + +@pytest.fixture +async def store(): + session_store = InMemorySessionStore() + await session_store.create("secretary") + return session_store + + +@pytest.fixture(autouse=True) +def _session_files_dir(monkeypatch, tmp_path): + async def _to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(sessions_mod.asyncio, "to_thread", _to_thread) + monkeypatch.setattr(sessions_mod.settings, "session_files_dir", str(tmp_path / "sessions")) + monkeypatch.setattr(session_files_mod.settings, "session_files_dir", str(tmp_path / "sessions")) + monkeypatch.setattr(sessions_mod.settings, "session_files_max_size_mb", 1) + + +async def _session_id(store: InMemorySessionStore) -> str: + sessions = await store.list_all() + return sessions[0].id + + +class FakeUploadFile: + def __init__(self, name: str, data: bytes, content_type: str = "application/octet-stream") -> None: + self.filename = name + self.content_type = content_type + self._data = data + self._offset = 0 + + async def read(self, size: int = -1) -> bytes: + if self._offset >= len(self._data): + return b"" + if size < 0: + size = len(self._data) - self._offset + chunk = self._data[self._offset:self._offset + size] + self._offset += len(chunk) + return chunk + + +def _upload(name: str, data: bytes, content_type: str = "application/octet-stream") -> FakeUploadFile: + return FakeUploadFile(name, data, content_type) + + +async def test_upload_duplicate_filename_gets_numbered_name(store): + session_id = await _session_id(store) + + first = await sessions_mod.upload_file(session_id, _upload("report.txt", b"one"), store) + second = await sessions_mod.upload_file(session_id, _upload("report.txt", b"two"), store) + + assert first["name"] == "report.txt" + assert second["name"] == "report_1.txt" + + +async def test_upload_forbidden_extension_rejected(store): + session_id = await _session_id(store) + + with pytest.raises(HTTPException) as exc: + await sessions_mod.upload_file(session_id, _upload("run.sh", b"echo nope"), store) + + assert exc.value.status_code == 400 + assert "Executable files" in exc.value.detail + + +async def test_download_rejects_path_traversal(store): + session_id = await _session_id(store) + + with pytest.raises(HTTPException) as exc: + await sessions_mod.download_file(session_id, "../secret.txt", store) + + assert exc.value.status_code == 403 + + +async def test_download_browser_renderable_file_is_inline(store): + session_id = await _session_id(store) + dest = session_files_mod.ensure_session_dir(session_id) / "page.html" + dest.write_text("

Hello

") + + response = await sessions_mod.download_file(session_id, "page.html", store) + + assert response.media_type == "text/html" + assert "inline" in response.headers["content-disposition"] + + +async def test_download_archive_is_attachment(store): + session_id = await _session_id(store) + dest = session_files_mod.ensure_session_dir(session_id) / "bundle.zip" + dest.write_bytes(b"zip") + + response = await sessions_mod.download_file(session_id, "bundle.zip", store) + + assert response.media_type == "application/zip" + assert "attachment" in response.headers["content-disposition"] diff --git a/tests/unit/test_content_store.py b/tests/unit/test_content_store.py index 121800a..ec2db95 100644 --- a/tests/unit/test_content_store.py +++ b/tests/unit/test_content_store.py @@ -23,6 +23,24 @@ assert "CREATE UNIQUE INDEX IF NOT EXISTS idx_session_content_file" in executed assert "ON session_content (session_id, filename)" in executed + async def test_ensure_tables_is_idempotent(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() + await content_store.ensure_tables() + + unique_index_calls = [ + call for call in conn.calls + if call[0] == "execute" and "idx_session_content_file" in call[1] + ] + assert len(unique_index_calls) == 2 + assert all("IF NOT EXISTS" in call[1] for call in unique_index_calls) + class TestPublish: @pytest.fixture(autouse=True) @@ -55,6 +73,27 @@ 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_repeated_publish_uses_same_content_id_and_upsert(self, monkeypatch, tmp_path): + conn = FakeConnection() + conn.enqueue("INSERT 0 1") + 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("") + + first = await content_store.publish(session_id="sess-1", filename="logo.svg", title="Logo") + second = await content_store.publish(session_id="sess-1", filename="logo.svg", title="Logo 2") + + assert first["id"] == second["id"] == "sess-1_logo.svg" + execute_calls = [call for call in conn.calls if call[0] == "execute"] + assert len(execute_calls) == 2 + assert all("ON CONFLICT (session_id, filename) DO UPDATE" in call[1] for call in execute_calls) + async def test_missing_file_raises(self, monkeypatch, tmp_path): async def fake_pool(): return FakePool(FakeConnection()) diff --git a/tests/unit/test_startup.py b/tests/unit/test_startup.py new file mode 100644 index 0000000..a1cd04f --- /dev/null +++ b/tests/unit/test_startup.py @@ -0,0 +1,42 @@ +"""Unit tests for application startup wiring.""" + + +async def test_startup_initializes_registries_before_embed_check(monkeypatch): + import navi.api.deps as deps + import navi.api.routes.health as health_mod + import navi.content_store as content_store + import navi.main as main_mod + import navi.session_files as session_files + + order = [] + + async def fake_ensure_tables(): + order.append("ensure_tables") + + def fake_get_registries(): + order.append("get_registries") + return None + + async def fake_check_embed(): + order.append("check_embed") + return {"ok": True, "backend": "fake", "error": None} + + async def fake_cleanup_loop(store): + return None + + def fake_create_task(coro): + order.append("create_task") + coro.close() + return object() + + monkeypatch.setattr(content_store, "ensure_tables", fake_ensure_tables) + monkeypatch.setattr(deps, "get_registries", fake_get_registries) + monkeypatch.setattr(health_mod, "_check_embed", fake_check_embed) + monkeypatch.setattr(session_files, "cleanup_loop", fake_cleanup_loop) + monkeypatch.setattr(deps, "get_session_store", lambda: object()) + monkeypatch.setattr(main_mod.asyncio, "create_task", fake_create_task) + + await main_mod._on_startup() + + assert order[:3] == ["ensure_tables", "get_registries", "check_embed"] + assert order[-1] == "create_task" diff --git a/tests/unit/tools/test_share_file.py b/tests/unit/tools/test_share_file.py index 7b4156d..96491b0 100644 --- a/tests/unit/tools/test_share_file.py +++ b/tests/unit/tools/test_share_file.py @@ -64,3 +64,18 @@ "/sessions/sess%201/files/%D0%BE%D1%82%D1%87%D1%91%D1%82%20%231.txt" ) assert unquote(parsed.path).endswith("/sessions/sess 1/files/отчёт #1.txt") + + async def test_duplicate_filename_gets_numbered_copy(self, tool, tmp_path): + src = tmp_path / "source.txt" + src.write_text("new") + existing = tmp_path / "sessions" / "sess 1" / "report.txt" + existing.parent.mkdir(parents=True) + existing.write_text("old") + + result = await tool.execute({"path": str(src), "filename": "report.txt"}) + + assert result.success + assert existing.read_text() == "old" + numbered = tmp_path / "sessions" / "sess 1" / "report_1.txt" + assert numbered.read_text() == "new" + assert result.metadata["filename"] == "report_1.txt"