feat(ai): ecosysteme d'outils phase 2 - recherche a cle, cache/retry, Playwright, crawl, Gitea/GitHub, documents (#92)
This commit is contained in:
@@ -22,6 +22,23 @@ def _reset_tool_ratelimit():
|
||||
ratelimit.reset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_web_cache():
|
||||
"""Web cache off by default: tests stay hermetic (no cross-test hits).
|
||||
|
||||
tests/test_web_cache.py re-enables it explicitly with a tmp path.
|
||||
"""
|
||||
from backend.tools import webcache
|
||||
|
||||
saved_path = os.environ.get("OBSIGATE_WEB_CACHE_PATH")
|
||||
os.environ["OBSIGATE_WEB_CACHE_TTL"] = "0"
|
||||
yield
|
||||
if saved_path is None:
|
||||
os.environ.pop("OBSIGATE_WEB_CACHE_PATH", None)
|
||||
else:
|
||||
os.environ["OBSIGATE_WEB_CACHE_PATH"] = saved_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env():
|
||||
"""Ensure no vault env vars leak between tests — but preserve test vault config."""
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Unit tests for the connected sources (#92): Gitea & GitHub tools.
|
||||
|
||||
All HTTP calls are mocked (httpx.request monkeypatched) — the CI never talks
|
||||
to a real Gitea/GitHub instance.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.tools.connected as connected
|
||||
from backend.tools.context import ToolContext, ToolError, ToolMode
|
||||
from backend.tools.registry import get_tool
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, json_data: Any = None, status_code: int = 200):
|
||||
self._json = json_data
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
import httpx
|
||||
|
||||
raise httpx.HTTPStatusError("boom", request=None, response=self) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitea_env(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_GITEA_URL", "https://git.example.net")
|
||||
monkeypatch.setenv("OBSIGATE_GITEA_TOKEN", "tok-gitea")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_env(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_GITHUB_TOKEN", "tok-gh")
|
||||
|
||||
|
||||
def _patch_request(monkeypatch, handler):
|
||||
def fake_request(method, url, headers=None, timeout=None, follow_redirects=False, **kw):
|
||||
captured = {"method": method, "url": str(url), "headers": headers or {},
|
||||
"params": kw.get("params")}
|
||||
return handler(captured)
|
||||
|
||||
monkeypatch.setattr(connected.httpx, "request", fake_request)
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
@pytest.mark.parametrize("name", ["git_list_repos", "git_search_issues", "git_get_file"])
|
||||
def test_tools_registered_read(self, name):
|
||||
from backend.tools.context import ToolRisk
|
||||
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.READ
|
||||
assert "gitea" in spec.description or "github" in spec.description.lower()
|
||||
|
||||
|
||||
class TestConfiguration:
|
||||
def test_gitea_requires_base_url(self, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_GITEA_URL", raising=False)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
connected._provider_base("gitea")
|
||||
assert ei.value.code == "provider_not_configured"
|
||||
|
||||
def test_unknown_provider_rejected(self):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
connected._provider_base("gitlab")
|
||||
assert ei.value.code == "invalid_arguments"
|
||||
|
||||
def test_gitea_token_sent_as_header(self, gitea_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def handler(captured_req):
|
||||
captured.update(captured_req)
|
||||
return FakeResponse(json_data={"data": []})
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
connected.git_list_repos(_ctx(), connected.GitProviderInput(provider="gitea"))
|
||||
assert captured["headers"]["Authorization"] == "token tok-gitea"
|
||||
|
||||
|
||||
class TestListRepos:
|
||||
def test_gitea_search_endpoint(self, gitea_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def handler(captured_req):
|
||||
captured.update(captured_req)
|
||||
return FakeResponse(json_data={"data": [
|
||||
{"name": "ObsiGate", "full_name": "bruno/ObsiGate",
|
||||
"html_url": "https://git.example.net/bruno/ObsiGate",
|
||||
"description": "vault gateway", "updated_at": "2026-09-01",
|
||||
"private": False},
|
||||
]})
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
out = connected.git_list_repos(_ctx(), connected.GitProviderInput(provider="gitea"))
|
||||
assert "/api/v1/repos/search" in captured["url"]
|
||||
assert out["repos"][0]["name"] == "ObsiGate"
|
||||
assert out["count"] == 1
|
||||
|
||||
def test_github_single_repo(self, github_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def handler(captured_req):
|
||||
captured.update(captured_req)
|
||||
return FakeResponse(json_data={
|
||||
"name": "ObsiGate", "full_name": "bruno/ObsiGate",
|
||||
"html_url": "https://github.com/bruno/ObsiGate",
|
||||
"description": "", "updated_at": "2026-09-02", "private": True,
|
||||
})
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
out = connected.git_list_repos(_ctx(), connected.GitProviderInput(
|
||||
provider="github", repo="bruno/ObsiGate"))
|
||||
assert captured["url"].endswith("/repos/bruno/ObsiGate")
|
||||
assert out["repos"][0]["full_name"] == "bruno/ObsiGate"
|
||||
assert out["repos"][0]["private"] is True
|
||||
|
||||
|
||||
class TestSearchIssues:
|
||||
def test_gitea_scoped_to_repo(self, gitea_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def handler(captured_req):
|
||||
captured.update(captured_req)
|
||||
return FakeResponse(json_data=[
|
||||
{"number": 12, "title": "Bug affichage", "html_url": "https://x/12",
|
||||
"state": "open"},
|
||||
])
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
out = connected.git_search_issues(_ctx(), connected.GitSearchIssuesInput(
|
||||
provider="gitea", query="affichage", repo="bruno/ObsiGate"))
|
||||
assert "/repos/bruno/ObsiGate/issues" in captured["url"]
|
||||
assert out["issues"][0]["id"] == 12
|
||||
assert out["issues"][0]["pull_request"] is False
|
||||
|
||||
def test_github_search_syntax(self, github_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def handler(captured_req):
|
||||
captured.update(captured_req)
|
||||
return FakeResponse(json_data={"items": [
|
||||
{"number": 5, "title": "Crash on save", "html_url": "https://gh/5",
|
||||
"state": "open", "pull_request": {"url": "x"}},
|
||||
]})
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
out = connected.git_search_issues(_ctx(), connected.GitSearchIssuesInput(
|
||||
provider="github", query="crash", repo="bruno/ObsiGate", state="open"))
|
||||
assert "/search/issues" in captured["url"]
|
||||
assert "repo:bruno/ObsiGate" in captured["params"]["q"]
|
||||
assert out["issues"][0]["pull_request"] is True
|
||||
|
||||
|
||||
class TestGetFile:
|
||||
def test_gitea_base64_content_decoded(self, gitea_env, monkeypatch):
|
||||
payload = base64.b64encode("# Readme\n\nBonjour".encode()).decode()
|
||||
|
||||
def handler(_captured):
|
||||
return FakeResponse(json_data={
|
||||
"path": "README.md", "size": 15, "encoding": "base64", "content": payload,
|
||||
})
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
out = connected.git_get_file(_ctx(), connected.GitGetFileInput(
|
||||
provider="gitea", repo="bruno/ObsiGate", path="README.md"))
|
||||
assert "Bonjour" in out["content"]
|
||||
assert out["truncated"] is False
|
||||
|
||||
def test_github_ref_parameter(self, github_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def handler(captured_req):
|
||||
captured.update(captured_req)
|
||||
return FakeResponse(json_data={
|
||||
"path": "a.md", "size": 1, "encoding": "base64",
|
||||
"content": base64.b64encode(b"x").decode(),
|
||||
})
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
connected.git_get_file(_ctx(), connected.GitGetFileInput(
|
||||
provider="github", repo="o/r", path="a.md", ref="v2.9.0"))
|
||||
assert captured["url"].endswith("?ref=v2.9.0")
|
||||
|
||||
def test_missing_repo_or_path_rejected(self, gitea_env):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
connected.git_get_file(_ctx(), connected.GitGetFileInput(
|
||||
provider="gitea", repo="", path="a.md"))
|
||||
assert ei.value.code == "invalid_arguments"
|
||||
|
||||
|
||||
class TestErrors:
|
||||
def test_404_maps_to_not_found(self, gitea_env, monkeypatch):
|
||||
def handler(_captured):
|
||||
return FakeResponse(json_data={}, status_code=404)
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
connected.git_list_repos(_ctx(), connected.GitProviderInput(provider="gitea"))
|
||||
assert ei.value.code == "not_found"
|
||||
|
||||
def test_401_maps_to_permission_denied(self, gitea_env, monkeypatch):
|
||||
def handler(_captured):
|
||||
return FakeResponse(json_data={}, status_code=401)
|
||||
|
||||
_patch_request(monkeypatch, handler)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
connected.git_list_repos(_ctx(), connected.GitProviderInput(provider="gitea"))
|
||||
assert ei.value.code == "permission_denied"
|
||||
|
||||
def test_network_error_maps_to_tool_error(self, gitea_env, monkeypatch):
|
||||
import httpx
|
||||
|
||||
def fake_request(*a, **kw):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
monkeypatch.setattr(connected.httpx, "request", fake_request)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
connected.git_list_repos(_ctx(), connected.GitProviderInput(provider="gitea"))
|
||||
assert ei.value.code == "connected_source_unavailable"
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Unit tests for the bounded site crawler (#92): crawl_site (WRITE + confirmation)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.tools.crawler as crawler
|
||||
from backend.tools.api import ToolConfirmationRequired, ToolContext, ToolError, call_tool
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, content: bytes = b"", status_code: int = 200,
|
||||
headers: dict | None = None):
|
||||
self.content = content
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {"content-type": "text/html; charset=utf-8"}
|
||||
self.encoding = "utf-8"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(
|
||||
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||
audit_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path, monkeypatch):
|
||||
vault_dir = tmp_path / "Vault"
|
||||
vault_dir.mkdir()
|
||||
monkeypatch.setitem(__import__("backend.indexer", fromlist=["index"]).index,
|
||||
"Vault", {"name": "Vault", "path": str(vault_dir), "config": {}})
|
||||
return vault_dir
|
||||
|
||||
|
||||
PAGE_A = (
|
||||
b"<html><head><title>Docs</title></head><body>"
|
||||
b"<p>Bienvenue sur la documentation.</p>"
|
||||
b'<a href="/page-b">Suite</a><a href="https://other.dev/x">ext</a>'
|
||||
b"</body></html>"
|
||||
)
|
||||
PAGE_B = (
|
||||
b"<html><head><title>Page B</title></head><body><p>Details ici.</p></body></html>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_urls(monkeypatch):
|
||||
"""Skip the DNS-based SSRF guard: test hosts are fake, HTTP is mocked."""
|
||||
monkeypatch.setattr(crawler, "_assert_public_http_url", lambda url: url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_pages(monkeypatch, local_urls):
|
||||
def fake_get(url, **kw):
|
||||
url = str(url)
|
||||
if url.endswith("/page-b"):
|
||||
return FakeResponse(content=PAGE_B)
|
||||
return FakeResponse(content=PAGE_A)
|
||||
|
||||
monkeypatch.setattr(crawler.httpx, "get", fake_get)
|
||||
|
||||
|
||||
class TestConfirmation:
|
||||
def test_requires_confirmation(self, vault, two_pages):
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("crawl_site", _ctx(), {
|
||||
"url": "https://docs.example.dev/start",
|
||||
"vault": "Vault", "path": "Crawls/docs.md",
|
||||
})
|
||||
|
||||
|
||||
class TestCrawl:
|
||||
def test_saves_same_host_pages(self, vault, two_pages):
|
||||
out = call_tool("crawl_site", _ctx(), {
|
||||
"url": "https://docs.example.dev/start",
|
||||
"vault": "Vault", "path": "Crawls/docs.md",
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["pages"] == 2
|
||||
digest = (vault / "Crawls" / "docs.md").read_text(encoding="utf-8")
|
||||
assert "# Crawl de docs.example.dev" in digest
|
||||
assert "Bienvenue sur la documentation." in digest
|
||||
assert "Details ici." in digest
|
||||
assert "other.dev" not in digest
|
||||
|
||||
def test_max_pages_bound(self, vault, monkeypatch, local_urls):
|
||||
# A link farm: every page links to a new page — cap at max_pages.
|
||||
def fake_get(url, **kw):
|
||||
url = str(url)
|
||||
n = int(url.rsplit("/", 1)[-1] or 0)
|
||||
return FakeResponse(
|
||||
content=f"<html><head><title>P{n}</title></head><body>"
|
||||
f"<p>page {n}</p><a href=\"/{n + 1}\">next</a></body></html>".encode())
|
||||
|
||||
monkeypatch.setattr(crawler.httpx, "get", fake_get)
|
||||
out = call_tool("crawl_site", _ctx(), {
|
||||
"url": "https://farm.example.dev/0",
|
||||
"vault": "Vault", "path": "farm.md", "max_pages": 3,
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["pages"] == 3
|
||||
|
||||
def test_no_pages_recovered(self, vault, monkeypatch, local_urls):
|
||||
def dead_get(*a, **kw):
|
||||
raise crawler.httpx.ConnectError("down")
|
||||
|
||||
monkeypatch.setattr(crawler.httpx, "get", dead_get)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
call_tool("crawl_site", _ctx(), {
|
||||
"url": "https://dead.example.dev/", "vault": "Vault", "path": "x.md",
|
||||
}, confirm=True)
|
||||
assert ei.value.code == "crawl_failed"
|
||||
|
||||
def test_internal_url_rejected(self, vault):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
call_tool("crawl_site", _ctx(), {
|
||||
"url": "http://127.0.0.1:8080/", "vault": "Vault", "path": "x.md",
|
||||
}, confirm=True)
|
||||
assert ei.value.code in ("ssrf_blocked", "dns_error")
|
||||
|
||||
def test_binary_content_skipped(self, vault, monkeypatch, local_urls):
|
||||
def fake_get(url, **kw):
|
||||
url = str(url)
|
||||
if url.endswith("/x.pdf"):
|
||||
return FakeResponse(content=b"%PDF-1.4", headers={"content-type": "application/pdf"})
|
||||
return FakeResponse(content=PAGE_A)
|
||||
|
||||
monkeypatch.setattr(crawler.httpx, "get", fake_get)
|
||||
out = call_tool("crawl_site", _ctx(), {
|
||||
"url": "https://docs.example.dev/start",
|
||||
"vault": "Vault", "path": "docs.md", "max_pages": 5,
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["pages"] >= 1
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Unit tests for the document-production tools (#92): create_xlsx, create_docx,
|
||||
create_csv, create_pdf — WRITE risk, confirmation gating, vault persistence."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.tools.api import (
|
||||
ToolConfirmationRequired,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
call_tool,
|
||||
get_tool,
|
||||
)
|
||||
from backend.tools.context import ToolRisk
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path, monkeypatch):
|
||||
"""A minimal configured vault (index entry patched, no full build)."""
|
||||
vault_dir = tmp_path / "Vault"
|
||||
vault_dir.mkdir()
|
||||
monkeypatch.setitem(__import__("backend.indexer", fromlist=["index"]).index,
|
||||
"Vault", {"name": "Vault", "path": str(vault_dir), "config": {}})
|
||||
return vault_dir
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(
|
||||
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||
audit_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
@pytest.mark.parametrize("name", ["create_xlsx", "create_docx", "create_csv", "create_pdf"])
|
||||
def test_write_risk_and_confirmation(self, name):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.WRITE
|
||||
assert spec.requires_confirmation is True
|
||||
|
||||
|
||||
class TestConfirmationGating:
|
||||
def test_csv_requires_confirmation(self, vault):
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("create_csv", _ctx(), {
|
||||
"vault": "Vault", "path": "data.csv",
|
||||
"rows": [["a", "b"], [1, 2]],
|
||||
})
|
||||
|
||||
def test_pdf_requires_confirmation(self, vault):
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("create_pdf", _ctx(), {
|
||||
"vault": "Vault", "path": "doc.pdf", "title": "T", "content": "# H\npara",
|
||||
})
|
||||
|
||||
|
||||
class TestCreateCsv:
|
||||
def test_creates_file_in_vault(self, vault):
|
||||
out = call_tool("create_csv", _ctx(), {
|
||||
"vault": "Vault", "path": "Exports/data.csv",
|
||||
"rows": [["nom", "score"], ["alice", 12], ["bob", 9.5]],
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["success"] is True
|
||||
path = vault / "Exports" / "data.csv"
|
||||
assert path.exists()
|
||||
rows = list(csv.reader(io.StringIO(path.read_text(encoding="utf-8"))))
|
||||
assert rows[0] == ["nom", "score"]
|
||||
assert rows[2] == ["bob", "9.5"]
|
||||
|
||||
def test_semicolon_delimiter(self, vault):
|
||||
call_tool("create_csv", _ctx(), {
|
||||
"vault": "Vault", "path": "d.csv", "delimiter": ";",
|
||||
"rows": [["a", "b"], [1, 2]],
|
||||
}, confirm=True)
|
||||
content = (vault / "d.csv").read_text(encoding="utf-8")
|
||||
assert "a;b" in content
|
||||
|
||||
def test_wrong_extension_rejected(self, vault):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
call_tool("create_csv", _ctx(), {
|
||||
"vault": "Vault", "path": "d.txt", "rows": [["a"], [1]],
|
||||
}, confirm=True)
|
||||
assert ei.value.code == "invalid_arguments"
|
||||
|
||||
def test_empty_rows_rejected(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("create_csv", _ctx(), {
|
||||
"vault": "Vault", "path": "d.csv", "rows": [],
|
||||
}, confirm=True)
|
||||
|
||||
|
||||
class TestCreateXlsx:
|
||||
def test_creates_readable_workbook(self, vault):
|
||||
call_tool("create_xlsx", _ctx(), {
|
||||
"vault": "Vault", "path": "Rapports/budget.xlsx",
|
||||
"rows": [["item", "cout"], ["serveur", 1200], ["licence", 300]],
|
||||
"sheet_name": "Budget",
|
||||
}, confirm=True)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(vault / "Rapports" / "budget.xlsx")
|
||||
ws = wb.active
|
||||
assert ws.title == "Budget"
|
||||
assert ws.cell(row=1, column=1).value == "item"
|
||||
assert ws.cell(row=2, column=2).value == 1200
|
||||
|
||||
def test_wrong_extension_rejected(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("create_xlsx", _ctx(), {
|
||||
"vault": "Vault", "path": "b.docx", "rows": [["a"], [1]],
|
||||
}, confirm=True)
|
||||
|
||||
|
||||
class TestCreateDocx:
|
||||
def test_creates_readable_document(self, vault):
|
||||
call_tool("create_docx", _ctx(), {
|
||||
"vault": "Vault", "path": "rapport.docx",
|
||||
"title": "Rapport hebdo", "paragraphs": ["Premier point.", "Second point."],
|
||||
}, confirm=True)
|
||||
import docx as docx_lib
|
||||
|
||||
doc = docx_lib.Document(str(vault / "rapport.docx"))
|
||||
texts = [p.text for p in doc.paragraphs]
|
||||
assert "Rapport hebdo" in texts
|
||||
assert "Second point." in texts
|
||||
|
||||
def test_no_paragraphs_rejected(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("create_docx", _ctx(), {
|
||||
"vault": "Vault", "path": "r.docx", "paragraphs": [],
|
||||
}, confirm=True)
|
||||
|
||||
|
||||
class TestCreatePdf:
|
||||
def test_creates_valid_pdf(self, vault):
|
||||
call_tool("create_pdf", _ctx(), {
|
||||
"vault": "Vault", "path": "docs/archi.pdf",
|
||||
"title": "Architecture", "content": "# Titre\n\nUn paragraphe.\n## Sous-titre\nAutre texte.",
|
||||
}, confirm=True)
|
||||
raw = (vault / "docs" / "archi.pdf").read_bytes()
|
||||
assert raw.startswith(b"%PDF")
|
||||
|
||||
def test_long_content_truncated(self, vault):
|
||||
call_tool("create_pdf", _ctx(), {
|
||||
"vault": "Vault", "path": "big.pdf", "title": "T", "content": "x" * 500_000,
|
||||
}, confirm=True)
|
||||
assert (vault / "big.pdf").exists()
|
||||
|
||||
|
||||
class TestVaultSafety:
|
||||
def test_path_outside_vault_rejected(self, vault):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
call_tool("create_csv", _ctx(), {
|
||||
"vault": "Vault", "path": "../outside.csv", "rows": [["a"], [1]],
|
||||
}, confirm=True)
|
||||
assert ei.value.code in ("path_outside_vault", "invalid_arguments", "tool_execution_error")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Unit tests for the SQLite web cache (backend.tools.webcache, #92)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.tools import webcache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_enabled(tmp_path, monkeypatch):
|
||||
"""Enable the cache against an isolated file with a short TTL."""
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_PATH", str(tmp_path / "cache.sqlite3"))
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_TTL", "60")
|
||||
webcache._schema_ready = False
|
||||
yield
|
||||
webcache._schema_ready = False
|
||||
|
||||
|
||||
class TestCacheKey:
|
||||
def test_deterministic_and_payload_sensitive(self):
|
||||
a = webcache.cache_key("search", {"q": "pizza", "page": 1})
|
||||
b = webcache.cache_key("search", {"page": 1, "q": "pizza"})
|
||||
c = webcache.cache_key("search", {"q": "pasta", "page": 1})
|
||||
d = webcache.cache_key("fetch", {"q": "pizza", "page": 1})
|
||||
assert a == b
|
||||
assert a != c
|
||||
assert a != d
|
||||
|
||||
|
||||
class TestCacheRoundTrip:
|
||||
def test_set_get_roundtrip(self, cache_enabled):
|
||||
key = webcache.cache_key("search", {"q": "x"})
|
||||
webcache.cache_set(key, {"results": [1, 2, 3], "provider": "tavily"})
|
||||
assert webcache.cache_get(key) == {"results": [1, 2, 3], "provider": "tavily"}
|
||||
|
||||
def test_miss_returns_none(self, cache_enabled):
|
||||
assert webcache.cache_get("search:unknown") is None
|
||||
|
||||
def test_overwrite_updates_value(self, cache_enabled):
|
||||
key = webcache.cache_key("search", {"q": "x"})
|
||||
webcache.cache_set(key, {"v": 1})
|
||||
webcache.cache_set(key, {"v": 2})
|
||||
assert webcache.cache_get(key) == {"v": 2}
|
||||
|
||||
def test_ttl_expiry(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_PATH", str(tmp_path / "cache.sqlite3"))
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_TTL", "0.05")
|
||||
webcache._schema_ready = False
|
||||
key = webcache.cache_key("search", {"q": "x"})
|
||||
webcache.cache_set(key, {"v": 1})
|
||||
assert webcache.cache_get(key) == {"v": 1}
|
||||
import time
|
||||
|
||||
time.sleep(0.15)
|
||||
assert webcache.cache_get(key) is None
|
||||
|
||||
def test_disabled_when_ttl_zero(self, cache_enabled, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_TTL", "0")
|
||||
key = webcache.cache_key("search", {"q": "x"})
|
||||
webcache.cache_set(key, {"v": 1})
|
||||
assert webcache.cache_get(key) is None
|
||||
|
||||
def test_purge_expired(self, cache_enabled, monkeypatch):
|
||||
key = webcache.cache_key("search", {"q": "x"})
|
||||
webcache.cache_set(key, {"v": 1})
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_TTL", "0.05")
|
||||
import time
|
||||
|
||||
time.sleep(0.15)
|
||||
assert webcache.purge_expired() >= 1
|
||||
assert webcache.cache_get(key) is None
|
||||
|
||||
def test_corrupt_db_degrades_silently(self, tmp_path, monkeypatch):
|
||||
# A directory as cache file breaks sqlite3.connect: the cache must
|
||||
# disable itself instead of breaking the tools.
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_PATH", str(tmp_path))
|
||||
monkeypatch.setenv("OBSIGATE_WEB_CACHE_TTL", "60")
|
||||
webcache._schema_ready = False
|
||||
try:
|
||||
assert webcache.cache_get("search:x") is None
|
||||
webcache.cache_set("search:x", {"v": 1})
|
||||
finally:
|
||||
webcache._schema_ready = False
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for the keyed web-search providers (#92): Tavily, Brave,
|
||||
SerpAPI, Exa — plus provider ordering and the transient retry."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import backend.tools.web as web
|
||||
from backend.tools.context import ToolContext, ToolError, ToolMode
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, json_data=None, status_code=200, content=b""):
|
||||
self._json = json_data
|
||||
self.status_code = status_code
|
||||
self.content = content
|
||||
self.encoding = "utf-8"
|
||||
self.headers = {}
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise web.httpx.HTTPStatusError("boom", request=None, response=self) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP)
|
||||
|
||||
|
||||
def _no_fallback(monkeypatch):
|
||||
"""Limit the chain to the provider under test (no searxng/ddg/bing noise)."""
|
||||
monkeypatch.setattr(web, "WEB_FALLBACK_ENABLED", False)
|
||||
monkeypatch.setattr(web, "SEARXNG_URL", "http://searxng.invalid")
|
||||
monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
httpx.ConnectError("offline")))
|
||||
monkeypatch.setattr(web.httpx, "post", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
httpx.ConnectError("offline")))
|
||||
|
||||
|
||||
class TestKeyedProviderParsers:
|
||||
def test_tavily_maps_results(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["payload"] = json
|
||||
return FakeResponse(json_data={"results": [
|
||||
{"title": "T", "url": "https://a.dev", "content": "c" * 800},
|
||||
]})
|
||||
|
||||
monkeypatch.setattr(web.httpx, "post", fake_post)
|
||||
results, engines = web._search_tavily("q", web.WebSearchInput(query="q", max_results=3))
|
||||
assert results[0]["title"] == "T"
|
||||
assert len(results[0]["snippet"]) <= 600
|
||||
assert engines == []
|
||||
assert captured["payload"]["api_key"] == ""
|
||||
assert captured["payload"]["max_results"] == 3
|
||||
|
||||
def test_brave_maps_results(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, params=None, headers=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return FakeResponse(json_data={"web": {"results": [
|
||||
{"title": "B", "url": "https://b.dev", "description": "d"},
|
||||
]}})
|
||||
|
||||
monkeypatch.setattr(web.httpx, "get", fake_get)
|
||||
results, _ = web._search_brave("q", web.WebSearchInput(query="q"))
|
||||
assert results[0]["title"] == "B"
|
||||
assert "api.search.brave.com" in str(captured["url"])
|
||||
|
||||
def test_serpapi_maps_results(self, monkeypatch):
|
||||
monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={
|
||||
"organic_results": [{"title": "S", "link": "https://s.dev", "snippet": "sn"}],
|
||||
}))
|
||||
results, _ = web._search_serpapi("q", web.WebSearchInput(query="q"))
|
||||
assert results[0]["url"] == "https://s.dev"
|
||||
|
||||
def test_exa_maps_results(self, monkeypatch):
|
||||
monkeypatch.setattr(web.httpx, "post", lambda *a, **kw: FakeResponse(json_data={
|
||||
"results": [{"title": "E", "url": "https://e.dev", "text": "t" * 900}],
|
||||
}))
|
||||
results, _ = web._search_exa("q", web.WebSearchInput(query="q"))
|
||||
assert results[0]["title"] == "E"
|
||||
assert len(results[0]["snippet"]) <= 600
|
||||
|
||||
|
||||
class TestProviderChain:
|
||||
def test_no_key_falls_back_to_searxng(self, monkeypatch):
|
||||
for var in ("OBSIGATE_TAVILY_API_KEY", "OBSIGATE_BRAVE_API_KEY",
|
||||
"OBSIGATE_SERPAPI_API_KEY", "OBSIGATE_EXA_API_KEY"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
chain = [name for name, _ in web._provider_chain()]
|
||||
assert chain[0] == "searxng"
|
||||
assert "tavily" not in chain
|
||||
|
||||
def test_keyed_provider_used_first_when_key_set(self, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_TAVILY_API_KEY", "k")
|
||||
chain = [name for name, _ in web._provider_chain()]
|
||||
assert chain[0] == "tavily"
|
||||
assert "brave" not in chain # no key → skipped
|
||||
|
||||
def test_explicit_order_env(self, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_TAVILY_API_KEY", "k")
|
||||
monkeypatch.setenv("OBSIGATE_EXA_API_KEY", "k")
|
||||
monkeypatch.setenv("OBSIGATE_WEB_PROVIDERS", "exa,unknown,tavily")
|
||||
chain = [name for name, _ in web._provider_chain()]
|
||||
assert chain[:2] == ["exa", "tavily"]
|
||||
|
||||
def test_search_uses_keyed_provider_first(self, monkeypatch):
|
||||
_no_fallback(monkeypatch)
|
||||
monkeypatch.setenv("OBSIGATE_BRAVE_API_KEY", "k")
|
||||
monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={
|
||||
"web": {"results": [{"title": "B", "url": "https://b.dev", "description": "d"}]},
|
||||
}))
|
||||
out = web.web_search(_ctx(), web.WebSearchInput(query="q"))
|
||||
assert out["provider"] == "brave"
|
||||
assert out["count"] == 1
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_transient_error_retried_then_succeeds(self, monkeypatch):
|
||||
monkeypatch.setattr(web, "WEB_RETRY_ATTEMPTS", 1)
|
||||
monkeypatch.setattr(web, "_provider_chain", lambda: [("searxng", web._search_searxng)])
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_get(*a, **kw):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise httpx.ConnectError("blip")
|
||||
return FakeResponse(json_data={"results": [
|
||||
{"title": "A", "url": "https://a.dev", "content": "x"}]})
|
||||
|
||||
monkeypatch.setattr(web.httpx, "get", flaky_get)
|
||||
out = web.web_search(_ctx(), web.WebSearchInput(query="q"))
|
||||
assert out["provider"] == "searxng"
|
||||
assert calls["n"] == 2
|
||||
|
||||
def test_persistent_error_not_retried_forever(self, monkeypatch):
|
||||
monkeypatch.setattr(web, "WEB_RETRY_ATTEMPTS", 1)
|
||||
monkeypatch.setattr(web, "_provider_chain", lambda: [("searxng", web._search_searxng)])
|
||||
calls = {"n": 0}
|
||||
|
||||
def dead_get(*a, **kw):
|
||||
calls["n"] += 1
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
monkeypatch.setattr(web.httpx, "get", dead_get)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.web_search(_ctx(), web.WebSearchInput(query="q"))
|
||||
assert ei.value.code == "web_search_unavailable"
|
||||
assert calls["n"] == 2 # initial + 1 retry, per provider
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for the dynamic rendering path (#92): fetch_url(render=True)."""
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.tools.web as web
|
||||
from backend.tools import webrender
|
||||
from backend.tools.context import ToolContext, ToolError, ToolMode
|
||||
from backend.tools.registry import get_tool
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP)
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_render_param_exposed_in_schema(self):
|
||||
spec = get_tool("fetch_url")
|
||||
assert spec is not None
|
||||
assert "render" in spec.input_model.model_fields
|
||||
|
||||
|
||||
class TestRenderUnavailable:
|
||||
def test_missing_playwright_clear_error(self, monkeypatch):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: False)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(
|
||||
url="https://example.com/spa", render=True))
|
||||
assert ei.value.code == "playwright_unavailable"
|
||||
|
||||
def test_ssrf_guard_applied_before_render(self, monkeypatch):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: True)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(
|
||||
url="http://127.0.0.1:9222/devtools", render=True))
|
||||
assert ei.value.code in ("ssrf_blocked", "dns_error")
|
||||
|
||||
|
||||
class TestRenderSuccess:
|
||||
def test_fetch_url_delegates_to_worker(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_render(url):
|
||||
captured["url"] = url
|
||||
return {"url": url, "status": 200, "title": "SPA",
|
||||
"text": "dynamic content", "rendered": True, "truncated": False}
|
||||
|
||||
monkeypatch.setattr(webrender, "render_page", fake_render)
|
||||
out = web.fetch_url(_ctx(), web.FetchUrlInput(
|
||||
url="https://example.com/spa", render=True))
|
||||
assert captured["url"] == "https://example.com/spa"
|
||||
assert out["rendered"] is True
|
||||
assert "dynamic content" in out["text"]
|
||||
|
||||
def test_worker_failure_maps_to_tool_error(self, monkeypatch):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: True)
|
||||
|
||||
def boom(url):
|
||||
raise RuntimeError("chromium crashed")
|
||||
|
||||
# The executor re-raises the worker exception on .result(); render_page
|
||||
# must wrap it into a ToolError instead of leaking a bare exception.
|
||||
monkeypatch.setattr(webrender, "_render_in_worker", boom)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(
|
||||
url="https://example.com/spa", render=True))
|
||||
assert ei.value.code == "render_unavailable"
|
||||
|
||||
|
||||
class TestMarkdownExtraction:
|
||||
def test_html_to_text_reused(self):
|
||||
text = webrender._html_to_text("<html><body><p>hello</p><script>x()</script></body></html>")
|
||||
assert "hello" in text
|
||||
assert "x()" not in text
|
||||
Reference in New Issue
Block a user