"""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