feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
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
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
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM integration tests for the AI tooling (#72 / AI UX).
|
||||
*
|
||||
* Covers the regressions fixed for the AI editor toolbar and BooksLM:
|
||||
* - custom-rewrite modal replaces window.prompt()
|
||||
* - Ctrl/Cmd+J inline-completion shortcut is bound exactly once
|
||||
* - BooksLM sends the user message (not the empty placeholder) and parses
|
||||
* the `{token}` SSE frames streamed by the backend
|
||||
* - BooksLM uses the authenticated request helper
|
||||
*
|
||||
* Usage: node tests/frontend/ai.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
|
||||
// ── JSDOM bootstrap ────────────────────────────────────────────────────────
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="editor-modal" class="active"></div>
|
||||
<div id="ai-toolbar-container"></div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true }
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.KeyboardEvent = w.KeyboardEvent;
|
||||
globalThis.MessageEvent = w.MessageEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.MutationObserver = w.MutationObserver;
|
||||
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
// ── Test harness ───────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function freshToolbarContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.id = "ai-toolbar-container";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
// A minimal fetch mock: AI status reports one available provider and
|
||||
// inline-complete counts how many times it was invoked.
|
||||
let inlineCompleteCalls = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/ai/status")) {
|
||||
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true, model: "deepseek-chat" } } }) };
|
||||
}
|
||||
if (u.includes("/api/config/ai-models")) {
|
||||
return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat"], source: "fallback" }) };
|
||||
}
|
||||
if (u.includes("/api/ai/inline-complete")) {
|
||||
inlineCompleteCalls++;
|
||||
return { ok: true, status: 200, json: async () => ({ result: "complété", provider: "deepseek" }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
|
||||
const ai = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href);
|
||||
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
|
||||
const { BooksLM } = bookslmMod;
|
||||
|
||||
// ── 1. Rewrite modal resolves with the typed instruction ──
|
||||
await test("rewrite modal returns the typed instruction on confirm", async () => {
|
||||
const promise = ai._promptRewriteInstruction();
|
||||
const overlay = document.querySelector(".ai-modal-overlay");
|
||||
assert.ok(overlay, "modal overlay should be rendered");
|
||||
const input = overlay.querySelector(".ai-modal-input");
|
||||
input.value = "Rends le ton plus formel";
|
||||
overlay.querySelector(".ai-modal-ok").click();
|
||||
const result = await promise;
|
||||
assert.equal(result, "Rends le ton plus formel");
|
||||
assert.equal(document.querySelector(".ai-modal-overlay"), null, "overlay removed");
|
||||
});
|
||||
|
||||
// ── 2. Rewrite modal cancels on Escape ──
|
||||
await test("rewrite modal returns null on Escape", async () => {
|
||||
const promise = ai._promptRewriteInstruction();
|
||||
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
const result = await promise;
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// ── 3. Ctrl+J inline completion fires exactly once after multiple toolbar builds ──
|
||||
await test("Ctrl+J shortcut is bound once across toolbar re-creations", async () => {
|
||||
const view = {
|
||||
state: {
|
||||
selection: { main: { from: 0, to: 5 } },
|
||||
doc: { toString: () => "hello", sliceString: () => "hello" },
|
||||
},
|
||||
dispatch: () => {},
|
||||
};
|
||||
inlineCompleteCalls = 0;
|
||||
await ai.createAIToolbar(freshToolbarContainer(), () => view);
|
||||
await ai.createAIToolbar(freshToolbarContainer(), () => view);
|
||||
|
||||
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "j", ctrlKey: true, bubbles: true }));
|
||||
await sleep(20);
|
||||
assert.equal(inlineCompleteCalls, 1, `expected 1 inline-complete call, got ${inlineCompleteCalls}`);
|
||||
});
|
||||
|
||||
// ── 4. BooksLM sends the user message and parses {token} SSE frames ──
|
||||
await test("BooksLM streams assistant tokens and sends the user message", async () => {
|
||||
const sse =
|
||||
'event: message\ndata: {"token":"Bonjour le monde","provider":"deepseek","model":"deepseek-chat"}\n\n' +
|
||||
"event: done\ndata: {}\n\n";
|
||||
|
||||
let capturedBody = null;
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [encoder.encode(sse)];
|
||||
const reader = {
|
||||
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
|
||||
};
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes("/api/ai/bookslm/chat")) {
|
||||
capturedBody = JSON.parse(opts.body);
|
||||
return { ok: true, status: 200, body: { getReader: () => reader } };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.innerHTML = `
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></div>
|
||||
<textarea></textarea>
|
||||
<button class="bookslm-btn-send"></button>`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const b = new BooksLM();
|
||||
b._panel = panel;
|
||||
b._vault = "TestVault";
|
||||
b._directory = "notes";
|
||||
b._contextFiles = [{ path: "notes/a.md" }];
|
||||
panel.querySelector("textarea").value = "Résume ce dossier";
|
||||
|
||||
await b._sendMessage();
|
||||
|
||||
const assistant = b._messages[b._messages.length - 1];
|
||||
assert.equal(assistant.role, "assistant");
|
||||
assert.equal(assistant.content, "Bonjour le monde", "assistant token should be rendered");
|
||||
assert.equal(capturedBody.message, "Résume ce dossier", "current user message must be sent");
|
||||
assert.equal(capturedBody.vault, "TestVault");
|
||||
assert.deepEqual(capturedBody.conversation_history, [], "history excludes the current message");
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
// ── 5. Source guards: no window.prompt() and authenticated BooksLM ──
|
||||
await test("ai.js no longer uses window.prompt()", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const raw = readFileSync(path.join(JS_DIR, "ai.js"), "utf-8");
|
||||
const code = raw.replace(/\/\*[\s\S]*?\*\//g, "").split("\n").map((l) => l.replace(/\/\/.*$/, "")).join("\n");
|
||||
assert.ok(!/\bprompt\s*\(/.test(code), "ai.js should not call prompt()");
|
||||
assert.ok(code.includes("_promptRewriteInstruction"), "rewrite modal helper present");
|
||||
});
|
||||
|
||||
await test("bookslm.js uses the authenticated API helper", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
|
||||
assert.ok(/import\s*\{[^}]*AuthManager[^}]*\}\s*from\s*['"]\.\/auth\.js['"]/.test(src), "AuthManager imported");
|
||||
assert.ok(src.includes("data.token"), "SSE token field parsed");
|
||||
});
|
||||
|
||||
// ── 6. Every t() key used by the AI tooling exists in FR and EN ──
|
||||
await test("AI/BooksLM i18n keys exist in fr.json and en.json", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const localesDir = path.resolve(JS_DIR, "..", "locales");
|
||||
const fr = JSON.parse(readFileSync(path.join(localesDir, "fr.json"), "utf-8"));
|
||||
const en = JSON.parse(readFileSync(path.join(localesDir, "en.json"), "utf-8"));
|
||||
|
||||
const keys = new Set();
|
||||
for (const file of ["ai.js", "bookslm.js"]) {
|
||||
const src = readFileSync(path.join(JS_DIR, file), "utf-8");
|
||||
for (const m of src.matchAll(/\bt\(\s*['"]([a-z0-9_.]+)['"]/g)) {
|
||||
keys.add(m[1]);
|
||||
}
|
||||
}
|
||||
assert.ok(keys.size > 20, "should discover AI translation keys");
|
||||
const missingFr = [...keys].filter((k) => !(k in fr));
|
||||
const missingEn = [...keys].filter((k) => !(k in en));
|
||||
assert.deepEqual(missingFr, [], `missing FR keys: ${missingFr.join(", ")}`);
|
||||
assert.deepEqual(missingEn, [], `missing EN keys: ${missingEn.join(", ")}`);
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user