"""Tests for BooksLM — directory context collection, caching, and API routes.""" import asyncio import json import os import shutil import tempfile from pathlib import Path import pytest # ── Unit tests: collect_directory_context ────────────────────────────── class TestCollectDirectoryContext: """Tests for collect_directory_context().""" def test_basic_collection(self, tmp_path): """Collect .md files from a simple directory.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" vault.mkdir() (vault / "note1.md").write_text("# Note 1\nHello world", encoding="utf-8") (vault / "note2.md").write_text("# Note 2\nGoodbye world", encoding="utf-8") (vault / "notemd.txt").write_text("Not markdown", encoding="utf-8") result = collect_directory_context(vault, "") assert result["file_count"] == 2 assert result["total_chars"] > 0 paths = [f["path"] for f in result["files"]] assert "note1.md" in paths assert "note2.md" in paths assert "notemd.txt" not in paths def test_subdirectory_collection(self, tmp_path): """Collect files recursively from subdirectories.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" subdir = vault / "projects" / "code" subdir.mkdir(parents=True) (subdir / "readme.md").write_text("# Code project", encoding="utf-8") (vault / "root.md").write_text("# Root", encoding="utf-8") result = collect_directory_context(vault, "projects") assert result["file_count"] == 1 assert result["files"][0]["path"] == "projects/code/readme.md" def test_hidden_files_skipped(self, tmp_path): """Hidden files and special directories are skipped.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" vault.mkdir() (vault / ".hidden.md").write_text("Hidden", encoding="utf-8") (vault / ".obsidian").mkdir() (vault / ".obsidian" / "config.md").write_text("Config", encoding="utf-8") (vault / "visible.md").write_text("Visible", encoding="utf-8") result = collect_directory_context(vault, "") assert result["file_count"] == 1 assert result["files"][0]["path"] == "visible.md" def test_attachments_skipped(self, tmp_path): """_attachments/ directory is skipped.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" attach = vault / "_attachments" attach.mkdir(parents=True) (attach / "image.md").write_text("Image doc", encoding="utf-8") (vault / "real.md").write_text("Real content", encoding="utf-8") result = collect_directory_context(vault, "") assert result["file_count"] == 1 assert result["files"][0]["path"] == "real.md" def test_max_files_limit(self, tmp_path): """Respects BOOKSLM_MAX_FILES limit.""" from backend.bookslm import collect_directory_context import backend.bookslm as bookslm_mod vault = tmp_path / "vault" vault.mkdir() old_max = bookslm_mod.BOOKSLM_MAX_FILES bookslm_mod.BOOKSLM_MAX_FILES = 3 try: for i in range(10): (vault / f"note{i}.md").write_text(f"Content {i}", encoding="utf-8") result = collect_directory_context(vault, "") assert result["file_count"] == 3 finally: bookslm_mod.BOOKSLM_MAX_FILES = old_max def test_max_file_chars_truncation(self, tmp_path): """Files exceeding max chars are truncated with marker.""" from backend.bookslm import collect_directory_context import backend.bookslm as bookslm_mod vault = tmp_path / "vault" vault.mkdir() old_max = bookslm_mod.BOOKSLM_MAX_FILE_CHARS bookslm_mod.BOOKSLM_MAX_FILE_CHARS = 50 try: long_content = "x" * 200 (vault / "long.md").write_text(long_content, encoding="utf-8") result = collect_directory_context(vault, "") assert result["file_count"] == 1 assert "[... tronqué]" in result["files"][0]["content"] assert len(result["files"][0]["content"]) <= 50 + 50 # truncated + marker finally: bookslm_mod.BOOKSLM_MAX_FILE_CHARS = old_max def test_max_total_chars_limit(self, tmp_path): """Stops collecting when total chars limit is reached.""" from backend.bookslm import collect_directory_context import backend.bookslm as bookslm_mod vault = tmp_path / "vault" vault.mkdir() old_total = bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS old_file = bookslm_mod.BOOKSLM_MAX_FILE_CHARS bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS = 100 bookslm_mod.BOOKSLM_MAX_FILE_CHARS = 10000 try: for i in range(10): (vault / f"f{i}.md").write_text("a" * 50, encoding="utf-8") result = collect_directory_context(vault, "") # Should not collect all 10 files (10 * 50 = 500 > 100) assert result["total_chars"] <= 100 + 50 # some margin for truncation marker finally: bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS = old_total bookslm_mod.BOOKSLM_MAX_FILE_CHARS = old_file def test_readme_index_priority(self, tmp_path): """README and index files come first in results.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" vault.mkdir() (vault / "aaa.md").write_text("# AAA", encoding="utf-8") (vault / "README.md").write_text("# README", encoding="utf-8") (vault / "index.md").write_text("# Index", encoding="utf-8") (vault / "zzz.md").write_text("# ZZZ", encoding="utf-8") result = collect_directory_context(vault, "") paths = [f["path"] for f in result["files"]] # README and index should be before other files readme_idx = paths.index("README.md") index_idx = paths.index("index.md") aaa_idx = paths.index("aaa.md") assert readme_idx < aaa_idx assert index_idx < aaa_idx def test_empty_directory(self, tmp_path): """Empty directory returns empty result.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" vault.mkdir() result = collect_directory_context(vault, "") assert result["file_count"] == 0 assert result["total_chars"] == 0 assert result["files"] == [] def test_nonexistent_directory(self, tmp_path): """Nonexistent directory returns empty result.""" from backend.bookslm import collect_directory_context result = collect_directory_context(tmp_path, "nonexistent") assert result["file_count"] == 0 def test_title_generation(self, tmp_path): """File titles are derived from stem with proper casing.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" vault.mkdir() (vault / "my-cool-note.md").write_text("Content", encoding="utf-8") result = collect_directory_context(vault, "") assert result["files"][0]["title"] == "My Cool Note" def test_directory_tree(self, tmp_path): """Directory tree is included in result.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" (vault / "sub").mkdir(parents=True) (vault / "sub" / "file.md").write_text("Content", encoding="utf-8") (vault / "root.md").write_text("Root", encoding="utf-8") result = collect_directory_context(vault, "") tree = result["directory_tree"] assert "root.md" in tree assert "sub/" in tree assert "file.md" in tree # ── Unit tests: build_system_prompt ──────────────────────────────────── class TestBuildSystemPrompt: """Tests for build_system_prompt().""" def test_basic_prompt(self): """Prompt contains expected sections.""" from backend.bookslm import build_system_prompt context = { "files": [ {"path": "note.md", "title": "My Note", "content": "# Hello", "type": "markdown"}, ], "total_chars": 7, "file_count": 1, "directory_tree": "note.md", } prompt = build_system_prompt(context) assert "assistant de recherche" in prompt assert "note.md" in prompt assert "My Note" in prompt assert "# Hello" in prompt assert "Cite tes sources" in prompt def test_empty_context(self): """Empty context still produces valid prompt.""" from backend.bookslm import build_system_prompt context = {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""} prompt = build_system_prompt(context) assert "0 fichier" in prompt def test_token_warning(self): """Large context triggers token warning.""" from backend.bookslm import build_system_prompt context = { "files": [ {"path": "big.md", "title": "Big", "content": "x" * 500000, "type": "markdown"}, ], "total_chars": 500000, "file_count": 1, "directory_tree": "big.md", } prompt = build_system_prompt(context) assert "⚠️" in prompt or "volumineux" in prompt def test_vault_name_guidance(self): """BUG-046: naming the vault steers write tools away from invented names.""" from backend.bookslm import build_system_prompt context = { "files": [{"path": "note.md", "title": "N", "content": "x", "type": "markdown"}], "total_chars": 1, "file_count": 1, "directory_tree": "", } prompt = build_system_prompt(context, scope="documents", vault_name="TestVault") assert "TestVault" in prompt assert "append_to_file" in prompt assert "jamais un nom inventé" in prompt # Without a vault name the block stays out (general fallback prompts). assert "jamais un nom inventé" not in build_system_prompt(context) # ── Unit tests: caching ──────────────────────────────────────────────── class TestCaching: """Tests for cache behavior.""" def test_cache_hit(self, tmp_path): """Second call with same data returns cached result.""" from backend.bookslm import collect_directory_context, _cache _cache.clear() vault = tmp_path / "vault" vault.mkdir() (vault / "note.md").write_text("Content", encoding="utf-8") result1 = collect_directory_context(vault, "") result2 = collect_directory_context(vault, "") assert result1["file_count"] == result2["file_count"] assert result1["total_chars"] == result2["total_chars"] def test_cache_invalidation_on_change(self, tmp_path): """Cache is invalidated when file content changes.""" from backend.bookslm import collect_directory_context, _cache _cache.clear() vault = tmp_path / "vault" vault.mkdir() (vault / "note.md").write_text("Original", encoding="utf-8") result1 = collect_directory_context(vault, "") assert result1["file_count"] == 1 # Modify file (change mtime) import time time.sleep(0.1) (vault / "note.md").write_text("Modified content", encoding="utf-8") result2 = collect_directory_context(vault, "") assert result2["files"][0]["content"] == "Modified content" def test_invalidate_cache(self, tmp_path): """invalidate_cache clears the cache.""" from backend.bookslm import collect_directory_context, invalidate_cache, _cache _cache.clear() vault = tmp_path / "vault" vault.mkdir() (vault / "note.md").write_text("Content", encoding="utf-8") collect_directory_context(vault, "") assert len(_cache) > 0 count = invalidate_cache() assert count > 0 assert len(_cache) == 0 # ── Unit tests: secret redaction ────────────────────────────────────── class TestRedaction: """Verify that secrets are redacted in collected content.""" def test_secrets_are_redacted(self, tmp_path): """API keys in file content are redacted.""" from backend.bookslm import collect_directory_context vault = tmp_path / "vault" vault.mkdir() # The secret redactor looks for patterns like sk-..., AKIA..., etc. (vault / "secrets.md").write_text( "Config: api_key=AKIA1234567890ABCDEF and sk-abcdefghijklmnopqrstuvwxyz01234567890", encoding="utf-8", ) result = collect_directory_context(vault, "") # The redactor should have processed this file content = result["files"][0]["content"] # At minimum the file should be collected (redaction is best-effort) assert result["file_count"] == 1 # ── Integration tests: API endpoints ────────────────────────────────── @pytest.fixture def bookslm_client(): """Create a TestClient with auth enabled, isolated temp data.""" tmp = Path(tempfile.mkdtemp()) data_dir = tmp / "data" data_dir.mkdir() # Create a test vault with some files test_vault = tmp / "test-vault" test_vault.mkdir() (test_vault / "README.md").write_text("# Test Vault\nWelcome to the test vault.", encoding="utf-8") (test_vault / "notes").mkdir() (test_vault / "notes" / "note1.md").write_text("# Note 1\nFirst note content.", encoding="utf-8") (test_vault / "notes" / "note2.md").write_text("# Note 2\nSecond note content.", encoding="utf-8") from backend.auth.password import hash_password pw_hash = hash_password("TestPass123!") users = { "version": 1, "users": { "testuser": { "id": "testuser-1", "username": "testuser", "display_name": "Test User", "password_hash": pw_hash, "role": "admin", "vaults": ["*"], "active": True, "created_at": "2026-01-01T00:00:00", } } } (data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8") src_secret = Path("data/secret.key") if src_secret.exists(): shutil.copy2(str(src_secret), str(data_dir / "secret.key")) orig_cwd = os.getcwd() os.chdir(str(tmp)) os.environ["VAULT_1_NAME"] = "TestVault" os.environ["VAULT_1_PATH"] = str(test_vault) os.environ["OBSIGATE_AUTH_ENABLED"] = "true" os.environ["OBSIGATE_ADMIN_USER"] = "testuser" os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!" os.environ["OBSIGATE_WATCHER_ENABLED"] = "false" import backend.main backend.main._load_config = lambda: {"watcher_enabled": False} from backend.main import app from backend.indexer import build_index, index for key in list(index.keys()): del index[key] loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(build_index()) from backend.search import init_inverted_index init_inverted_index() from fastapi.testclient import TestClient client = TestClient(app) yield client if hasattr(client, 'close'): client.close() loop.run_until_complete(asyncio.sleep(0)) os.chdir(orig_cwd) shutil.rmtree(str(tmp), ignore_errors=True) for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED", "OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]: os.environ.pop(k, None) def _login_bookslm(client, username="testuser", password="TestPass123!"): resp = client.post("/api/auth/login", json={"username": username, "password": password}) return resp.json().get("access_token"), resp class TestBooksLMContextEndpoint: """Tests for POST /api/ai/bookslm/context.""" def test_context_returns_files(self, bookslm_client): """Context endpoint returns files from the directory.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"vault": "TestVault", "directory": "notes"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["file_count"] == 2 paths = [f["path"] for f in data["files"]] assert "notes/note1.md" in paths assert "notes/note2.md" in paths def test_context_root_directory(self, bookslm_client): """Context endpoint works for root directory.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"vault": "TestVault", "directory": ""}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["file_count"] >= 1 # At least README.md def test_context_requires_auth(self, bookslm_client): """Context endpoint requires authentication.""" resp = bookslm_client.post( "/api/ai/bookslm/context", json={"vault": "TestVault", "directory": ""}, ) assert resp.status_code == 401 def test_context_vault_not_found(self, bookslm_client): """Context endpoint returns 404 for unknown vault.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"vault": "NonExistent", "directory": ""}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 def test_context_nonexistent_directory(self, bookslm_client): """Context endpoint returns empty for nonexistent directory.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"vault": "TestVault", "directory": "nonexistent"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["file_count"] == 0 class TestBooksLMChatEndpoint: """Tests for POST /api/ai/chat.""" def test_chat_requires_auth(self, bookslm_client): """Chat endpoint requires authentication.""" resp = bookslm_client.post( "/api/ai/bookslm/chat", json={"vault": "TestVault", "directory": "", "message": "Hello"}, ) assert resp.status_code == 401 def test_chat_vault_not_found(self, bookslm_client): """Chat endpoint returns 404 for unknown vault.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={"vault": "NonExistent", "directory": "", "message": "Hello"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 def test_chat_empty_directory_does_not_block(self, bookslm_client): """Chat on an empty directory must not 404 — it answers without context.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={"vault": "TestVault", "directory": "nonexistent", "message": "Hello"}, headers={"Authorization": f"Bearer {token}"}, ) # 200 (stream opened) or 500 (no AI key) — never 404/422. assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}" assert resp.status_code != 404 def test_chat_request_accepts_provider_and_model(self, bookslm_client): """The chat endpoint schema accepts provider + model fields without rejecting. We don't actually call the AI (would need a live key) — we just verify the request schema is wired correctly so a missing key is the only failure mode, not a 422 validation error. """ token, _ = _login_bookslm(bookslm_client) # Build a tiny valid directory so the chat endpoint doesn't 404. from pathlib import Path vault_dir = Path(os.environ.get("VAULT_1_PATH", "test-vault")) sub = vault_dir / "for_chat_test" sub.mkdir(exist_ok=True) (sub / "note.md").write_text("# hello\n", encoding="utf-8") try: resp = bookslm_client.post( "/api/ai/bookslm/chat", json={ "vault": "TestVault", "directory": "for_chat_test", "message": "ping", "provider": "deepseek", "model": "deepseek-chat", }, headers={"Authorization": f"Bearer {token}"}, ) # Either 200 (if a real key is configured) or 500 (no key / quota). # Must NOT be 422 — the schema must accept the fields. assert resp.status_code in (200, 500), ( f"unexpected status {resp.status_code}: {resp.text[:200]}" ) assert resp.status_code != 422, ( f"schema rejected provider/model fields: {resp.text[:300]}" ) finally: import shutil shutil.rmtree(sub, ignore_errors=True) # ── Unit tests: explicit documents context + general prompt ──────────── class TestCollectFilesContext: """Tests for collect_files_context() (Documents mode).""" def test_collects_only_requested_files(self, tmp_path): from backend.bookslm import collect_files_context vault = tmp_path / "vault" (vault / "sub").mkdir(parents=True) (vault / "a.md").write_text("# A", encoding="utf-8") (vault / "b.md").write_text("# B", encoding="utf-8") (vault / "sub" / "c.md").write_text("# C", encoding="utf-8") result = collect_files_context(vault, ["a.md", "sub/c.md"], scope="documents") assert result["scope"] == "documents" assert result["file_count"] == 2 assert [f["path"] for f in result["files"]] == ["a.md", "sub/c.md"] assert result["directory_tree"] == "" def test_ignores_paths_outside_vault(self, tmp_path): from backend.bookslm import collect_files_context vault = tmp_path / "vault" vault.mkdir() (vault / "ok.md").write_text("# ok", encoding="utf-8") outside = tmp_path / "outside.md" outside.write_text("# secret", encoding="utf-8") result = collect_files_context(vault, ["../outside.md", "ok.md"]) assert result["file_count"] == 1 assert result["files"][0]["path"] == "ok.md" def test_deduplicates_paths(self, tmp_path): from backend.bookslm import collect_files_context vault = tmp_path / "vault" vault.mkdir() (vault / "dup.md").write_text("# dup", encoding="utf-8") result = collect_files_context(vault, ["dup.md", "dup.md"]) assert result["file_count"] == 1 def test_empty_context(self): from backend.bookslm import empty_context ctx = empty_context("general") assert ctx["file_count"] == 0 assert ctx["files"] == [] assert ctx["scope"] == "general" class TestGeneralPrompt: def test_general_prompt_lists_vaults(self): from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt(["Alpha", "Beta"]) assert "Alpha" in prompt assert "Beta" in prompt assert "obsigate-action" in prompt def test_general_prompt_includes_app_context(self): """The General assistant is told what the user is currently doing.""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt( ["Alpha"], app_context={ "vault": "Alpha", "directory": "notes", "current_path": "notes/a.md", "open_documents": [{"vault": "Alpha", "path": "notes/a.md"}], "search_query": "auth", "search_total": 3, "search_results": [{"vault": "Alpha", "path": "notes/auth.md"}], }, recent_files=[{"vault": "Alpha", "path": "notes/recent.md"}], ) assert "Contexte applicatif actuel" in prompt assert "notes/a.md" in prompt assert "notes/recent.md" in prompt assert "auth" in prompt def test_general_prompt_includes_edited_document(self): """#93 — the live edition session is announced to the assistant.""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt( ["Alpha"], app_context={ "current_path": "notes/a.md", "editing": {"vault": "Alpha", "path": "notes/a.md", "surface": "editor"}, }, ) assert "Document en cours d'édition" in prompt assert "notes/a.md" in prompt assert "edit_file" in prompt def test_general_prompt_marks_forge_surface(self): """#93 — Forge (iframe) is distinguished from the CodeMirror editor.""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt( ["Alpha"], app_context={"editing": {"vault": "Alpha", "path": "notes/a.md", "surface": "forge"}}, ) assert "dans Forge" in prompt assert "Document en cours d'édition" in prompt def test_general_prompt_ignores_malformed_editing(self): """#93 — a malformed `editing` payload must not break the prompt.""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt( ["Alpha"], app_context={"editing": {"vault": "Alpha"}}, ) assert "Document en cours d'édition" not in prompt def test_general_prompt_without_app_context_is_unchanged(self): """No context → no app-context block (backwards compatible).""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt(["Alpha"]) assert "Contexte applicatif actuel" not in prompt def test_general_prompt_agent_uses_native_tools(self): """BUG-053: the agent must call tools, not emit `obsigate-action` blocks.""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt(["Alpha"], agent=True) assert "create_file" in prompt # The text-protocol example block must not be taught in agent mode. assert '"action": "create_file"' not in prompt assert "inclus un bloc de ce type" not in prompt def test_general_prompt_classic_keeps_text_protocol(self): """The classic chat endpoint still uses the text action protocol.""" from backend.bookslm import build_general_system_prompt prompt = build_general_system_prompt(["Alpha"]) assert '"action": "create_file"' in prompt def test_documents_prompt_scope(self): from backend.bookslm import build_system_prompt context = { "files": [{"path": "a.md", "title": "A", "content": "hello"}], "file_count": 1, "total_chars": 5, "directory_tree": "", } prompt = build_system_prompt(context, scope="documents") assert "Documents ouverts" in prompt assert "hello" in prompt class TestBooksLMContextModes: """Tests for the mode-aware /context endpoint.""" def test_general_mode_needs_no_vault(self, bookslm_client): token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"mode": "general"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["scope"] == "general" assert data["file_count"] == 0 assert data["files"] == [] def test_documents_mode_returns_selected_files(self, bookslm_client): token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={ "mode": "documents", "vault": "TestVault", "context_files": ["notes/note1.md"], }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["scope"] == "documents" assert data["file_count"] == 1 assert data["files"][0]["path"] == "notes/note1.md" def test_documents_mode_without_files_degrades_to_general(self, bookslm_client): token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"mode": "documents", "vault": "TestVault", "context_files": []}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["scope"] == "general" assert data["file_count"] == 0 def test_documents_mode_requires_vault(self, bookslm_client): token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"mode": "documents", "context_files": ["notes/note1.md"]}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 400 def test_unknown_mode_falls_back_to_directory(self, bookslm_client): token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/context", json={"mode": "bogus", "vault": "TestVault", "directory": "notes"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["scope"] == "directory" class TestBooksLMChatModes: def test_general_chat_does_not_require_vault(self, bookslm_client): """General mode must not 400/404 on a missing vault (only 500 if no key).""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={"mode": "general", "message": "Bonjour"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}" assert resp.status_code != 422 def test_documents_chat_accepts_context_files(self, bookslm_client): token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={ "mode": "documents", "vault": "TestVault", "context_files": ["notes/note1.md"], "message": "Résume", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}" assert resp.status_code != 422 def test_general_chat_includes_adhoc_files(self, bookslm_client, monkeypatch): """`@` ad-hoc files must be read even in General mode (BUG-008).""" import backend.bookslm_routes as routes captured = {} async def fake_stream(messages, **kwargs): captured["messages"] = messages yield "ok" monkeypatch.setattr(routes, "stream_completion", fake_stream) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={ "mode": "general", "vault": "TestVault", "extra_files": ["notes/note1.md"], "message": "Résume", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text[:200] system_prompt = captured["messages"][0]["content"] assert "First note content" in system_prompt, "ad-hoc file content missing from prompt" def test_general_chat_without_adhoc_ignores_vault(self, bookslm_client, monkeypatch): """General mode without ad-hoc context stays vault-independent.""" import backend.bookslm_routes as routes captured = {} async def fake_stream(messages, **kwargs): captured["messages"] = messages yield "ok" monkeypatch.setattr(routes, "stream_completion", fake_stream) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={"mode": "general", "vault": "UnknownVault", "message": "Bonjour"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text[:200] assert captured["messages"][0]["content"] # ── Tool-calling agent endpoint ─────────────────────────────────────── class TestBooksLMAgentEndpoint: """Tests for POST /api/ai/bookslm/agent (Phase B).""" def test_agent_requires_auth(self, bookslm_client): resp = bookslm_client.post( "/api/ai/bookslm/agent", json={"vault": "TestVault", "directory": "", "message": "Hello"}, ) assert resp.status_code == 401 def test_agent_prompt_uses_native_tools_not_text_protocol(self, bookslm_client, monkeypatch): """BUG-053: the agent system prompt must not teach the text protocol.""" import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse captured = {} async def fake_chat_completion(messages, **kwargs): captured["system"] = messages[0]["content"] return LLMResponse(content="ok") monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/agent", json={"directory": "", "message": "cree un fichier", "mode": "general"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert "create_file" in captured["system"] assert '"action": "create_file"' not in captured["system"] def test_agent_llm_uses_resolved_provider(self, bookslm_client, monkeypatch): """#187: the agent loop must call the provider resolved by _resolve_provider_name (not the raw request value), so the provider tag reported by SSE matches the engine that actually answered.""" import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse seen = {} async def fake_chat_completion(messages, **kwargs): seen["provider"] = kwargs.get("provider") return LLMResponse(content="ok") monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/agent", json={"directory": "", "message": "salut", "mode": "general", "provider": "nope"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert seen["provider"] == "openrouter" def test_agent_tool_call_flow(self, bookslm_client, monkeypatch): import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse, ToolCall responses = [ LLMResponse(tool_calls=[ToolCall(id="1", name="list_directory", arguments={"vault": "TestVault"})]), LLMResponse(content="Il y a des fichiers."), ] async def fake_chat_completion(messages, **kwargs): return responses.pop(0) monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/agent", json={"vault": "TestVault", "directory": "", "message": "liste les fichiers", "mode": "directory"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 body = resp.text assert "event: tool" in body assert "list_directory" in body assert "event: message" in body assert "Il y a des fichiers." in body def test_agent_pauses_for_confirmation(self, bookslm_client, monkeypatch): import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse, ToolCall from backend.tools import registry from backend.tools.api import ToolRisk from backend.tools.registry import ToolSpec from backend.tools.schemas import ListVaultsInput spec = ToolSpec( name="_agent_write", description="write for tests", input_model=ListVaultsInput, handler=lambda ctx, params: {"ok": True}, risk=ToolRisk.WRITE, ) monkeypatch.setitem(registry._REGISTRY, "_agent_write", spec) responses = [LLMResponse(tool_calls=[ToolCall(id="1", name="_agent_write", arguments={})])] async def fake_chat_completion(messages, **kwargs): return responses.pop(0) monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/agent", json={"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert "event: confirmation" in resp.text assert "_agent_write" in resp.text def test_agent_confirmation_resume(self, bookslm_client, monkeypatch): import re import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse, ToolCall from backend.tools import registry from backend.tools.api import ToolRisk from backend.tools.registry import ToolSpec from backend.tools.schemas import ListVaultsInput executed = {} def handler(ctx, params): executed["ran"] = True return {"ok": True} spec = ToolSpec( name="_agent_write_resume", description="write for tests", input_model=ListVaultsInput, handler=handler, risk=ToolRisk.WRITE, ) monkeypatch.setitem(registry._REGISTRY, "_agent_write_resume", spec) responses = [LLMResponse(tool_calls=[ToolCall(id="c1", name="_agent_write_resume", arguments={})])] async def fake_chat_completion(messages, **kwargs): return responses.pop(0) monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) payload = {"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"} resp = bookslm_client.post( "/api/ai/bookslm/agent", json=payload, headers={"Authorization": f"Bearer {token}"}, ) assert "event: confirmation" in resp.text assert "_agent_write_resume" in resp.text assert executed.get("ran") is not True match = re.search(r"event: confirmation\ndata: (.*)", resp.text) assert match, resp.text confirmation = json.loads(match.group(1)) assert confirmation["pending"]["error"]["tool"] == "_agent_write_resume" # Resume: apply the pending call, then the model answers. responses.append(LLMResponse(content="C'est fait.")) resume_payload = dict(payload, confirm=confirmation["pending"], confirm_messages=confirmation["messages"]) resp2 = bookslm_client.post( "/api/ai/bookslm/agent", json=resume_payload, headers={"Authorization": f"Bearer {token}"}, ) assert resp2.status_code == 200 assert executed.get("ran") is True assert "C'est fait." in resp2.text assert "event: message" in resp2.text def test_agent_confirmation_batches_actions_and_confirm_all(self, bookslm_client, monkeypatch): """BUG-075: one confirmation lists every mutating call of the turn and ``confirm_all`` authorizes the rest of the run without pausing again.""" import re import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse, ToolCall from backend.tools import registry from backend.tools.api import ToolRisk from backend.tools.registry import ToolSpec from backend.tools.schemas import ListVaultsInput calls = [] def handler(ctx, params): calls.append(params) return {"ok": True} spec = ToolSpec( name="_agent_write_batch", description="write for tests", input_model=ListVaultsInput, handler=handler, risk=ToolRisk.WRITE, ) monkeypatch.setitem(registry._REGISTRY, "_agent_write_batch", spec) responses = [LLMResponse(tool_calls=[ ToolCall(id="c1", name="_agent_write_batch", arguments={}), ToolCall(id="c2", name="_agent_write_batch", arguments={}), ])] async def fake_chat_completion(messages, **kwargs): return responses.pop(0) monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") token, _ = _login_bookslm(bookslm_client) payload = {"vault": "TestVault", "directory": "", "message": "crée tout", "mode": "directory"} resp = bookslm_client.post( "/api/ai/bookslm/agent", json=payload, headers={"Authorization": f"Bearer {token}"}, ) assert "event: confirmation" in resp.text match = re.search(r"event: confirmation\ndata: (.*)", resp.text) assert match, resp.text confirmation = json.loads(match.group(1)) # Both mutating calls are pending, not one. assert [a["tool"] for a in confirmation["pending"]["actions"]] == [ "_agent_write_batch", "_agent_write_batch", ] assert calls == [], "nothing runs before the approval" # Resume with a global approval: the two pending calls run, and a third # mutating call of the same run runs without a second confirmation. responses.append(LLMResponse(tool_calls=[ToolCall(id="c3", name="_agent_write_batch", arguments={})])) responses.append(LLMResponse(content="Terminé.")) resume_payload = dict( payload, confirm=confirmation["pending"], confirm_messages=confirmation["messages"], confirm_all=True, ) resp2 = bookslm_client.post( "/api/ai/bookslm/agent", json=resume_payload, headers={"Authorization": f"Bearer {token}"}, ) assert resp2.status_code == 200 assert "event: confirmation" not in resp2.text, "confirm_all must not pause again" assert len(calls) == 3, "the whole plan ran in one approval" assert "Terminé." in resp2.text def test_agent_message_reports_effective_model(self, bookslm_client, monkeypatch): """The SSE payload carries the model really used, not the raw request. The client may leave `model` empty (provider default) — reporting it verbatim left the "provider · model" tag in the UI with the provider only. """ import re import backend.bookslm_routes as routes from backend.ai import PROVIDERS from backend.ai_chat import LLMResponse async def fake_chat_completion(messages, **kwargs): return LLMResponse(content="Bonjour.") monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter") token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/agent", json={"vault": "TestVault", "directory": "", "message": "salut", "mode": "directory"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 match = re.search(r"event: message\ndata: (.*)", resp.text) assert match, resp.text data = json.loads(match.group(1)) assert data["provider"] == "openrouter" expected = PROVIDERS.get("openrouter", {}).get("model", "") or "" assert data["model"] == expected, data assert data["model"], "provider default model must not be reported empty" class TestEffectiveModel: """Unit tests for the provider/model tag payload helper.""" def test_requested_model_wins(self): from backend.bookslm_routes import _effective_model assert _effective_model("openrouter", "openai/gpt-4o-mini") == "openai/gpt-4o-mini" def test_falls_back_to_provider_default(self): from backend.ai import PROVIDERS from backend.bookslm_routes import _effective_model default = PROVIDERS.get("openrouter", {}).get("model", "") assert _effective_model("openrouter", None) == default assert _effective_model("openrouter", "") == default def test_unknown_provider_or_missing_provider_is_empty(self): from backend.bookslm_routes import _effective_model assert _effective_model("nope-not-a-provider", None) == "" assert _effective_model(None, None) == "" class TestToolEventSources: """Web sources surfaced in the « sources » section of the steps block.""" @staticmethod def _record(name, result): from backend.agent.loop import ToolCallRecord return ToolCallRecord(name=name, arguments={}, ok=True, result=result) def test_web_search_results_become_sources(self): from backend.bookslm_routes import _tool_sources rec = self._record("web_search", {"results": [ {"title": "Météo Paris", "url": "https://exemple.fr/meteo"}, {"title": "Sans URL", "url": ""}, {"title": "", "url": "https://exemple.fr/b"}, ]}) sources = _tool_sources(rec) assert sources == [ {"title": "Météo Paris", "url": "https://exemple.fr/meteo"}, {"title": "https://exemple.fr/b", "url": "https://exemple.fr/b"}, ] def test_fetch_url_yields_one_source(self): from backend.bookslm_routes import _tool_sources rec = self._record("fetch_url", {"url": "https://exemple.fr/a", "title": "Titre"}) assert _tool_sources(rec) == [{"title": "Titre", "url": "https://exemple.fr/a"}] def test_other_tools_yield_no_sources(self): from backend.bookslm_routes import _tool_sources assert _tool_sources(self._record("read_file", {"content": "x"})) == [] assert _tool_sources(self._record("web_search", {"error": "boom"})) == [] assert _tool_sources(self._record("fetch_url", "not-a-dict")) == [] def test_sse_payload_carries_the_sources(self): import json as _json from backend.bookslm_routes import _tool_event_sse rec = self._record("web_search", {"results": [{"title": "T", "url": "https://exemple.fr/t"}]}) payload = _tool_event_sse(rec) assert payload.startswith("event: tool\ndata: ") data = _json.loads(payload.split("data: ", 1)[1]) assert data["name"] == "web_search" assert data["sources"] == [{"title": "T", "url": "https://exemple.fr/t"}] # Non-web tools keep the key, empty, so the UI never guesses. assert _json.loads(_tool_event_sse(self._record("read_file", {})).split("data: ", 1)[1])["sources"] == [] class TestAIGatewayHistoryStore: """Unit tests for backend.ai_history persistence helpers.""" def test_upsert_roundtrip_summary(self, tmp_path, monkeypatch): from backend import ai_history monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history") session = { "id": "s-abc", "title": "Titre", "mode": "directory", "vault": "V", "directory": "dossier", "documents": [], "context": "directory-V_dossier", "createdAt": 10, "updatedAt": 20, "messages": [{"role": "user", "content": "Coucou"}, {"role": "assistant", "content": "Hey"}], } assert ai_history.upsert_session("alice", session) is not None assert ai_history.upsert_session("bob", session) is not None # isolated per user store summaries = ai_history.list_sessions("alice") assert len(summaries) == 1 assert summaries[0]["message_count"] == 2 assert summaries[0]["preview"] == "Hey" assert "messages" not in summaries[0] assert len(ai_history.list_sessions("bob")) == 1 assert ai_history.list_sessions("carol") == [] full = ai_history.get_session("alice", "s-abc") assert full["messages"][0]["content"] == "Coucou" assert ai_history.get_session("alice", "s-missing") is None assert ai_history.delete_session("alice", "s-abc") is True assert ai_history.delete_session("alice", "s-abc") is False assert ai_history.get_session("alice", "s-abc") is None assert len(ai_history.list_sessions("bob")) == 1 # bob unaffected def test_update_replaces_and_sorts(self, tmp_path, monkeypatch): from backend import ai_history monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history") ai_history.upsert_session("u", {"id": "s-1", "title": "Première", "updatedAt": 1, "messages": []}) ai_history.upsert_session("u", {"id": "s-2", "title": "Seconde", "updatedAt": 2, "messages": []}) ai_history.upsert_session("u", {"id": "s-1", "title": "Première MAJ", "updatedAt": 3, "messages": []}) titles = [s["title"] for s in ai_history.list_sessions("u")] assert titles == ["Première MAJ", "Seconde"] assert len(ai_history.list_sessions("u")) == 2 def test_max_sessions_cap(self, tmp_path, monkeypatch): from backend import ai_history monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history") monkeypatch.setattr(ai_history, "MAX_SESSIONS", 3) for i in range(5): ai_history.upsert_session("u", {"id": f"s-{i}", "title": f"S{i}", "updatedAt": i, "messages": []}) ids = [s["id"] for s in ai_history.list_sessions("u")] assert len(ids) == 3 assert ids == ["s-4", "s-3", "s-2"] # most recent kept def test_invalid_inputs(self, tmp_path, monkeypatch): from backend import ai_history monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history") assert ai_history.upsert_session("", {}) is None assert ai_history.upsert_session("u", {"id": ""}) is None assert ai_history.get_session("", "s-x") is None assert ai_history.delete_session("", "s-x") is False assert list_sessions_for_missing_user_is_empty(tmp_path) def list_sessions_for_missing_user_is_empty(tmp_path): # Local wrapper keeps the pytest node independent of the fixture chdir. from backend import ai_history old = ai_history.AI_HISTORY_DIR ai_history.AI_HISTORY_DIR = tmp_path / "empty" try: return ai_history.list_sessions("ghost") == [] finally: ai_history.AI_HISTORY_DIR = old def _session_payload(self, session_id="s-test1", title="Ma conversation"): return { "id": session_id, "title": title, "mode": "general", "vault": None, "directory": "", "documents": [], "context": "general", "createdAt": 1700000000000, "updatedAt": 1700000001000, "messages": [ {"role": "user", "content": "Bonjour"}, {"role": "assistant", "content": "Salut !"}, ], } def test_history_requires_auth(self, bookslm_client): """The history endpoints require authentication.""" resp = bookslm_client.get("/api/ai/bookslm/history") assert resp.status_code == 401 resp = bookslm_client.put( "/api/ai/bookslm/history/s-x", json=self._session_payload(), ) assert resp.status_code == 401 def test_upsert_then_list_then_get(self, bookslm_client): """PUT /history/{id} persists, GET /history lists a summary and GET /history/{id} returns the conversation.""" token, _ = _login_bookslm(bookslm_client) headers = {"Authorization": f"Bearer {token}"} resp = bookslm_client.put( "/api/ai/bookslm/history/s-test1", json=self._session_payload(), headers=headers, ) assert resp.status_code == 200 assert resp.json()["id"] == "s-test1" listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json() assert len(listing["sessions"]) == 1 summary = listing["sessions"][0] assert summary["id"] == "s-test1" assert summary["title"] == "Ma conversation" assert summary["message_count"] == 2 assert "messages" not in summary full = bookslm_client.get("/api/ai/bookslm/history/s-test1", headers=headers).json() assert full["messages"][0]["content"] == "Bonjour" assert full["context"] == "general" def test_update_replaces_session(self, bookslm_client): """PUT with the same id replaces the stored conversation.""" token, _ = _login_bookslm(bookslm_client) headers = {"Authorization": f"Bearer {token}"} bookslm_client.put( "/api/ai/bookslm/history/s-upd", json=self._session_payload("s-upd", "Avant"), headers=headers, ) payload = self._session_payload("s-upd", "Après") resp = bookslm_client.put( "/api/ai/bookslm/history/s-upd", json=payload, headers=headers, ) assert resp.json()["title"] == "Après" listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json() assert len(listing["sessions"]) == 1 assert listing["sessions"][0]["title"] == "Après" def test_list_is_sorted_by_updated_at(self, bookslm_client): """Sessions are listed most recently updated first.""" token, _ = _login_bookslm(bookslm_client) headers = {"Authorization": f"Bearer {token}"} old = self._session_payload("s-old", "Ancienne") old["updatedAt"] = 1000 new = self._session_payload("s-new", "Récente") new["updatedAt"] = 5000 bookslm_client.put("/api/ai/bookslm/history/s-old", json=old, headers=headers) bookslm_client.put("/api/ai/bookslm/history/s-new", json=new, headers=headers) listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json() ids = [s["id"] for s in listing["sessions"]] assert ids == ["s-new", "s-old"] def test_get_unknown_session_returns_404(self, bookslm_client): """GET /history/{id} on an unknown id returns 404.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.get( "/api/ai/bookslm/history/s-unknown", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 def test_delete_removes_session(self, bookslm_client): """DELETE /history/{id} removes the conversation for the current user.""" token, _ = _login_bookslm(bookslm_client) headers = {"Authorization": f"Bearer {token}"} bookslm_client.put( "/api/ai/bookslm/history/s-del", json=self._session_payload("s-del"), headers=headers, ) resp = bookslm_client.delete("/api/ai/bookslm/history/s-del", headers=headers) assert resp.json() == {"ok": True} listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json() assert listing["sessions"] == [] assert bookslm_client.get("/api/ai/bookslm/history/s-del", headers=headers).status_code == 404 def test_history_is_isolated_per_user(self, bookslm_client): """A session belongs to the user who created it, not to others.""" token, _ = _login_bookslm(bookslm_client) headers = {"Authorization": f"Bearer {token}"} bookslm_client.put( "/api/ai/bookslm/history/s-private", json=self._session_payload("s-private"), headers=headers, ) # Provision a second user so isolation can be checked. from backend.auth.password import hash_password db = json.loads(Path("data/users.json").read_text(encoding="utf-8")) db["users"]["bob"] = { "id": "bob-1", "username": "bob", "display_name": "Bob", "password_hash": hash_password("BobPass123!"), "role": "admin", "vaults": ["*"], "active": True, "created_at": "2026-01-01T00:00:00", } Path("data/users.json").write_text(json.dumps(db), encoding="utf-8") other_token, _ = _login_bookslm(bookslm_client, username="bob", password="BobPass123!") resp = bookslm_client.get( "/api/ai/bookslm/history/s-private", headers={"Authorization": f"Bearer {other_token}"}, ) assert resp.status_code == 404 assert bookslm_client.get( "/api/ai/bookslm/history", headers={"Authorization": f"Bearer {other_token}"}, ).json()["sessions"] == []