CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
API (#72): - backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe, securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples, page /api autonome (liens /docs, /redoc, /openapi.json) - backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele - main.py: FastAPI enrichi + override app.openapi; routes /api et /api/ - ai_routes/bookslm_routes: response_model + doc SSE - frontend: entree 'API' du menu (i18n FR/EN) - tests/test_openapi.py (58 tests) AI UI: - BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error, message utilisateur correct (plus le placeholder vide), barre de contexte - AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de reecriture accessible a la place de window.prompt() - tests/frontend/ai.test.mjs (7 tests) + CI
244 lines
8.0 KiB
Python
244 lines
8.0 KiB
Python
"""Tests for the enriched OpenAPI 3.1 documentation (#72).
|
|
|
|
These tests validate the *documentation contract*: the schema is valid
|
|
OpenAPI 3.1, every operation is tagged, examples/security/error responses are
|
|
present, the representative endpoints expose response schemas, and the
|
|
interactive documentation pages are reachable.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from backend.main import app
|
|
from backend.openapi_docs import TAGS_METADATA, canonical_tag, tag_for_path
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def schema() -> dict:
|
|
"""The enriched OpenAPI schema (cached by FastAPI)."""
|
|
return app.openapi()
|
|
|
|
|
|
def _operations(schema: dict):
|
|
for path, ops in schema.get("paths", {}).items():
|
|
for method, op in ops.items():
|
|
if isinstance(op, dict):
|
|
yield path, method, op
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema validity & metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_openapi_is_version_3_1(schema):
|
|
assert schema["openapi"] == "3.1.0"
|
|
|
|
|
|
def test_info_metadata(schema):
|
|
info = schema["info"]
|
|
assert info["title"] == "ObsiGate API"
|
|
assert info.get("description")
|
|
assert info.get("contact")
|
|
assert info.get("license")
|
|
|
|
|
|
def test_servers_and_external_docs(schema):
|
|
assert schema.get("servers")
|
|
assert schema.get("externalDocs", {}).get("url")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tags_metadata_declared(schema):
|
|
declared = [t["name"] for t in schema["tags"]]
|
|
assert declared == [t["name"] for t in TAGS_METADATA]
|
|
assert "Files" in declared
|
|
assert "Search" in declared
|
|
assert "AI" in declared
|
|
assert "Backups" in declared
|
|
assert "Auth" in declared
|
|
|
|
|
|
def test_every_operation_has_a_tag(schema):
|
|
untagged = [(m, p) for p, m, op in _operations(schema) if not op.get("tags")]
|
|
assert untagged == []
|
|
|
|
|
|
def test_used_tags_are_declared(schema):
|
|
declared = {t["name"] for t in schema["tags"]}
|
|
used = {tag for _, _, op in _operations(schema) for tag in op.get("tags", [])}
|
|
assert used <= declared, f"undeclared tags: {used - declared}"
|
|
|
|
|
|
def test_router_tags_are_canonicalised(schema):
|
|
# Routers declare lowercase tags (e.g. tags=["auth"]); the enrichment must
|
|
# map them to the documented, capitalised names.
|
|
auth_ops = [op for p, _, op in _operations(schema) if p.startswith("/api/auth")]
|
|
assert auth_ops
|
|
assert all(op["tags"] == ["Auth"] for op in auth_ops)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path,expected",
|
|
[
|
|
("/api/file/{vault_name}/pdf/stream", "PDF"),
|
|
("/api/file/{vault_name}/backups", "Backups"),
|
|
("/api/file/{vault_name}", "Files"),
|
|
("/api/search/advanced", "Search"),
|
|
("/api/config/ai-models", "AI"),
|
|
("/api/ai/bookslm/chat", "BooksLM"),
|
|
("/api/vaults/status", "Vaults"),
|
|
("/api/recent", "Bookmarks"),
|
|
("/api/webhooks", "Webhooks"),
|
|
("/s/{token}", "Sharing"),
|
|
("/api/conflicts", "Conflicts"),
|
|
("/api/health", "System"),
|
|
],
|
|
)
|
|
def test_tag_for_path_rules(path, expected):
|
|
assert tag_for_path(path) == expected
|
|
|
|
|
|
def test_canonical_tag_helper():
|
|
assert canonical_tag("auth") == "Auth"
|
|
assert canonical_tag("booksLM") == "BooksLM"
|
|
assert canonical_tag("unknown") == "unknown"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Security, errors & examples
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_security_schemes(schema):
|
|
schemes = schema["components"]["securitySchemes"]
|
|
assert schemes["bearerAuth"]["scheme"] == "bearer"
|
|
assert schemes["cookieAuth"]["in"] == "cookie"
|
|
|
|
|
|
def test_common_error_responses_documented(schema):
|
|
# Most authenticated operations document the standard error envelope.
|
|
with_401 = [op for _, _, op in _operations(schema) if "401" in op.get("responses", {})]
|
|
with_404 = [op for _, _, op in _operations(schema) if "404" in op.get("responses", {})]
|
|
assert len(with_401) > 50
|
|
assert len(with_404) > 30
|
|
|
|
|
|
def test_key_endpoint_examples_present(schema):
|
|
improve = schema["paths"]["/api/ai/improve"]["post"]
|
|
example = improve["requestBody"]["content"]["application/json"]["example"]
|
|
assert example["provider"] == "deepseek"
|
|
assert improve["responses"]["200"]["content"]["application/json"]["example"]["result"]
|
|
|
|
create = schema["paths"]["/api/file/{vault_name}"]["post"]
|
|
assert "content" in create["requestBody"]["content"]["application/json"]["example"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_RESPONSE_MODEL_PATHS = [
|
|
("/api/recent", "get"),
|
|
("/api/bookmarks", "get"),
|
|
("/api/bookmarks/toggle", "post"),
|
|
("/api/saved-searches", "get"),
|
|
("/api/saved-searches", "post"),
|
|
("/api/file/{vault_name}/backups", "get"),
|
|
("/api/file/{vault_name}/diff", "get"),
|
|
("/api/file/{vault_name}/restore", "post"),
|
|
("/api/file/{vault_name}/backlinks", "get"),
|
|
("/api/file/{vault_name}/pdf/info", "get"),
|
|
("/api/search/replace", "post"),
|
|
("/api/index/reload/{vault_name}", "get"),
|
|
("/api/vaults/add", "post"),
|
|
("/api/vaults/status", "get"),
|
|
("/api/attachments/stats", "get"),
|
|
("/api/vaults/{vault_name}/settings", "get"),
|
|
("/api/vault/{vault_name}/files", "get"),
|
|
("/api/vaults/settings/all", "get"),
|
|
("/api/backups", "get"),
|
|
("/api/backups/content", "get"),
|
|
("/api/config", "get"),
|
|
("/api/config/ai-models", "get"),
|
|
("/api/diagnostics", "get"),
|
|
("/api/dashboard", "get"),
|
|
("/api/webhooks", "get"),
|
|
("/api/shares", "get"),
|
|
("/api/conflicts", "get"),
|
|
("/api/conflicts/resolve", "post"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("path,method", _RESPONSE_MODEL_PATHS)
|
|
def test_json_response_schema_present(schema, path, method):
|
|
op = schema["paths"][path][method]
|
|
content = op["responses"]["200"].get("content", {})
|
|
assert "application/json" in content, f"{method.upper()} {path} has no JSON response schema"
|
|
assert "schema" in content["application/json"]
|
|
|
|
|
|
def test_binary_endpoints_do_not_claim_json(schema):
|
|
for path in ["/api/file/{vault_name}/download", "/api/file/{vault_name}/pdf/stream"]:
|
|
op = schema["paths"][path]["get"]
|
|
content = op["responses"]["200"].get("content", {})
|
|
assert "application/json" not in content
|
|
|
|
|
|
def test_error_response_component(schema):
|
|
assert "ErrorResponse" in schema["components"]["schemas"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive documentation endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_api_landing_page(client):
|
|
resp = client.get("/api")
|
|
assert resp.status_code == 200
|
|
assert "text/html" in resp.headers["content-type"]
|
|
body = resp.text
|
|
assert "/docs" in body
|
|
assert "/redoc" in body
|
|
assert "/openapi.json" in body
|
|
|
|
|
|
def test_openapi_json_endpoint(client):
|
|
resp = client.get("/openapi.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["openapi"] == "3.1.0"
|
|
assert data["info"]["title"] == "ObsiGate API"
|
|
|
|
|
|
def test_swagger_and_redoc_reachable(client):
|
|
assert client.get("/docs").status_code == 200
|
|
assert client.get("/redoc").status_code == 200
|
|
|
|
|
|
def test_schema_is_json_serialisable(schema):
|
|
# Guards against non-serialisable objects leaking into the schema.
|
|
json.dumps(schema)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BooksLM context limits (used by the UI progress bar)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bookslm_context_exposes_limits(test_vault_dir):
|
|
from backend.bookslm import collect_directory_context
|
|
|
|
from pathlib import Path
|
|
|
|
ctx = collect_directory_context(Path(test_vault_dir), "")
|
|
assert "max_total_chars" in ctx
|
|
assert "max_files" in ctx
|
|
assert ctx["max_total_chars"] > 0
|