feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
This commit is contained in:
@@ -607,6 +607,187 @@ async function main() {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 29. Resizable panel bounds and persistence (#81) ──
|
||||
await test("panel width is clamped and persisted", async () => {
|
||||
localStorage.clear();
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
assert.equal(b._clampPanelWidth(100), 320, "min width enforced");
|
||||
assert.equal(b._clampPanelWidth(5000), 1000, "max width enforced");
|
||||
localStorage.setItem("obsigate-bookslm-width", "640");
|
||||
b._applyPanelWidth();
|
||||
assert.equal(b._panel.style.width, "640px", "persisted width applied");
|
||||
assert.ok(b._panel.querySelector(".bookslm-resize-handle"), "resize handle present");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 30. Ad-hoc context chips (#81) ──
|
||||
await test("ad-hoc files and directories render as chips", async () => {
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._addAdhocFile("notes/a.md");
|
||||
b._addAdhocDir("projets/");
|
||||
const chips = b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip");
|
||||
assert.equal(chips.length, 2, "one chip per ad-hoc entry");
|
||||
assert.ok(b._adhocDirs[0].path === "projets", "trailing slash stripped");
|
||||
b._removeAdhocFile("notes/a.md");
|
||||
assert.equal(b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip").length, 1);
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 31. Chat payload carries extras, images and skill (#81) ──
|
||||
await test("send payload includes extra context, images and skill", async () => {
|
||||
localStorage.clear();
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._mode = MODE.GENERAL;
|
||||
b._vault = "V";
|
||||
b._messages = [];
|
||||
b._adhocFiles = [{ path: "a.md" }];
|
||||
b._adhocDirs = [{ path: "sub" }];
|
||||
b._images = [{ path: "pics/x.png", name: "x.png" }];
|
||||
b._activeSkill = "research";
|
||||
b._panel.querySelector("textarea").value = "Analyse";
|
||||
|
||||
let posted = null;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
|
||||
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
|
||||
};
|
||||
await b._sendMessage();
|
||||
|
||||
assert.ok(posted, "chat request posted");
|
||||
assert.deepEqual(posted.extra_files, ["a.md"]);
|
||||
assert.deepEqual(posted.extra_directories, ["sub"]);
|
||||
assert.deepEqual(posted.images, [{ path: "pics/x.png" }]);
|
||||
assert.equal(posted.skill, "research");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
|
||||
await test("images route to /chat even in agent mode", async () => {
|
||||
const b = new BooksLM();
|
||||
b._agentMode = true;
|
||||
let url = "";
|
||||
globalThis.fetch = async (u) => {
|
||||
url = String(u);
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
await b._postChat({ images: [{ data: "AAA", mime_type: "image/png" }] });
|
||||
assert.ok(url.includes("/api/ai/bookslm/chat"), "image request uses /chat");
|
||||
await b._postChat({ images: [] });
|
||||
assert.ok(url.includes("/api/ai/bookslm/agent"), "text request uses /agent in agent mode");
|
||||
});
|
||||
|
||||
// ── 33. Vision gate (#81) ──
|
||||
await test("_modelSupportsVision reflects selected model capabilities", async () => {
|
||||
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
|
||||
provider: "deepseek", model: "deepseek-chat", capabilities: { chat: true, vision: false },
|
||||
}));
|
||||
const b = new BooksLM();
|
||||
assert.equal(await b._modelSupportsVision(), false);
|
||||
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
|
||||
provider: "qwencloud", model: "qwen-vl-max", capabilities: { chat: true, vision: true },
|
||||
}));
|
||||
assert.equal(await b._modelSupportsVision(), true);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 34. `/` command menu and selection (#81) ──
|
||||
await test("slash command menu lists skills and selects one", async () => {
|
||||
localStorage.clear();
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ai/skills")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({
|
||||
skills: [{ id: "research", label: "Recherche", icon: "🔎", type: "skill", description: "d" }],
|
||||
commands: [{ id: "help", label: "Aide", icon: "❓", type: "admin", description: "h" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const ta = b._panel.querySelector("textarea");
|
||||
ta.value = "/res";
|
||||
ta.selectionStart = ta.selectionEnd = 4;
|
||||
await b._showCommandMenu("res");
|
||||
const menu = b._panel.querySelector(".bookslm-command-menu");
|
||||
assert.ok(!menu.classList.contains("hidden"), "command menu shown");
|
||||
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
||||
b._selectCommand({ id: "research", label: "Recherche", type: "skill" });
|
||||
assert.equal(b._activeSkill, "research");
|
||||
assert.ok(b._panel.querySelector(".bookslm-chip-skill"), "skill chip rendered");
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 35. `@` mention menu adds ad-hoc context / images (#81) ──
|
||||
await test("mention menu adds files, directories and images", async () => {
|
||||
localStorage.clear();
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/api/tree-search")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ results: [
|
||||
{ path: "notes/a.md", type: "file" },
|
||||
{ path: "notes", type: "dir" },
|
||||
] }),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._vault = "V";
|
||||
await b._showMentionMenu("notes");
|
||||
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
||||
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2);
|
||||
b._selectMention({ id: "notes/a.md", type: "file" });
|
||||
assert.ok(b._adhocFiles.some((f) => f.path === "notes/a.md"));
|
||||
b._selectMention({ id: "notes", type: "dir" });
|
||||
assert.ok(b._adhocDirs.some((d) => d.path === "notes"));
|
||||
b._selectMention({ id: "pics/x.png", type: "file" });
|
||||
assert.ok(b._images.some((img) => img.path === "pics/x.png"));
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 36. Capability checklist rendering (#81) ──
|
||||
await test("capability list renders checked and unchecked flags", () => {
|
||||
const caps = {
|
||||
chat: true, embeddings: false, rerank: false, images: false,
|
||||
video: false, audio_speech: false, audio_transcription: false, vision: true,
|
||||
};
|
||||
const box = ai.renderCapabilityList(caps);
|
||||
assert.equal(box.querySelectorAll(".ai-cap-item").length, 8);
|
||||
assert.equal(box.querySelectorAll(".ai-cap-item.on").length, 2);
|
||||
assert.ok(box.textContent.includes("☑"));
|
||||
assert.ok(box.textContent.includes("□"));
|
||||
});
|
||||
|
||||
// ── 37. Admin commands handled locally (#81) ──
|
||||
await test("admin commands are parsed without an LLM call", async () => {
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._messages = [];
|
||||
let fetched = false;
|
||||
globalThis.fetch = async () => { fetched = true; return { ok: true, status: 200, json: async () => ({}) }; };
|
||||
const handled = b._maybeRunAdminWithArg("/model deepseek-chat");
|
||||
assert.equal(handled, true);
|
||||
assert.equal(fetched, false, "no LLM request for admin command");
|
||||
assert.ok(b._messages.length === 1, "assistant note pushed");
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -256,6 +256,37 @@ class TestListModelsEndpoint:
|
||||
assert hdrs["api-key"] == "fake-xiaomi-key"
|
||||
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}"
|
||||
|
||||
def test_models_include_capabilities(self, admin_client, monkeypatch):
|
||||
"""The model list must expose per-model capability flags (#81)."""
|
||||
from backend import ai as aimod
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "qwencloud"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
caps = data.get("capabilities") or {}
|
||||
assert "qwen-vl-max" in caps
|
||||
assert caps["qwen-vl-max"]["vision"] is True
|
||||
|
||||
def test_model_capabilities_endpoint(self, admin_client):
|
||||
"""GET /api/ai/model-capabilities returns the curated flags."""
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/ai/model-capabilities",
|
||||
params={"provider": "deepseek", "model": "deepseek-chat"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["provider"] == "deepseek"
|
||||
assert data["capabilities"]["chat"] is True
|
||||
assert data["capabilities"]["vision"] is False
|
||||
|
||||
def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch):
|
||||
"""The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com.
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for multimodal (vision) support and ad-hoc context (#81)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from backend.ai_chat import _content_to_gemini_parts, _gemini_contents
|
||||
from backend.bookslm import (
|
||||
collect_adhoc_context,
|
||||
is_image_path,
|
||||
load_vault_image_data_url,
|
||||
merge_contexts,
|
||||
)
|
||||
|
||||
|
||||
class TestGeminiParts:
|
||||
def test_plain_string(self):
|
||||
assert _content_to_gemini_parts("hello") == [{"text": "hello"}]
|
||||
|
||||
def test_none_becomes_empty_text(self):
|
||||
assert _content_to_gemini_parts(None) == [{"text": ""}]
|
||||
|
||||
def test_text_and_image_data_url(self):
|
||||
raw = base64.b64encode(b"\x89PNG").decode()
|
||||
parts = _content_to_gemini_parts([
|
||||
{"type": "text", "text": "décris"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{raw}"}},
|
||||
])
|
||||
assert parts[0] == {"text": "décris"}
|
||||
assert parts[1] == {"inlineData": {"mimeType": "image/png", "data": raw}}
|
||||
|
||||
def test_remote_image_url_becomes_file_data(self):
|
||||
parts = _content_to_gemini_parts([
|
||||
{"type": "image_url", "image_url": {"url": "https://x.test/a.png"}},
|
||||
])
|
||||
assert parts == [{"fileData": {"fileUri": "https://x.test/a.png"}}]
|
||||
|
||||
def test_gemini_contents_handles_multimodal_user_message(self):
|
||||
raw = base64.b64encode(b"img").decode()
|
||||
system, contents = _gemini_contents([
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "hi"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{raw}"}},
|
||||
]},
|
||||
])
|
||||
assert system == "sys"
|
||||
assert contents[0]["role"] == "user"
|
||||
assert contents[0]["parts"][1]["inlineData"]["mimeType"] == "image/jpeg"
|
||||
|
||||
|
||||
class TestImageHelpers:
|
||||
def test_is_image_path(self):
|
||||
assert is_image_path("a/b.png")
|
||||
assert is_image_path("photo.JPEG")
|
||||
assert not is_image_path("note.md")
|
||||
|
||||
def test_load_vault_image_data_url(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "pic.png").write_bytes(b"PNGDATA")
|
||||
url = load_vault_image_data_url(vault, "pic.png")
|
||||
assert url is not None
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
assert base64.b64decode(url.split(",", 1)[1]) == b"PNGDATA"
|
||||
|
||||
def test_load_rejects_path_traversal(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
outside = tmp_path / "outside.png"
|
||||
outside.write_bytes(b"x")
|
||||
assert load_vault_image_data_url(vault, "../outside.png") is None
|
||||
|
||||
def test_load_rejects_non_image(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("hi", encoding="utf-8")
|
||||
assert load_vault_image_data_url(vault, "note.md") is None
|
||||
|
||||
|
||||
class TestAdhocContext:
|
||||
def test_files_and_directories(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
(vault / "sub").mkdir(parents=True)
|
||||
(vault / "a.md").write_text("# A", encoding="utf-8")
|
||||
(vault / "sub" / "b.md").write_text("# B", encoding="utf-8")
|
||||
|
||||
ctx = collect_adhoc_context(vault, files=["a.md"], directories=["sub"])
|
||||
paths = {f["path"] for f in ctx["files"]}
|
||||
assert paths == {"a.md", "sub/b.md"}
|
||||
|
||||
def test_deduplicates(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "a.md").write_text("# A", encoding="utf-8")
|
||||
ctx = collect_adhoc_context(vault, files=["a.md"], directories=["."])
|
||||
assert ctx["file_count"] == 1
|
||||
|
||||
def test_merge_contexts(self):
|
||||
base = {
|
||||
"files": [{"path": "a.md", "content": "aaa"}],
|
||||
"file_count": 1, "total_chars": 3, "directory_tree": "tree", "scope": "directory",
|
||||
}
|
||||
extra = {
|
||||
"files": [
|
||||
{"path": "a.md", "content": "aaa"},
|
||||
{"path": "b.md", "content": "bb"},
|
||||
],
|
||||
"file_count": 2, "total_chars": 5, "directory_tree": "", "scope": "directory",
|
||||
}
|
||||
merged = merge_contexts(base, extra)
|
||||
assert [f["path"] for f in merged["files"]] == ["a.md", "b.md"]
|
||||
assert merged["total_chars"] == 5
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for the curated model-capability table (#81)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.model_capabilities import (
|
||||
CAPABILITY_KEYS,
|
||||
get_capabilities_for_models,
|
||||
get_model_capabilities,
|
||||
model_supports_vision,
|
||||
)
|
||||
|
||||
|
||||
class TestCapabilityShape:
|
||||
def test_every_result_has_all_keys(self):
|
||||
for provider, model in [
|
||||
("deepseek", "deepseek-chat"),
|
||||
("gemini", "gemini-2.0-flash"),
|
||||
("qwencloud", "qwen-vl-max"),
|
||||
("openrouter", "unknown-model"),
|
||||
]:
|
||||
caps = get_model_capabilities(provider, model)
|
||||
assert set(caps.keys()) == set(CAPABILITY_KEYS)
|
||||
assert all(isinstance(v, bool) for v in caps.values())
|
||||
|
||||
|
||||
class TestVisionDetection:
|
||||
def test_vision_models_detected(self):
|
||||
assert model_supports_vision("qwencloud", "qwen-vl-max")
|
||||
assert model_supports_vision("openrouter", "openai/gpt-4o")
|
||||
assert model_supports_vision("gemini", "gemini-2.0-flash")
|
||||
assert model_supports_vision("mistral", "pixtral-large-latest")
|
||||
|
||||
def test_text_models_not_vision(self):
|
||||
assert not model_supports_vision("deepseek", "deepseek-chat")
|
||||
assert not model_supports_vision("xiaomi", "mimo-v2.5-pro")
|
||||
|
||||
def test_vision_implies_chat(self):
|
||||
caps = get_model_capabilities("qwencloud", "qwen-vl-plus")
|
||||
assert caps["vision"] is True
|
||||
assert caps["chat"] is True
|
||||
|
||||
|
||||
class TestSpecialModalities:
|
||||
def test_embeddings(self):
|
||||
caps = get_model_capabilities("openai", "text-embedding-3-small")
|
||||
assert caps["embeddings"] is True
|
||||
assert caps["chat"] is False
|
||||
|
||||
def test_rerank(self):
|
||||
caps = get_model_capabilities("nvidia", "nvidia/llama-3.2-nv-rerankqa-1b-v2")
|
||||
assert caps["rerank"] is True
|
||||
|
||||
def test_audio_transcription(self):
|
||||
caps = get_model_capabilities("xiaomi", "mimo-v2.5-asr")
|
||||
assert caps["audio_transcription"] is True
|
||||
|
||||
def test_audio_speech(self):
|
||||
caps = get_model_capabilities("xiaomi", "mimo-v2.5-tts")
|
||||
assert caps["audio_speech"] is True
|
||||
|
||||
def test_image_generation(self):
|
||||
caps = get_model_capabilities("openai", "dall-e-3")
|
||||
assert caps["images"] is True
|
||||
|
||||
|
||||
class TestProviderDefaults:
|
||||
def test_unknown_model_uses_provider_default(self):
|
||||
caps = get_model_capabilities("deepseek", "some-new-model")
|
||||
assert caps["chat"] is True
|
||||
assert caps["vision"] is False
|
||||
|
||||
def test_unknown_provider_defaults_to_chat(self):
|
||||
caps = get_model_capabilities("nope", "mystery")
|
||||
assert caps["chat"] is True
|
||||
|
||||
def test_empty_model_uses_provider_default(self):
|
||||
assert get_model_capabilities("gemini", "")["vision"] is True
|
||||
|
||||
|
||||
class TestBatch:
|
||||
def test_capabilities_for_models_map(self):
|
||||
result = get_capabilities_for_models(
|
||||
"qwencloud", ["qwen-max", "qwen-vl-max"]
|
||||
)
|
||||
assert result["qwen-max"]["vision"] is False
|
||||
assert result["qwen-vl-max"]["vision"] is True
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for the AI assistant skills & slash-commands (#81)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.skills as skills_mod
|
||||
from backend.skills import (
|
||||
BUILTIN_SKILLS,
|
||||
create_user_skill,
|
||||
delete_user_skill,
|
||||
get_skill_prompt,
|
||||
list_skills,
|
||||
list_user_skills,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_store(tmp_path, monkeypatch):
|
||||
"""Point the skills store at an isolated temp file."""
|
||||
store = tmp_path / "skills.json"
|
||||
monkeypatch.setattr(skills_mod, "SKILLS_FILE", store)
|
||||
return store
|
||||
|
||||
|
||||
USER = {"username": "alice"}
|
||||
|
||||
|
||||
class TestBuiltinSkills:
|
||||
def test_expected_skills_present(self):
|
||||
ids = {s["id"] for s in BUILTIN_SKILLS}
|
||||
for expected in [
|
||||
"research", "create-new-skill", "resume", "actions", "reformuler",
|
||||
"correction", "brainstorm", "plan", "ask", "meeting-note", "livrable",
|
||||
]:
|
||||
assert expected in ids
|
||||
|
||||
def test_every_skill_has_label_and_description(self):
|
||||
for skill in BUILTIN_SKILLS:
|
||||
assert skill["label"]
|
||||
assert skill["description"]
|
||||
|
||||
def test_builtin_prompt_resolves(self, skill_store):
|
||||
prompt = get_skill_prompt("research", USER)
|
||||
assert prompt and "RECHERCHE" in prompt
|
||||
|
||||
def test_unknown_skill_returns_none(self, skill_store):
|
||||
assert get_skill_prompt("does-not-exist", USER) is None
|
||||
|
||||
def test_list_includes_admin_commands(self, skill_store):
|
||||
data = list_skills(USER)
|
||||
command_ids = {c["id"] for c in data["commands"]}
|
||||
assert {"help", "providers", "provider", "model", "keys"} <= command_ids
|
||||
|
||||
|
||||
class TestUserSkills:
|
||||
def test_create_and_list(self, skill_store):
|
||||
skill = create_user_skill(USER, {
|
||||
"id": "my-skill", "label": "Mon skill", "prompt": "Fais X",
|
||||
})
|
||||
assert skill["id"] == "my-skill"
|
||||
assert skill["custom"] is True
|
||||
stored = list_user_skills(USER)
|
||||
assert [s["id"] for s in stored] == ["my-skill"]
|
||||
assert skill_store.exists()
|
||||
|
||||
def test_create_rejects_reserved_id(self, skill_store):
|
||||
with pytest.raises(ValueError):
|
||||
create_user_skill(USER, {"id": "research", "label": "x", "prompt": "y"})
|
||||
|
||||
def test_create_rejects_duplicate(self, skill_store):
|
||||
create_user_skill(USER, {"id": "dup", "label": "x", "prompt": "y"})
|
||||
with pytest.raises(ValueError):
|
||||
create_user_skill(USER, {"id": "dup", "label": "x", "prompt": "y"})
|
||||
|
||||
def test_create_rejects_bad_id(self, skill_store):
|
||||
with pytest.raises(ValueError):
|
||||
create_user_skill(USER, {"id": "Bad ID!", "label": "x", "prompt": "y"})
|
||||
|
||||
def test_create_requires_prompt(self, skill_store):
|
||||
with pytest.raises(ValueError):
|
||||
create_user_skill(USER, {"id": "noprompt", "label": "x", "prompt": ""})
|
||||
|
||||
def test_user_skill_prompt_resolves(self, skill_store):
|
||||
create_user_skill(USER, {"id": "custom", "label": "C", "prompt": "Instruction Z"})
|
||||
assert get_skill_prompt("custom", USER) == "Instruction Z"
|
||||
|
||||
def test_skills_are_per_user(self, skill_store):
|
||||
create_user_skill(USER, {"id": "only-alice", "label": "A", "prompt": "p"})
|
||||
assert list_user_skills({"username": "bob"}) == []
|
||||
|
||||
def test_delete(self, skill_store):
|
||||
create_user_skill(USER, {"id": "temp", "label": "T", "prompt": "p"})
|
||||
assert delete_user_skill(USER, "temp") is True
|
||||
assert list_user_skills(USER) == []
|
||||
assert delete_user_skill(USER, "temp") is False
|
||||
|
||||
def test_corrupted_store_is_ignored(self, skill_store):
|
||||
skill_store.write_text("{not json", encoding="utf-8")
|
||||
assert list_user_skills(USER) == []
|
||||
# Creating still works (store is rewritten).
|
||||
create_user_skill(USER, {"id": "recover", "label": "R", "prompt": "p"})
|
||||
data = json.loads(skill_store.read_text(encoding="utf-8"))
|
||||
assert data["alice"][0]["id"] == "recover"
|
||||
|
||||
|
||||
# ── Endpoint tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_client(tmp_path, monkeypatch):
|
||||
"""TestClient with auth enabled and an isolated data dir."""
|
||||
import asyncio
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
from backend.auth.password import hash_password
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"testuser": {
|
||||
"id": "testuser-1",
|
||||
"username": "testuser",
|
||||
"display_name": "Test User",
|
||||
"password_hash": hash_password("TestPass123!"),
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp))
|
||||
monkeypatch.setattr(skills_mod, "SKILLS_FILE", Path("data/skills.json"))
|
||||
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
|
||||
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = str(tmp / "vault")
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
from backend.indexer import index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
client = TestClient(backend.main.app)
|
||||
yield client
|
||||
client.close()
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["OBSIGATE_AUTH_ENABLED", "OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD",
|
||||
"OBSIGATE_WATCHER_ENABLED", "VAULT_1_NAME", "VAULT_1_PATH"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _token(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
class TestSkillsEndpoints:
|
||||
def test_list_requires_auth(self, skills_client):
|
||||
assert skills_client.get("/api/ai/skills").status_code == 401
|
||||
|
||||
def test_list_returns_builtins_and_commands(self, skills_client):
|
||||
token = _token(skills_client)
|
||||
resp = skills_client.get("/api/ai/skills", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
ids = {s["id"] for s in data["skills"]}
|
||||
assert "research" in ids
|
||||
assert any(c["id"] == "help" for c in data["commands"])
|
||||
|
||||
def test_create_and_delete_roundtrip(self, skills_client):
|
||||
token = _token(skills_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
resp = skills_client.post(
|
||||
"/api/ai/skills",
|
||||
json={"id": "custom", "label": "Custom", "prompt": "Do it"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["id"] == "custom"
|
||||
|
||||
listed = skills_client.get("/api/ai/skills", headers=headers).json()
|
||||
assert "custom" in {s["id"] for s in listed["skills"]}
|
||||
|
||||
deleted = skills_client.delete("/api/ai/skills/custom", headers=headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
def test_create_invalid_id_returns_400(self, skills_client):
|
||||
token = _token(skills_client)
|
||||
resp = skills_client.post(
|
||||
"/api/ai/skills",
|
||||
json={"id": "Bad!", "label": "x", "prompt": "y"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_delete_unknown_returns_404(self, skills_client):
|
||||
token = _token(skills_client)
|
||||
resp = skills_client.delete(
|
||||
"/api/ai/skills/unknown", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user