feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -513,6 +513,100 @@ async function main() {
|
||||
assert.equal(b._messages[0].content, "Ancienne conversation");
|
||||
});
|
||||
|
||||
// ── 14. Agent mode & confirmations (B5) ──
|
||||
await test("agent mode toggle persists and switches the endpoint", async () => {
|
||||
localStorage.clear();
|
||||
const b = new BooksLM();
|
||||
assert.equal(b._agentMode, false, "agent mode off by default");
|
||||
b._toggleAgentMode();
|
||||
assert.equal(b._agentMode, true);
|
||||
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
|
||||
const b2 = new BooksLM();
|
||||
assert.equal(b2._agentMode, true, "persisted across instances");
|
||||
|
||||
let url = null;
|
||||
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
|
||||
b2._abortCtrl = null;
|
||||
await b2._postChat({ message: "x" });
|
||||
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
|
||||
b2._agentMode = false;
|
||||
await b2._postChat({ message: "x" });
|
||||
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("header exposes the agent-mode toggle", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
const btn = panel.querySelector(".bookslm-btn-agent");
|
||||
assert.ok(btn, "agent toggle button present");
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
||||
btn.click();
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "true");
|
||||
panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("_lineDiff marks additions and deletions", () => {
|
||||
const b = new BooksLM();
|
||||
const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd");
|
||||
const adds = diff.filter((l) => l.t === "+").map((l) => l.s);
|
||||
const dels = diff.filter((l) => l.t === "-").map((l) => l.s);
|
||||
assert.ok(dels.includes("b"), "removed line marked");
|
||||
assert.ok(adds.includes("B"), "changed line marked as addition");
|
||||
assert.ok(adds.includes("d"), "appended line marked");
|
||||
});
|
||||
|
||||
await test("confirmation card resumes the agent run with confirm payload", async () => {
|
||||
localStorage.clear();
|
||||
const encoder = new TextEncoder();
|
||||
let posted = null;
|
||||
const sse =
|
||||
'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' +
|
||||
'event: message\ndata: {"token":"Fait."}\n\n' +
|
||||
"event: done\ndata: {}\n\n";
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes("/api/ai/bookslm/agent")) {
|
||||
posted = JSON.parse(opts.body);
|
||||
const chunks = [encoder.encode(sse)];
|
||||
const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) };
|
||||
return { ok: true, status: 200, body: { getReader: () => reader } };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ raw: "old content" }) };
|
||||
};
|
||||
|
||||
const b = new BooksLM();
|
||||
b._agentMode = true;
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const msg = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
sources: [],
|
||||
toolCalls: [],
|
||||
confirmation: {
|
||||
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } },
|
||||
messages: [{ role: "system", content: "s" }],
|
||||
},
|
||||
payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] },
|
||||
};
|
||||
b._messages = [msg];
|
||||
|
||||
await b._applyConfirmation(msg);
|
||||
|
||||
assert.equal(msg.confirmation, null, "confirmation resolved");
|
||||
assert.ok(posted, "resumed via /agent");
|
||||
assert.equal(posted.confirm.error.tool, "edit_file");
|
||||
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
|
||||
const cont = b._messages[b._messages.length - 1];
|
||||
assert.equal(cont.content, "Fait.");
|
||||
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -186,6 +186,65 @@ class TestConfirmationAndLimits:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestConfirmationResume:
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_applies_pending_and_continues(self, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def handler(ctx, params):
|
||||
seen["ctx_confirmed"] = ctx.confirmed
|
||||
return {"done": True}
|
||||
|
||||
_register(monkeypatch, "_write", handler, risk=ToolRisk.WRITE)
|
||||
|
||||
# First run pauses on the mutating tool.
|
||||
llm1 = ScriptedLLM([LLMResponse(tool_calls=[ToolCall(id="1", name="_write", arguments={"x": 1})])])
|
||||
paused = await run_agent([{"role": "user", "content": "write"}], ctx=_ctx(), llm=llm1)
|
||||
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
|
||||
assert paused.pending["error"]["id"] == "1"
|
||||
|
||||
# Resume: the pending call is applied (one-shot confirm), then the loop
|
||||
# continues and produces the final answer.
|
||||
llm2 = ScriptedLLM([LLMResponse(content="applied")])
|
||||
resumed = await run_agent(
|
||||
[{"role": "user", "content": "write"}],
|
||||
ctx=_ctx(),
|
||||
llm=llm2,
|
||||
resume_messages=paused.messages,
|
||||
confirm_pending=paused.pending,
|
||||
)
|
||||
assert resumed.stopped == STOP_DONE
|
||||
assert resumed.content == "applied"
|
||||
assert len(resumed.tool_calls) == 1
|
||||
assert resumed.tool_calls[0].ok is True
|
||||
# The one-shot confirmation must not leak into the context.
|
||||
assert seen["ctx_confirmed"] is False
|
||||
# The tool result is fed back to the model on the resumed turn.
|
||||
tool_msgs = [m for m in llm2.calls[0]["messages"] if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0]["tool_call_id"] == "1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_without_assistant_message_reconstructs_it(self, monkeypatch):
|
||||
_register(monkeypatch, "_write", lambda ctx, params: {"done": True}, risk=ToolRisk.WRITE)
|
||||
llm = ScriptedLLM([LLMResponse(content="ok")])
|
||||
pending = {"error": {"tool": "_write", "arguments": {}, "id": "call_9"}}
|
||||
result = await run_agent(
|
||||
[{"role": "user", "content": "write"}],
|
||||
ctx=_ctx(),
|
||||
llm=llm,
|
||||
resume_messages=[{"role": "user", "content": "write"}],
|
||||
confirm_pending=pending,
|
||||
)
|
||||
assert result.stopped == STOP_DONE
|
||||
assistant_tool_msgs = [
|
||||
m for m in llm.calls[0]["messages"]
|
||||
if m.get("role") == "assistant" and m.get("tool_calls")
|
||||
]
|
||||
assert len(assistant_tool_msgs) == 1
|
||||
assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9"
|
||||
|
||||
|
||||
class TestAgentPermissions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_denied_recorded(self, client):
|
||||
|
||||
@@ -199,3 +199,95 @@ class TestGeminiToolsConversion:
|
||||
converted = _gemini_tools(TOOLS)
|
||||
assert converted[0]["functionDeclarations"][0]["name"] == "read_file"
|
||||
assert converted[0]["functionDeclarations"][0]["description"] == "Read a file"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Streaming (B4)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class _StreamResponse:
|
||||
"""Fake httpx streaming response yielding pre-scripted SSE lines."""
|
||||
|
||||
def __init__(self, lines, status=200):
|
||||
self.lines = lines
|
||||
self.status_code = status
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise _http_error(self.status_code)
|
||||
|
||||
async def aiter_lines(self):
|
||||
for line in self.lines:
|
||||
yield line
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Minimal stand-in for ``httpx.AsyncClient`` used in stream mode."""
|
||||
|
||||
captured: dict = {}
|
||||
response: _StreamResponse | None = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, headers=None, json=None):
|
||||
_FakeAsyncClient.captured = {"method": method, "url": url, "headers": headers, "json": json}
|
||||
resp = _FakeAsyncClient.response
|
||||
|
||||
class _Ctx:
|
||||
async def __aenter__(self_inner):
|
||||
return resp
|
||||
|
||||
async def __aexit__(self_inner, *exc):
|
||||
return False
|
||||
|
||||
return _Ctx()
|
||||
|
||||
|
||||
class TestStreamCompletion:
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_stream_yields_deltas(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _StreamResponse([
|
||||
'data: {"choices":[{"delta":{"content":"Bon"}}]}',
|
||||
"",
|
||||
'data: {"choices":[{"delta":{"content":"jour"}}]}',
|
||||
"data: not-json",
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])]
|
||||
assert "".join(chunks) == "Bonjour"
|
||||
assert _FakeAsyncClient.captured["json"]["stream"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_stream_yields_text(self, monkeypatch):
|
||||
monkeypatch.setitem(ai_chat.PROVIDERS, "gemini", GEMINI_CFG)
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: GEMINI_CFG)
|
||||
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _StreamResponse([
|
||||
'data: {"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}',
|
||||
'data: {"candidates":[{"content":{"parts":[{"text":"lut"}]}}]}',
|
||||
])
|
||||
|
||||
chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])]
|
||||
assert "".join(chunks) == "salut"
|
||||
assert "streamGenerateContent" in _FakeAsyncClient.captured["url"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_raises_on_http_error(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _StreamResponse([], status=500)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
async for _ in ai_chat.stream_completion([{"role": "user", "content": "hi"}]):
|
||||
pass
|
||||
|
||||
@@ -621,13 +621,15 @@ class TestResolveSafePath:
|
||||
|
||||
def test_resolve_traversal_attempt(self):
|
||||
from backend.main import _resolve_safe_path
|
||||
from backend.services.errors import ServiceError
|
||||
from pathlib import Path
|
||||
root = Path("/vaults/TestVault")
|
||||
try:
|
||||
_resolve_safe_path(root, "../../etc/passwd")
|
||||
assert False, "Should have raised"
|
||||
except Exception as e:
|
||||
assert "traversal" in str(e).lower() or "403" in str(e)
|
||||
except ServiceError as e:
|
||||
assert e.status == 403
|
||||
assert e.code == "path_outside_vault"
|
||||
|
||||
|
||||
class TestBackupFile:
|
||||
|
||||
@@ -823,3 +823,65 @@ class TestBooksLMAgentEndpoint:
|
||||
assert resp.status_code == 200
|
||||
assert "event: confirmation" in resp.text
|
||||
assert "_agent_write" in resp.text
|
||||
|
||||
def test_agent_confirmation_resume(self, bookslm_client, monkeypatch):
|
||||
import re
|
||||
|
||||
import backend.bookslm_routes as routes
|
||||
from backend.ai_chat import LLMResponse, ToolCall
|
||||
from backend.tools import registry
|
||||
from backend.tools.api import ToolRisk
|
||||
from backend.tools.registry import ToolSpec
|
||||
from backend.tools.schemas import ListVaultsInput
|
||||
|
||||
executed = {}
|
||||
|
||||
def handler(ctx, params):
|
||||
executed["ran"] = True
|
||||
return {"ok": True}
|
||||
|
||||
spec = ToolSpec(
|
||||
name="_agent_write_resume",
|
||||
description="write for tests",
|
||||
input_model=ListVaultsInput,
|
||||
handler=handler,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
monkeypatch.setitem(registry._REGISTRY, "_agent_write_resume", spec)
|
||||
|
||||
responses = [LLMResponse(tool_calls=[ToolCall(id="c1", name="_agent_write_resume", arguments={})])]
|
||||
|
||||
async def fake_chat_completion(messages, **kwargs):
|
||||
return responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
|
||||
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
|
||||
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
payload = {"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"}
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/agent",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert "event: confirmation" in resp.text
|
||||
assert "_agent_write_resume" in resp.text
|
||||
assert executed.get("ran") is not True
|
||||
|
||||
match = re.search(r"event: confirmation\ndata: (.*)", resp.text)
|
||||
assert match, resp.text
|
||||
confirmation = json.loads(match.group(1))
|
||||
assert confirmation["pending"]["error"]["tool"] == "_agent_write_resume"
|
||||
|
||||
# Resume: apply the pending call, then the model answers.
|
||||
responses.append(LLMResponse(content="C'est fait."))
|
||||
resume_payload = dict(payload, confirm=confirmation["pending"], confirm_messages=confirmation["messages"])
|
||||
resp2 = bookslm_client.post(
|
||||
"/api/ai/bookslm/agent",
|
||||
json=resume_payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert executed.get("ran") is True
|
||||
assert "C'est fait." in resp2.text
|
||||
assert "event: message" in resp2.text
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# tests/test_services.py — Unit tests for the shared business-logic services (A2)
|
||||
"""Tests for backend.services: vaults, files, search, and error mapping.
|
||||
|
||||
The index-dependent tests reuse the ``client`` fixture from conftest, which
|
||||
builds the in-memory index for a ``TestVault``.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.files import read_file_text, read_raw_file
|
||||
from backend.services.search import list_tags, search_vaults
|
||||
from backend.services.vaults import browse_directory, list_accessible_vaults
|
||||
|
||||
ADMIN = {"username": "admin", "role": "admin", "vaults": ["*"]}
|
||||
LIMITED = {"username": "limited", "vaults": ["OtherVault"]}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vaults
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestListAccessibleVaults:
|
||||
def test_admin_sees_vault(self, client):
|
||||
names = {v["name"] for v in list_accessible_vaults(ADMIN)}
|
||||
assert "TestVault" in names
|
||||
|
||||
def test_limited_user_sees_none(self, client):
|
||||
assert list_accessible_vaults(LIMITED) == []
|
||||
|
||||
def test_metadata_fields(self, client):
|
||||
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type"}
|
||||
assert vault["file_count"] >= 1
|
||||
|
||||
|
||||
class TestBrowseDirectory:
|
||||
def test_root_lists_children(self, client):
|
||||
data = browse_directory("TestVault", "")
|
||||
names = {i["name"] for i in data["items"]}
|
||||
assert "note1.md" in names
|
||||
assert "Projets" in names
|
||||
|
||||
def test_missing_path_raises_not_found(self, client):
|
||||
with pytest.raises(ServiceError) as exc:
|
||||
browse_directory("TestVault", "nope")
|
||||
assert exc.value.code == "not_found"
|
||||
|
||||
def test_unknown_vault_raises_not_found(self, client):
|
||||
with pytest.raises(ServiceError) as exc:
|
||||
browse_directory("MissingVault", "")
|
||||
assert exc.value.code == "not_found"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Files
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
def test_read_text_redacts_secrets(self, client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
root = Path(get_vault_data("TestVault")["path"])
|
||||
secret = root / "svc_secret.md"
|
||||
fake_jwt = "eyJ" + "a" * 30 + "." + "b" * 30 + "." + "c" * 30
|
||||
secret.write_text(f"token: {fake_jwt}\n", encoding="utf-8")
|
||||
try:
|
||||
data = read_file_text("TestVault", "svc_secret.md")
|
||||
assert "[JWT MASQUÉ]" in data["content"]
|
||||
finally:
|
||||
secret.unlink()
|
||||
|
||||
def test_read_text_too_large(self, client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
root = Path(get_vault_data("TestVault")["path"])
|
||||
big = root / "svc_big.txt"
|
||||
big.write_text("x" * 1000, encoding="utf-8")
|
||||
try:
|
||||
with pytest.raises(ServiceError) as exc:
|
||||
read_file_text("TestVault", "svc_big.txt", max_bytes=10)
|
||||
assert exc.value.code == "file_too_large"
|
||||
finally:
|
||||
big.unlink()
|
||||
|
||||
def test_read_raw_no_redaction(self, client):
|
||||
data = read_raw_file("TestVault", "note1.md")
|
||||
assert "Python" in data["raw"]
|
||||
|
||||
def test_read_missing_raises_not_found(self, client):
|
||||
with pytest.raises(ServiceError) as exc:
|
||||
read_raw_file("TestVault", "missing.md")
|
||||
assert exc.value.code == "not_found"
|
||||
|
||||
def test_traversal_is_rejected(self, client):
|
||||
with pytest.raises(ServiceError) as exc:
|
||||
read_raw_file("TestVault", "../../etc/passwd")
|
||||
assert exc.value.code == "path_outside_vault"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Search
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSearchService:
|
||||
def test_pagination(self, client):
|
||||
data = search_vaults("Python", limit=1, offset=0)
|
||||
assert data["limit"] == 1
|
||||
assert len(data["results"]) <= 1
|
||||
assert data["total"] >= 1
|
||||
|
||||
def test_returns_api_shape(self, client):
|
||||
data = search_vaults("Python")
|
||||
assert set(data) >= {"query", "vault_filter", "tag_filter", "count", "total", "offset", "limit", "results"}
|
||||
|
||||
def test_tags(self, client):
|
||||
tags = list_tags("TestVault")
|
||||
assert "python" in tags
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Service → Tool error mapping
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestServiceErrorMapping:
|
||||
def test_not_found_maps_to_tool_not_found(self, client):
|
||||
from backend.tools.api import ToolContext, ToolNotFoundError, call_tool
|
||||
|
||||
ctx = ToolContext(user=ADMIN, audit_enabled=False)
|
||||
with pytest.raises(ToolNotFoundError):
|
||||
call_tool("read_file", ctx, {"vault": "TestVault", "path": "nope.md"})
|
||||
|
||||
def test_file_too_large_preserves_code(self, client, monkeypatch):
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.tools import service as service_mod
|
||||
from backend.tools.api import ToolContext, ToolError, call_tool
|
||||
|
||||
monkeypatch.setattr(service_mod, "TOOL_MAX_READ_BYTES", 10)
|
||||
root = Path(get_vault_data("TestVault")["path"])
|
||||
big = root / "svc_tool_big.txt"
|
||||
big.write_text("x" * 1000, encoding="utf-8")
|
||||
try:
|
||||
ctx = ToolContext(user=ADMIN, audit_enabled=False)
|
||||
with pytest.raises(ToolError) as exc:
|
||||
call_tool("read_file", ctx, {"vault": "TestVault", "path": "svc_tool_big.txt"})
|
||||
assert exc.value.code == "file_too_large"
|
||||
finally:
|
||||
big.unlink()
|
||||
Reference in New Issue
Block a user