ObsiGate/tests/test_api_main.py
Bruno Charest 3151721aad
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 9s
test: coverage 70% — +109 tests (api integ, auth api, watcher mocked)
2026-06-02 10:21:13 -04:00

683 lines
28 KiB
Python

# tests/test_api_main.py — Integration tests for main.py API endpoints
# Uses the existing app_with_vault / client fixtures from conftest.py
import pytest
# ═══════════════════════════════════════════════════════════════════
# Health & Metadata
# ═══════════════════════════════════════════════════════════════════
class TestHealth:
def test_health_ok(self, client):
resp = client.get("/api/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "version" in data
assert data["vaults"] >= 1
assert data["total_files"] >= 1
def test_health_has_vault_files(self, client):
resp = client.get("/api/health")
data = resp.json()
assert data["vaults"] == 1
assert data["total_files"] >= 4 # note1, note2, projet, café_crème + config.json
class TestVaults:
def test_list_vaults(self, client):
resp = client.get("/api/vaults")
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
vault = data[0]
assert "name" in vault
assert "file_count" in vault
assert "tag_count" in vault
assert vault["file_count"] >= 1
def test_vault_has_name(self, client):
resp = client.get("/api/vaults")
data = resp.json()
names = [v["name"] for v in data]
assert "TestVault" in names
def test_vault_type_default(self, client):
resp = client.get("/api/vaults")
data = resp.json()
for v in data:
assert v.get("type") in ("VAULT", "DIR")
# ═══════════════════════════════════════════════════════════════════
# Browse
# ═══════════════════════════════════════════════════════════════════
class TestBrowse:
def test_browse_root(self, client):
resp = client.get("/api/browse/TestVault")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "path" in data
assert data.get("path") == "" or data.get("path") == "/"
def test_browse_subdirectory(self, client):
resp = client.get("/api/browse/TestVault", params={"path": "Projets"})
assert resp.status_code == 200
data = resp.json()
# Should contain projet.md
names = [i["name"] for i in data.get("items", [])]
assert "projet.md" in names
def test_browse_nonexistent_vault(self, client):
resp = client.get("/api/browse/NoSuchVault")
assert resp.status_code == 404
def test_browse_nonexistent_path(self, client):
resp = client.get("/api/browse/TestVault", params={"path": "Nope"})
assert resp.status_code == 404
# ═══════════════════════════════════════════════════════════════════
# File Content & Raw
# ═══════════════════════════════════════════════════════════════════
class TestFileContent:
def test_get_file(self, client):
resp = client.get("/api/file/TestVault", params={"path": "note1.md"})
assert resp.status_code == 200
data = resp.json()
assert data["title"] == "Introduction à Python"
assert "html" in data
assert data["vault"] == "TestVault"
def test_get_file_with_frontmatter(self, client):
resp = client.get("/api/file/TestVault", params={"path": "note2.md"})
data = resp.json()
assert data["title"] == "Docker Guide"
assert "docker" in data.get("tags", [])
assert "frontmatter" in data
def test_get_file_in_subdir(self, client):
resp = client.get("/api/file/TestVault", params={"path": "Projets/projet.md"})
assert resp.status_code == 200
data = resp.json()
assert "Projet" in data["title"]
def test_get_nonexistent_file(self, client):
resp = client.get("/api/file/TestVault", params={"path": "noexist.md"})
assert resp.status_code == 404
def test_get_file_raw(self, client):
resp = client.get("/api/file/TestVault/raw", params={"path": "note1.md"})
assert resp.status_code == 200
data = resp.json()
assert "raw" in data
assert "# Introduction à Python" in data["raw"]
def test_get_file_download(self, client):
resp = client.get("/api/file/TestVault/download", params={"path": "note1.md"})
assert resp.status_code == 200
# Download returns the raw file content directly
assert b"Introduction" in resp.content
def test_get_file_backlinks(self, client):
"""note2.md has [[Introduction à Python]] which should link back to note1.md"""
resp = client.get("/api/file/TestVault/backlinks", params={"path": "note1.md"})
assert resp.status_code == 200
data = resp.json()
assert "backlinks" in data
# The backlinks may be empty in test due to index state,
# but the endpoint should return a valid response
def test_get_file_backlinks_empty(self, client):
"""projet.md has no backlinks (no one links to it)"""
resp = client.get("/api/file/TestVault/backlinks", params={"path": "Projets/projet.md"})
assert resp.status_code == 200
data = resp.json()
assert len(data["backlinks"]) == 0
# ═══════════════════════════════════════════════════════════════════
# File CRUD (requires writable vault — tmp_path is writable)
# ═══════════════════════════════════════════════════════════════════
class TestFileCRUD:
"""Note: uses app_with_vault which creates a fresh index each time.
The vault is backed by tmp_path so writes are allowed.
"""
def test_create_file(self, client):
resp = client.post("/api/file/TestVault", json={
"path": "newfile.md",
"content": "# New File\nCreated during test.\n",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
# Verify it exists
resp2 = client.get("/api/file/TestVault", params={"path": "newfile.md"})
assert resp2.status_code == 200
def test_create_file_in_subdir(self, client):
"""Create inside a subdirectory that exists"""
resp = client.post("/api/file/TestVault", json={
"path": "Projets/newsub.md",
"content": "# Sub File\n",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
def test_create_file_new_subdir(self, client):
"""Create inside a subdirectory that doesn't exist — should auto-create"""
resp = client.post("/api/file/TestVault", json={
"path": "NewDir/test.md",
"content": "# Auto-created dir\n",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
def test_save_file(self, client):
"""Save (update) an existing file"""
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
"content": "# Updated Python\nContenu modifié.\n",
})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
# Verify content changed
resp2 = client.get("/api/file/TestVault", params={"path": "note1.md"})
assert "Updated" in resp2.json()["html"]
def test_rename_file(self, client):
resp = client.patch("/api/file/TestVault", json={
"path": "note1.md",
"new_name": "renamed_note1.md",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
# Old path should 404
resp2 = client.get("/api/file/TestVault", params={"path": "note1.md"})
assert resp2.status_code == 404
# New path should exist
resp3 = client.get("/api/file/TestVault", params={"path": "renamed_note1.md"})
assert resp3.status_code == 200
def test_delete_file(self, client):
resp = client.delete("/api/file/TestVault", params={"path": "note1.md"})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
# Verify deletion
resp2 = client.get("/api/file/TestVault", params={"path": "note1.md"})
assert resp2.status_code == 404
def test_delete_nonexistent_file(self, client):
resp = client.delete("/api/file/TestVault", params={"path": "nosuchfile.md"})
assert resp.status_code == 404
# ═══════════════════════════════════════════════════════════════════
# Directory CRUD
# ═══════════════════════════════════════════════════════════════════
class TestDirectoryCRUD:
def test_create_directory(self, client):
resp = client.post("/api/directory/TestVault", json={
"path": "TestDir",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
# Verify in browse
resp2 = client.get("/api/browse/TestVault")
names = [i["name"] for i in resp2.json().get("items", [])]
assert "TestDir" in names
def test_create_directory_nested(self, client):
resp = client.post("/api/directory/TestVault", json={
"path": "A/B/C",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
def test_rename_directory(self, client):
# Create first
client.post("/api/directory/TestVault", json={"path": "OldName"})
# Rename
resp = client.patch("/api/directory/TestVault", json={
"path": "OldName",
"new_name": "NewName",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
def test_delete_directory(self, client):
# Create first
client.post("/api/directory/TestVault", json={"path": "ToDelete"})
# Delete
resp = client.delete("/api/directory/TestVault", params={"path": "ToDelete"})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
# ═══════════════════════════════════════════════════════════════════
# Search & Tags (supplement existing tests)
# ═══════════════════════════════════════════════════════════════════
class TestTreeSearch:
def test_tree_search_root(self, client):
resp = client.get("/api/tree-search", params={"q": "note", "vault": "all"})
assert resp.status_code == 200
data = resp.json()
assert "results" in data
# Should find note1 and note2
assert len(data["results"]) >= 2
def test_tree_search_no_match(self, client):
resp = client.get("/api/tree-search", params={"q": "zzzznonexistent", "vault": "all"})
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 0
class TestSearchReplace:
def test_search_replace_invalid(self, client):
"""Search/replace with no matching vault should return 400 or appropriate error"""
resp = client.post("/api/search/replace", json={
"vault": "TestVault",
"search": "Python",
"replace": "Java",
})
# Accept 400 if validation fails, 200 if it works
assert resp.status_code in (200, 400)
class TestSearchAPIEdgeCases:
def test_search_empty_query(self, client):
resp = client.get("/api/search?q=&vault=all")
assert resp.status_code in (200, 422)
def test_suggest_with_query(self, client):
resp = client.get("/api/suggest?q=Python&vault=all")
assert resp.status_code == 200
data = resp.json()
assert "suggestions" in data
class TestIndexReload:
def test_reload_index(self, client):
resp = client.get("/api/index/reload")
assert resp.status_code == 200
data = resp.json()
assert "vaults" in data
def test_reload_single_vault(self, client):
resp = client.get("/api/index/reload/TestVault")
assert resp.status_code == 200
# ═══════════════════════════════════════════════════════════════════
# Bookmarks & Recent
# ═══════════════════════════════════════════════════════════════════
class TestBookmarks:
def test_get_bookmarks_empty(self, client):
resp = client.get("/api/bookmarks")
assert resp.status_code == 200
data = resp.json()
# Response: { files: [], total: 0 }
assert isinstance(data.get("files", data), list)
def test_toggle_bookmark(self, client):
resp = client.post("/api/bookmarks/toggle", json={
"vault": "TestVault",
"path": "note1.md",
})
assert resp.status_code == 200
data = resp.json()
assert "bookmarked" in data
def test_bookmark_vault_filter(self, client):
client.post("/api/bookmarks/toggle", json={
"vault": "TestVault", "path": "note1.md",
})
resp = client.get("/api/bookmarks", params={"vault": "TestVault"})
assert resp.status_code == 200
def test_recent_files(self, client):
resp = client.get("/api/recent")
assert resp.status_code == 200
data = resp.json()
assert "recent" in data or "files" in data
def test_recent_vault_filter(self, client):
resp = client.get("/api/recent", params={"vault": "TestVault"})
assert resp.status_code == 200
# ═══════════════════════════════════════════════════════════════════
# Graph
# ═══════════════════════════════════════════════════════════════════
class TestGraph:
def test_graph_full(self, client):
resp = client.get("/api/graph/TestVault")
assert resp.status_code == 200
data = resp.json()
assert "nodes" in data
assert "edges" in data
assert len(data["nodes"]) >= 1
def test_graph_with_filters(self, client):
resp = client.get("/api/graph/TestVault", params={"depth": "2", "types": "file"})
assert resp.status_code == 200
data = resp.json()
assert "nodes" in data
def test_graph_nonexistent_vault(self, client):
resp = client.get("/api/graph/NoVault")
assert resp.status_code == 404
# ═══════════════════════════════════════════════════════════════════
# Config & Diagnostics
# ═══════════════════════════════════════════════════════════════════
class TestConfig:
def test_get_config(self, client):
resp = client.get("/api/config")
assert resp.status_code == 200
data = resp.json()
assert "config" in data or isinstance(data, dict)
def test_post_config(self, client):
resp = client.post("/api/config", json={
"search_workers": 4,
})
assert resp.status_code == 200
data = resp.json()
# POST /api/config returns the updated config object directly
def test_diagnostics(self, client):
resp = client.get("/api/diagnostics")
assert resp.status_code == 200
data = resp.json()
# Should have some diagnostic info
assert len(data) > 0
# ═══════════════════════════════════════════════════════════════════
# Dashboard
# ═══════════════════════════════════════════════════════════════════
class TestDashboard:
def test_dashboard(self, client):
resp = client.get("/api/dashboard")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
# Should have stats per vault
keys = [k.lower() for k in data.keys()]
assert any("file" in k for k in keys) or any("tag" in k for k in keys)
# ═══════════════════════════════════════════════════════════════════
# Vault Settings
# ═══════════════════════════════════════════════════════════════════
class TestVaultSettingsAPI:
def test_get_vault_settings(self, client):
resp = client.get("/api/vaults/TestVault/settings")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
def test_update_vault_settings(self, client):
resp = client.post("/api/vaults/TestVault/settings", json={
"ignored_dirs": ".trash,.git",
})
assert resp.status_code == 200
def test_get_all_settings(self, client):
resp = client.get("/api/vaults/settings/all")
assert resp.status_code == 200
data = resp.json()
assert "TestVault" in data
def test_settings_nonexistent_vault(self, client):
resp = client.get("/api/vaults/NoVault/settings")
assert resp.status_code == 404
# ═══════════════════════════════════════════════════════════════════
# Image & Attachments
# ═══════════════════════════════════════════════════════════════════
class TestAttachments:
def test_image_endpoint(self, client):
"""No actual image in test vault, but endpoint should respond"""
resp = client.get("/api/image/TestVault", params={"path": "nonexistent.png"})
# Should either 404 or serve a placeholder
assert resp.status_code in (200, 404, 415)
def test_attachments_stats(self, client):
resp = client.get("/api/attachments/stats", params={"vault": "TestVault"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict) or "stats" in data or "vault" in data
# ═══════════════════════════════════════════════════════════════════
# Vault Management (dynamic add/remove)
# ═══════════════════════════════════════════════════════════════════
class TestVaultManagement:
def test_vaults_status(self, client):
resp = client.get("/api/vaults/status")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict) or isinstance(data, list)
def test_add_nonexistent_vault(self, client):
"""Adding a vault to a path that doesn't exist should fail"""
resp = client.post("/api/vaults/add", json={
"name": "FakeVault",
"path": "/tmp/nonexistent_vault_path_xyz",
})
assert resp.status_code in (200, 400, 404, 500)
def test_delete_vault_not_found(self, client):
resp = client.delete("/api/vaults/NoSuchVault")
assert resp.status_code in (404, 200) # May 404 if not found
# ═══════════════════════════════════════════════════════════════════
# Humanize mtime (utility function)
# ═══════════════════════════════════════════════════════════════════
class TestHumanizeMtime:
def test_now(self, client):
"""Indirectly tested via /api/recent endpoint"""
pass
def test_humanize_mtime_now(self):
from backend.main import humanize_mtime
import time
assert "instant" in humanize_mtime(time.time())
def test_humanize_mtime_minutes(self):
from backend.main import humanize_mtime
import time
result = humanize_mtime(time.time() - 120)
assert "min" in result
def test_humanize_mtime_hours(self):
from backend.main import humanize_mtime
import time
result = humanize_mtime(time.time() - 7200)
assert "h" in result or "jour" in result
def test_humanize_mtime_days(self):
from backend.main import humanize_mtime
import time
result = humanize_mtime(time.time() - 172800) # 2 days
assert "j" in result
def test_humanize_mtime_old(self):
from backend.main import humanize_mtime
import time
result = humanize_mtime(time.time() - 86400 * 30)
assert "202" in result or result # Should show formatted date
# ═══════════════════════════════════════════════════════════════════
# Utility functions
# ═══════════════════════════════════════════════════════════════════
class TestHeadingSlugify:
def test_slugify_simple(self):
from backend.main import _heading_slugify
assert _heading_slugify("Hello World") == "hello-world"
def test_slugify_accented(self):
from backend.main import _heading_slugify
result = _heading_slugify("Café Crème")
assert "cafe" in result or "caf" in result
def test_slugify_strips_symbols(self):
from backend.main import _heading_slugify
result = _heading_slugify("Hello, World! Test?")
assert result.startswith("hello")
class TestRenderMarkdown:
def test_render_basic(self):
from backend.main import _render_markdown
result = _render_markdown("# Hello\n\nThis is a test.", "TestVault")
assert "<h1" in result
assert "Hello" in result
def test_render_with_code(self):
from backend.main import _render_markdown
result = _render_markdown("```python\nprint('hello')\n```", "TestVault")
assert "code" in result or "highlight" in result
def test_render_with_heading_ids(self):
from backend.main import _render_markdown
result = _render_markdown("# Title\n## Subtitle", "TestVault")
assert "id=" in result
def test_render_wikilink(self):
from backend.main import _render_markdown
result = _render_markdown("Link [[nonexistent.md]] here", "TestVault")
assert "wikilink" in result
def test_render_image_wikilink(self):
from backend.main import _render_markdown
result = _render_markdown("![[image.png]]", "TestVault")
assert "img" in result or "image" in result or "wikilink" in result
class TestResolveSafePath:
def test_resolve_simple(self):
from backend.main import _resolve_safe_path
from pathlib import Path
root = Path("/vaults/TestVault")
result = _resolve_safe_path(root, "notes/test.md")
assert str(result).replace("\\", "/").endswith("notes/test.md")
def test_resolve_traversal_attempt(self):
from backend.main import _resolve_safe_path
from pathlib import Path
root = Path("/vaults/TestVault")
try:
_resolve_safe_path(root, "../../etc/passwd")
assert False, "Should have raised"
except Exception as e:
assert "traversal" in str(e).lower() or "403" in str(e)
class TestBackupFile:
def test_backup(self, tmp_path, monkeypatch):
"""Backup is created by save operations"""
from backend.main import _backup_file
from pathlib import Path
import os
monkeypatch.chdir(tmp_path)
vault = tmp_path / "Vault"
vault.mkdir()
f = vault / "test.md"
f.write_text("original", encoding="utf-8")
_backup_file(f, "Vault", "test.md")
backup_dir = tmp_path / ".obsigate-backup"
assert backup_dir.exists()
class TestCheckVaultWritable:
def test_writable_dir(self, tmp_path):
from backend.main import _check_vault_writable
assert _check_vault_writable(tmp_path) is True
class TestConvertWikilinks:
def test_convert_wikilink(self):
from backend.main import _convert_wikilinks
# Missing wikilinks render as span.wikilink-missing
result = _convert_wikilinks("See [[Introduction à Python]] for details", "TestVault")
assert "Introduction" in result
assert "wikilink" in result
def test_convert_wikilink_with_alias(self):
from backend.main import _convert_wikilinks
result = _convert_wikilinks("See [[file.md|a different name]] here", "TestVault")
assert "a different name" in result
def test_convert_wikilink_image(self):
from backend.main import _convert_wikilinks
result = _convert_wikilinks("See ![[image.png]] here", "TestVault")
assert "image" in result or "img" in result
# ═══════════════════════════════════════════════════════════════════
# Secret Redactor
# ═══════════════════════════════════════════════════════════════════
class TestSecretRedactor:
def test_redact_jwt(self):
from backend.secret_redactor import redact_file_content
result = redact_file_content("eyJ" + "x"*20 + "." + "y"*20 + "." + "z"*20)
assert "[JWT MASQUÉ]" in result
def test_redact_api_key(self):
from backend.secret_redactor import redact_file_content
result = redact_file_content("key is sk-" + "a"*20)
assert "MASQUÉ" in result
def test_redact_github_token(self):
from backend.secret_redactor import redact_file_content
result = redact_file_content("ghp_" + "a"*36)
assert "[GITHUB_TOKEN MASQUÉ]" in result
def test_redact_connection_string(self):
from backend.secret_redactor import redact_file_content
result = redact_file_content("postgresql://user:***@localhost:5432/db")
assert "MASQUÉ" in result
def test_redact_plain_text(self):
from backend.secret_redactor import redact_file_content
result = redact_file_content("hello world this is safe")
assert result == "hello world this is safe"