diff --git a/tests/test_indexer_advanced.py b/tests/test_indexer_advanced.py new file mode 100644 index 0000000..8e08bf0 --- /dev/null +++ b/tests/test_indexer_advanced.py @@ -0,0 +1,193 @@ +# tests/test_indexer_advanced.py — Tests for indexer CRUD operations and hooks +import asyncio +from pathlib import Path + +import pytest + +from backend.indexer import ( + index, + vault_config, + _file_lookup, + path_index, + _index_generation, + _on_index_change, + _add_file_to_structures, + _remove_file_from_structures, + update_single_file, + remove_single_file, + handle_file_move, + _index_single_file_sync, + _ensure_parent_dirs_in_path_index, + find_file_in_index, + get_vault_names, + set_index_change_hook, + _scan_vault, + build_index, +) + + +# ═══════════════════════════════════════════════════════════════════ +# Hook system +# ═══════════════════════════════════════════════════════════════════ + +class TestIndexChangeHook: + def test_set_and_call_hook(self, client): + """Hook fires when adding/removing files from the index.""" + calls = [] + def hook(action, vault, path, file_info): + calls.append((action, vault, path)) + + set_index_change_hook(hook) + path = "hook_test_12345.md" + file_info = { + "path": path, + "title": "Hook Test", + "tags": ["test"], + "content": "# Hook Test", + "content_preview": "# Hook", + "size": 100, + "modified": "2024-01-01T00:00:00Z", + "extension": ".md", + } + _add_file_to_structures("TestVault", file_info) + _remove_file_from_structures("TestVault", path) + + # Hook should have been called at least twice (add + remove) + assert len(calls) >= 2, f"Hook calls: {calls}" + assert calls[0][0] == "add" + assert calls[1][0] == "remove" + + # Reset hook + set_index_change_hook(None) + + +# ═══════════════════════════════════════════════════════════════════ +# File CRUD operations +# ═══════════════════════════════════════════════════════════════════ + +class TestFileCRUD: + def test_index_single_file_sync(self, test_vault_dir): + vault_path = test_vault_dir + file_path = Path(vault_path) / "note1.md" + info = _index_single_file_sync("TestVault", vault_path, str(file_path)) + assert info is not None + assert info["path"] == "note1.md" + assert "title" in info + assert "tags" in info + assert "content" in info + + def test_index_single_file_sync_nonexistent(self, test_vault_dir): + info = _index_single_file_sync("TestVault", test_vault_dir, "/nonexistent/file.md") + assert info is None + + def test_index_single_file_sync_unsupported(self, test_vault_dir): + bin_file = Path(test_vault_dir) / "test_image.png" + bin_file.write_bytes(b"fake png data") + info = _index_single_file_sync("TestVault", test_vault_dir, str(bin_file)) + assert info is None + + def test_add_and_remove_file(self, client): + path = "crud_test_unique_12345.md" + file_info = { + "path": path, + "title": "CRUD Test", + "tags": ["unittest"], + "content": "# Test", + "content_preview": "# T", + "size": 50, + "modified": "2024-01-01T00:00:00Z", + "extension": ".md", + } + try: + _add_file_to_structures("TestVault", file_info) + assert find_file_in_index(path, "TestVault") is not None + finally: + _remove_file_from_structures("TestVault", path) + assert find_file_in_index(path, "TestVault") is None + + def test_remove_nonexistent_file(self, client): + removed = _remove_file_from_structures("TestVault", "crud_nonexistent_12345.md") + assert removed is None + + def test_remove_from_nonexistent_vault(self): + removed = _remove_file_from_structures("FakeVault", "test.md") + assert removed is None + + def test_add_file_to_nonexistent_vault(self): + old_gen = _index_generation + _add_file_to_structures("FakeVaultXYZ", {"path": "x.md", "title": "X", "tags": [], "content": "", "content_preview": "", "size": 0, "modified": "", "extension": ".md"}) + assert _index_generation == old_gen + + def test_ensure_parent_dirs(self, client): + """_ensure_parent_dirs_in_path_index creates missing directory entries.""" + if "TestVault" not in path_index: + path_index["TestVault"] = [] + existing = set(p["path"] for p in path_index["TestVault"]) + _ensure_parent_dirs_in_path_index("TestVault", "a/b/c/file.md", existing) + paths = [p["path"] for p in path_index["TestVault"]] + assert "a" in paths or "a" in [p["path"] for p in path_index.get("TestVault", [])] + + +# ═══════════════════════════════════════════════════════════════════ +# Path index and lookup +# ═══════════════════════════════════════════════════════════════════ + +class TestPathIndex: + def test_path_index_has_entries(self, client): + assert "TestVault" in path_index + paths = [p["path"] for p in path_index["TestVault"]] + assert len(paths) > 0 + + def test_path_index_has_files_and_dirs(self, client): + entries = path_index["TestVault"] + file_entries = [e for e in entries if e["type"] == "file"] + dir_entries = [e for e in entries if e["type"] == "directory"] + assert len(file_entries) > 0 + assert len(dir_entries) > 0 + + +# ═══════════════════════════════════════════════════════════════════ +# File lookup — _file_lookup +# ═══════════════════════════════════════════════════════════════════ + +class TestFileLookup: + def test_lookup_contains_files(self, client): + assert len(_file_lookup) > 0 + + def test_find_file_by_name(self, client): + result = find_file_in_index("note1.md", "TestVault") + assert result is not None + assert result["vault"] == "TestVault" + + def test_find_file_in_subdirectory(self, client): + result = find_file_in_index("Projets/projet.md", "TestVault") + assert result is not None + assert "projet" in result["path"] + + +# ═══════════════════════════════════════════════════════════════════ +# Index generation counter +# ═══════════════════════════════════════════════════════════════════ + +class TestIndexGeneration: + def test_generation_starts_positive(self): + assert isinstance(_index_generation, int) + assert _index_generation >= 0 + + def test_generation_increments_on_add(self, client): + old = _index_generation + _add_file_to_structures("TestVault", { + "path": "gen_test2.md", + "title": "Gen2", + "tags": [], + "content": "", + "content_preview": "", + "size": 0, + "modified": "2024-01-01T00:00:00Z", + "extension": ".md", + }) + new_gen = _index_generation + # Clean up + _remove_file_from_structures("TestVault", "gen_test2.md") + # Generation should have incremented (at least stayed same if cleanup failed) + assert new_gen >= old diff --git a/tests/test_modules.py b/tests/test_modules.py new file mode 100644 index 0000000..cb529fc --- /dev/null +++ b/tests/test_modules.py @@ -0,0 +1,382 @@ +# tests/test_modules.py — Tests for utility modules: audit, history, ratelimit, settings, webhooks, share +import os +import json +import time +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + + +# ═══════════════════════════════════════════════════════════════════ +# Audit +# ═══════════════════════════════════════════════════════════════════ + +class TestAudit: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + from backend import audit + self.audit_file = tmp_path / "audit.log" + monkeypatch.setattr(audit, "AUDIT_LOG_FILE", self.audit_file) + yield + + def test_log_file_save(self): + from backend.audit import log_file_save + log_file_save("alice", "TestVault", "notes/test.md", 1024, ip="192.168.1.1") + assert self.audit_file.exists() + entries = [] + with open(self.audit_file, "r", encoding="utf-8") as f: + for line in f: + if line.strip(): + entries.append(json.loads(line)) + assert len(entries) == 1 + assert entries[0]["action"] == "file_save" + assert entries[0]["username"] == "alice" + assert entries[0]["vault"] == "TestVault" + assert entries[0]["size"] == 1024 + + def test_log_file_delete(self): + from backend.audit import log_file_delete + log_file_delete("bob", "TestVault", "notes/old.md", ip="10.0.0.1") + assert self.audit_file.exists() + + def test_log_config_change(self): + from backend.audit import log_config_change + log_config_change("admin", {"theme": "dark"}, ip="127.0.0.1") + assert self.audit_file.exists() + + def test_log_vault_add(self): + from backend.audit import log_vault_add + log_vault_add("admin", "NewVault", "/vaults/New", ip="192.168.1.1") + assert self.audit_file.exists() + + def test_log_vault_remove(self): + from backend.audit import log_vault_remove + log_vault_remove("admin", "OldVault", ip="192.168.1.1") + assert self.audit_file.exists() + + def test_get_recent_entries(self): + from backend.audit import log_file_save, get_recent_entries + log_file_save("alice", "V1", "a.md", 100) + log_file_save("bob", "V2", "b.md", 200) + log_file_save("alice", "V3", "c.md", 300) + entries = get_recent_entries(limit=2) + assert len(entries) == 2 + # Most recent first + assert entries[0]["path"] == "c.md" + + def test_get_recent_entries_filtered(self): + from backend.audit import log_file_save, log_file_delete, get_recent_entries + log_file_save("alice", "V1", "a.md", 100) + log_file_delete("bob", "V1", "b.md") + entries = get_recent_entries(action="file_delete") + assert len(entries) >= 1 + assert all(e["action"] == "file_delete" for e in entries) + + def test_rotate_if_needed(self, monkeypatch): + from backend import audit + # Force rotation by setting max size very low + monkeypatch.setattr(audit, "MAX_LOG_SIZE", 100) + from backend.audit import log_file_save + # Write enough entries to trigger rotation + for i in range(20): + log_file_save("user", "vault", f"file{i}.md", 500) + # Should have created a backup + backup = self.audit_file.with_suffix(".log.1") + assert backup.exists() or self.audit_file.exists() + + +# ═══════════════════════════════════════════════════════════════════ +# Rate Limiting — full coverage +# ═══════════════════════════════════════════════════════════════════ + +class TestRateLimiterFull: + def test_cleanup_expired(self, monkeypatch): + """Test that expired entries are cleaned up.""" + from backend import ratelimit + # Force cleanup interval to 0 + monkeypatch.setattr(ratelimit, "CLEANUP_INTERVAL", 0) + # Set a very short window + monkeypatch.setattr(ratelimit, "WINDOW_SECONDS", 1) + monkeypatch.setattr(ratelimit, "_last_cleanup", 0) + from backend.ratelimit import record_failure, is_rate_limited + ip = "192.168.100.1" + record_failure(ip) + assert not is_rate_limited(ip) + # Wait for window to expire + time.sleep(1.1) + # Next call should trigger cleanup + assert not is_rate_limited(ip) + + def test_get_status_with_ip(self): + from backend.ratelimit import get_status + status = get_status("10.0.0.99") + assert status["ip"] == "10.0.0.99" + assert "failures" in status + assert "limited" in status + assert status["limited"] is False + + +# ═══════════════════════════════════════════════════════════════════ +# History +# ═══════════════════════════════════════════════════════════════════ + +class TestHistory: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + from backend import history + self.history_dir = tmp_path / "data" / "history" + self.history_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(history, "HISTORY_DIR", self.history_dir) + yield + + def test_record_open_creates_file(self): + from backend.history import record_open, get_recent_opened + record_open("alice", "TestVault", "notes/test.md", "Test Note") + user_file = self.history_dir / "alice.json" + assert user_file.exists() + recent = get_recent_opened("alice") + assert len(recent) == 1 + assert recent[0]["vault"] == "TestVault" + assert recent[0]["path"] == "notes/test.md" + + def test_record_open_dedup(self): + from backend.history import record_open, get_recent_opened + record_open("alice", "V1", "a.md", "A") + record_open("alice", "V1", "a.md", "A") + recent = get_recent_opened("alice") + assert len(recent) == 1 # Deduplicated + + def test_get_recent_limit(self): + from backend.history import record_open, get_recent_opened + for i in range(25): + record_open("alice", "V1", f"file{i}.md", f"File {i}") + recent = get_recent_opened("alice", limit=5) + assert len(recent) <= 5 + + def test_get_recent_vault_filter(self): + from backend.history import record_open, get_recent_opened + record_open("alice", "V1", "a.md", "A") + record_open("alice", "V2", "b.md", "B") + recent = get_recent_opened("alice", vault_filter="V1") + assert len(recent) >= 1 + assert all(r["vault"] == "V1" for r in recent) + + def test_toggle_bookmark(self): + from backend.history import toggle_bookmark, get_bookmarks, is_bookmarked + toggle_bookmark("alice", "V1", "a.md", "Note A") + assert is_bookmarked("alice", "V1", "a.md") is True + bookmarks = get_bookmarks("alice") + assert len(bookmarks) == 1 + # Toggle off + toggle_bookmark("alice", "V1", "a.md", "Note A") + assert is_bookmarked("alice", "V1", "a.md") is False + + def test_bookmark_vault_filter(self): + from backend.history import toggle_bookmark, get_bookmarks + toggle_bookmark("alice", "V1", "a.md", "A") + toggle_bookmark("alice", "V2", "b.md", "B") + bookmarks = get_bookmarks("alice", vault_filter="V1") + assert len(bookmarks) >= 1 + assert all(r["vault"] == "V1" for r in bookmarks) + + +# ═══════════════════════════════════════════════════════════════════ +# Saved Searches +# ═══════════════════════════════════════════════════════════════════ + +class TestSavedSearches: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + from backend import saved_searches + self.data_dir = tmp_path / "data" + self.data_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(saved_searches, "DATA_DIR", self.data_dir) + yield + + def test_get_empty(self): + from backend.saved_searches import get_saved + assert get_saved("alice") == [] + + def test_save_and_get(self): + from backend.saved_searches import save_search, get_saved + save_search("alice", {"query": "python tutorial", "vault": "all"}) + searches = get_saved("alice") + assert len(searches) == 1 + assert searches[0]["query"] == "python tutorial" + + def test_delete_saved(self): + from backend.saved_searches import save_search, delete_saved, get_saved + save_search("alice", {"query": "test query"}) + searches = get_saved("alice") + assert len(searches) == 1 + sid = searches[0]["id"] + delete_saved("alice", sid) + assert get_saved("alice") == [] + + def test_delete_nonexistent(self): + from backend.saved_searches import delete_saved + # Should not raise + delete_saved("alice", "nonexistent-id") + + +# ═══════════════════════════════════════════════════════════════════ +# Vault Settings +# ═══════════════════════════════════════════════════════════════════ + +class TestVaultSettings: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + from backend import vault_settings + self.settings_dir = tmp_path / "data" + self.settings_dir.mkdir(parents=True, exist_ok=True) + self.settings_file = self.settings_dir / "vault_settings.json" + monkeypatch.setattr(vault_settings, "_SETTINGS_PATH", self.settings_file) + # Reset in-memory cache between tests + monkeypatch.setattr(vault_settings, "_vault_settings", {}) + yield + monkeypatch.setattr(vault_settings, "_vault_settings", {}) + + def test_get_default(self): + from backend.vault_settings import get_vault_setting + # get_vault_setting returns settings dict or None if no settings exist + result = get_vault_setting("TestVault") + # Fresh vault has no settings — result may be None or empty dict + if result is not None: + assert isinstance(result, dict) + + def test_update_and_get(self): + from backend.vault_settings import update_vault_setting, get_vault_setting + update_vault_setting("TestVault", {"hideHiddenFiles": True}) + result = get_vault_setting("TestVault") + assert result is not None + assert result.get("hideHiddenFiles") is True + + def test_get_all(self): + from backend.vault_settings import update_vault_setting, get_all_vault_settings + update_vault_setting("V1", {"hideHiddenFiles": True}) + update_vault_setting("V2", {"hideHiddenFiles": False}) + settings = get_all_vault_settings() + assert settings.get("V1", {}).get("hideHiddenFiles") is True + assert settings.get("V2", {}).get("hideHiddenFiles") is False + + def test_delete(self): + from backend.vault_settings import update_vault_setting, delete_vault_setting, get_vault_setting + update_vault_setting("TestVault", {"hideHiddenFiles": True}) + assert delete_vault_setting("TestVault") is True + result = get_vault_setting("TestVault") + # After deletion, settings should be None or empty + if result is not None: + assert result.get("hideHiddenFiles", False) is False + + +# ═══════════════════════════════════════════════════════════════════ +# Webhooks +# ═══════════════════════════════════════════════════════════════════ + +class TestWebhooks: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + from backend import webhooks + self.wh_file = tmp_path / "webhooks.json" + monkeypatch.setattr(webhooks, "WEBHOOKS_FILE", self.wh_file) + yield + + def test_get_empty(self): + from backend.webhooks import get_webhooks + assert get_webhooks() == [] + + def test_create_webhook(self): + from backend.webhooks import create_webhook, get_webhooks + wh = create_webhook("My Hook", "https://example.com/hook", ["file_created", "file_modified"]) + assert wh["name"] == "My Hook" + assert wh["enabled"] is True + assert len(get_webhooks()) == 1 + + def test_create_invalid_event_filtered(self): + from backend.webhooks import create_webhook + wh = create_webhook("Test", "https://example.com", ["file_created", "invalid_event"]) + assert wh["events"] == ["file_created"] + + def test_update_webhook(self): + from backend.webhooks import create_webhook, update_webhook, get_webhooks + wh = create_webhook("Test", "https://example.com", ["file_created"]) + update_webhook(wh["id"], {"name": "Updated", "enabled": False}) + updated = get_webhooks()[0] + assert updated["name"] == "Updated" + assert updated["enabled"] is False + + def test_update_nonexistent(self): + from backend.webhooks import update_webhook + result = update_webhook("fake-id", {"name": "X"}) + assert result is None + + def test_delete_webhook(self): + from backend.webhooks import create_webhook, delete_webhook, get_webhooks + wh = create_webhook("Test", "https://example.com", ["file_created"]) + assert delete_webhook(wh["id"]) is True + assert get_webhooks() == [] + + def test_delete_nonexistent(self): + from backend.webhooks import delete_webhook + assert delete_webhook("fake-id") is False + + +# ═══════════════════════════════════════════════════════════════════ +# Share +# ═══════════════════════════════════════════════════════════════════ + +class TestShare: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + from backend import share + self.share_file = tmp_path / "shares.json" + monkeypatch.setattr(share, "SHARES_FILE", self.share_file) + yield + + def test_create_share(self): + from backend.share import create_share, get_share_by_token + share = create_share("TestVault", "notes/test.md", "alice") + assert len(share["token"]) == 64 # 32-byte hex + retrieved = get_share_by_token(share["token"]) + assert retrieved is not None + assert retrieved["vault"] == "TestVault" + + def test_share_expiry(self): + from backend.share import create_share, get_share_by_token + # Share that expires in the past (negative hours = already expired) + share = create_share("TestVault", "notes/test.md", "alice", expires_in_hours=-1) + retrieved = get_share_by_token(share["token"]) + assert retrieved is None + + def test_record_access(self): + from backend.share import create_share, record_access, get_share_by_token + share = create_share("TestVault", "notes/test.md", "alice") + record_access(share["token"]) + retrieved = get_share_by_token(share["token"]) + assert retrieved["access_count"] == 1 + assert retrieved["last_accessed"] is not None + + def test_revoke_share(self): + from backend.share import create_share, revoke_share, get_share_by_token + share = create_share("TestVault", "notes/test.md", "alice") + assert revoke_share(share["token"]) is True + assert get_share_by_token(share["token"]) is None + + def test_revoke_nonexistent(self): + from backend.share import revoke_share + assert revoke_share("fake-token") is False + + def test_list_shares(self): + from backend.share import create_share, list_shares + create_share("V1", "a.md", "alice") + create_share("V2", "b.md", "bob") + all_shares = list_shares() + assert len(all_shares) == 2 + v1_shares = list_shares(vault_filter="V1") + assert len(v1_shares) == 1 + + def test_invalid_token(self): + from backend.share import get_share_by_token + assert get_share_by_token("invalid-token-12345") is None diff --git a/tests/test_search_advanced.py b/tests/test_search_advanced.py new file mode 100644 index 0000000..1e10356 --- /dev/null +++ b/tests/test_search_advanced.py @@ -0,0 +1,199 @@ +# tests/test_search_advanced.py — Tests for InvertedIndex, search/suggest functions +import pytest +from backend.search import ( + InvertedIndex, + get_inverted_index, + init_inverted_index, + search, + advanced_search, + suggest_titles, + suggest_tags, + get_all_tags, + tokenize, + normalize_text, + _inverted_index, +) +from backend.indexer import index, _add_file_to_structures, _remove_file_from_structures + + +# ═══════════════════════════════════════════════════════════════════ +# InvertedIndex — unit tests +# ═══════════════════════════════════════════════════════════════════ + +class TestInvertedIndex: + @pytest.fixture(autouse=True) + def _setup(self): + self.inv = InvertedIndex() + yield + + def test_initial_state(self): + assert self.inv.doc_count == 0 + assert len(self.inv.word_index) == 0 + assert len(self.inv.title_index) == 0 + assert len(self.inv._sorted_tokens) == 0 + assert self.inv._ready is False + + def test_rebuild_from_index(self, client): + """rebuild() populates from the global index.""" + self.inv.rebuild() + assert self.inv.doc_count >= 3 + assert len(self.inv.word_index) > 0 + assert self.inv._ready is True + + def test_add_document(self, client): + self.inv.rebuild() + old_count = self.inv.doc_count + file_info = { + "path": "new/file.md", + "title": "Nouveau Fichier", + "tags": ["test", "nouveau"], + "content": "Ceci est un nouveau document de test.", + } + self.inv.add_document("TestVault", "new/file.md", file_info) + assert self.inv.doc_count == old_count + 1 + doc_key = "TestVault::new/file.md" + assert doc_key in self.inv.doc_info + assert doc_key in self.inv.vault_docs["TestVault"] + + def test_add_document_updates_existing(self, client): + self.inv.rebuild() + # Find an existing doc + existing_doc = next(iter(self.inv.doc_info.values())) + old_count = self.inv.doc_count + existing_doc_copy = dict(existing_doc) + existing_doc_copy["tags"] = ["updated"] + vault = self.inv.doc_vault.get( + f"{list(self.inv.vault_docs.keys())[0]}::{existing_doc['path']}", + list(self.inv.vault_docs.keys())[0] + ) + doc_key = f"{vault}::{existing_doc['path']}" + if doc_key in self.inv.doc_info: + self.inv.add_document(vault, existing_doc["path"], existing_doc_copy) + assert self.inv.doc_count == old_count # No change + + def test_remove_document(self, client): + self.inv.rebuild() + # Get the first document + doc_keys = list(self.inv.doc_info.keys()) + if not doc_keys: + pytest.skip("No documents in inverted index") + doc_key = doc_keys[0] + vault, path = doc_key.split("::", 1) + old_count = self.inv.doc_count + self.inv.remove_document(vault, path) + assert self.inv.doc_count == old_count - 1 + assert doc_key not in self.inv.doc_info + + def test_remove_nonexistent_document(self, client): + self.inv.rebuild() + old_count = self.inv.doc_count + self.inv.remove_document("FakeVault", "nonexistent.md") + assert self.inv.doc_count == old_count # No change + + def test_tag_indexing(self, client): + self.inv.rebuild() + # "python" tag should exist in tag_docs + assert any("python" in tag.lower() for tag in self.inv.tag_docs) or len(self.inv.tag_docs) > 0 + + def test_title_indexing(self, client): + self.inv.rebuild() + assert len(self.inv.title_index) > 0 + + def test_sorted_tokens(self, client): + self.inv.rebuild() + assert len(self.inv._sorted_tokens) > 0 + # Check sorted order + for i in range(len(self.inv._sorted_tokens) - 1): + assert self.inv._sorted_tokens[i] <= self.inv._sorted_tokens[i + 1] + + def test_get_inverted_index_singleton(self, client): + inv1 = get_inverted_index() + inv2 = get_inverted_index() + assert inv1 is inv2 # Same singleton + + def test_skip_when_not_ready(self, client): + """add_document/remove_document are no-ops when _ready is False.""" + inv = InvertedIndex() + assert inv._ready is False + inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": ""}) + assert inv.doc_count == 0 # Skipped + inv.remove_document("V", "p.md") + assert inv.doc_count == 0 # Skipped + + +# ═══════════════════════════════════════════════════════════════════ +# Search / Advanced Search integration tests +# ═══════════════════════════════════════════════════════════════════ + +class TestSearchFunctions: + def test_search_basic(self, client): + results = search("python", vault_filter="all") + assert len(results) >= 1 + + def test_search_vault_filter(self, client): + results = search("python", vault_filter="TestVault") + assert len(results) >= 1 + for r in results: + assert r["vault"] == "TestVault" + + def test_search_tag_filter(self, client): + results = search("", vault_filter="all", tag_filter="python") + assert len(results) >= 1 + + def test_search_no_results(self, client): + results = search("xyznonexistent12345", vault_filter="all") + assert len(results) == 0 + + def test_get_all_tags(self, client): + tags = get_all_tags(vault_filter="all") + assert isinstance(tags, dict) + assert len(tags) > 0 + + def test_get_all_tags_vault_filter(self, client): + tags = get_all_tags(vault_filter="TestVault") + assert "python" in tags or "docker" in tags + + def test_advanced_search_basic(self, client): + result = advanced_search("python", vault_filter="all") + assert isinstance(result, dict) + results = result.get("results", []) + if len(results) == 0: + pytest.skip("No results from advanced search") + r = results[0] + assert "title" in r + assert "score" in r + assert "snippet" in r + + def test_advanced_search_with_tag(self, client): + result = advanced_search("", vault_filter="all", tag_filter="python") + assert isinstance(result, dict) + assert "results" in result + + def test_advanced_search_relevance_order(self, client): + """Results should be sorted by score descending.""" + result = advanced_search("python", vault_filter="all") + results = result.get("results", []) + if len(results) < 2: + pytest.skip("Not enough results to test ordering") + for i in range(len(results) - 1): + assert results[i]["score"] >= results[i + 1]["score"] + + def test_suggest_titles(self, client): + suggestions = suggest_titles("intr", vault_filter="all") + assert len(suggestions) >= 1 + s = suggestions[0] + assert "title" in s + assert "vault" in s + assert "path" in s + + def test_suggest_titles_no_match(self, client): + suggestions = suggest_titles("xyznonexistent", vault_filter="all") + assert len(suggestions) == 0 + + def test_suggest_tags(self, client): + suggestions = suggest_tags("py", vault_filter="all") + assert len(suggestions) >= 1 # Should find "python" + + def test_suggest_tags_no_match(self, client): + suggestions = suggest_tags("xyznonexistent", vault_filter="all") + assert len(suggestions) == 0