326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""Tests for the CLI ``/file`` command.
|
|
|
|
The ``/file`` command:
|
|
|
|
- Accepts the broader allow-list (PDF, text-family, JSON, plus images).
|
|
- Routes by size: <= 2 MB inlines as base64 (same shape as /image today).
|
|
- > 2 MB uploads to /api/v1/files/upload via the gateway bridge and
|
|
references the returned ``file_uuid`` in the chat.send payload.
|
|
- Hard-fails with a clear, actionable error when the bridge is
|
|
unreachable AND the file exceeds the inline cap.
|
|
|
|
These tests target the helper directly so the contract is locked
|
|
without requiring a live gateway. The hooks the helper accepts (an
|
|
``upload_callable`` parameter) make it trivial to inject a fake
|
|
bridge in tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from opensquilla.cli import chat_cmd
|
|
from opensquilla.cli.attachments import (
|
|
CLI_IMAGE_ATTACHMENT_BYTES,
|
|
CLI_INLINE_THRESHOLD_BYTES,
|
|
CLI_STAGED_PDF_BYTES,
|
|
CLI_TEXT_ATTACHMENT_BYTES,
|
|
)
|
|
from opensquilla.cli.chat_cmd import (
|
|
_file_prompt_and_attachments,
|
|
_image_prompt_and_attachments,
|
|
_image_prompt_from_command,
|
|
)
|
|
from opensquilla.cli.repl import input_bridge
|
|
|
|
|
|
def _write(tmp_path: Path, name: str, payload: bytes) -> Path:
|
|
path = tmp_path / name
|
|
path.write_bytes(payload)
|
|
return path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — small CSV inlines as base64.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_file_command_inline_for_small_csv(tmp_path: Path) -> None:
|
|
csv_bytes = b"col_a,col_b\n1,2\n3,4\n"
|
|
path = _write(tmp_path, "data.csv", csv_bytes)
|
|
|
|
prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path} summarise this", upload_callable=None
|
|
)
|
|
assert prompt == "summarise this"
|
|
assert len(attachments) == 1
|
|
att = attachments[0]
|
|
assert att["type"] == "text/csv"
|
|
assert att["name"] == "data.csv"
|
|
assert "data" in att and "file_uuid" not in att
|
|
assert base64.b64decode(att["data"]) == csv_bytes
|
|
|
|
|
|
def test_file_command_parses_quoted_path_with_spaces(tmp_path: Path) -> None:
|
|
csv_bytes = b"col_a,col_b\n1,2\n"
|
|
path = _write(tmp_path, "data set.csv", csv_bytes)
|
|
|
|
prompt, attachments = _file_prompt_and_attachments(
|
|
f'/file "{path}" summarise this', upload_callable=None
|
|
)
|
|
|
|
assert prompt == "summarise this"
|
|
assert attachments[0]["name"] == "data set.csv"
|
|
assert attachments[0]["type"] == "text/csv"
|
|
assert base64.b64decode(attachments[0]["data"]) == csv_bytes
|
|
|
|
|
|
def test_file_command_rejects_unclosed_quoted_path() -> None:
|
|
with pytest.raises(ValueError, match=r"Usage: /file"):
|
|
_file_prompt_and_attachments('/file "unterminated', upload_callable=None)
|
|
|
|
|
|
def test_image_command_parses_quoted_path_with_spaces(tmp_path: Path) -> None:
|
|
png_bytes = b"\x89PNG\r\n\x1a\n" + b"payload"
|
|
path = _write(tmp_path, "screen shot.png", png_bytes)
|
|
|
|
prompt, attachments = _image_prompt_and_attachments(f'/image "{path}" describe it')
|
|
|
|
assert _image_prompt_from_command(f'/image "{path}" describe it') == "describe it"
|
|
assert prompt == "describe it"
|
|
assert attachments[0]["name"] == "screen shot.png"
|
|
assert attachments[0]["type"] == "image/png"
|
|
assert base64.b64decode(attachments[0]["data"]) == png_bytes
|
|
|
|
|
|
def test_image_command_reports_attachment_size(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
png_bytes = b"\x89PNG\r\n\x1a\n" + b"payload"
|
|
path = _write(tmp_path, "screen shot.png", png_bytes)
|
|
prints: list[str] = []
|
|
|
|
class FakeConsole:
|
|
def print(self, message: str) -> None:
|
|
prints.append(message)
|
|
|
|
monkeypatch.setattr(input_bridge, "console", FakeConsole())
|
|
|
|
chat_cmd._image_prompt_and_attachments(f'/image "{path}" describe it')
|
|
|
|
assert prints == ["[dim]Sending image: screen shot.png (0KB base64)[/dim]"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — large PDF goes through the bridge upload callable.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_file_command_uses_bridge_for_large_pdf(tmp_path: Path) -> None:
|
|
big_pdf = b"%PDF-1.4\n" + b"a" * (3 * 1024 * 1024) # 3 MB > 2 MB threshold
|
|
path = _write(tmp_path, "big.pdf", big_pdf)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
|
captured["local_path"] = Path(local_path)
|
|
captured["mime"] = mime
|
|
captured["name"] = name
|
|
return "u-fake-uuid-1234"
|
|
|
|
prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path}", upload_callable=fake_upload
|
|
)
|
|
assert prompt # default prompt assigned by the helper
|
|
assert len(attachments) == 1
|
|
att = attachments[0]
|
|
assert att["type"] == "application/pdf"
|
|
assert att["name"] == "big.pdf"
|
|
assert att["file_uuid"] == "u-fake-uuid-1234"
|
|
assert "data" not in att
|
|
assert captured["local_path"] == path
|
|
assert captured["mime"] == "application/pdf"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — bridge unreachable AND file > inline cap → hard-fail.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_file_command_hard_fails_when_bridge_unreachable_and_file_too_large(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
big_pdf = b"%PDF-1.4\n" + b"a" * (3 * 1024 * 1024)
|
|
path = _write(tmp_path, "big.pdf", big_pdf)
|
|
|
|
def unreachable(local_path: Path, mime: str, name: str) -> str:
|
|
raise ConnectionError("gateway upload endpoint unavailable: connection refused")
|
|
|
|
with pytest.raises(ValueError, match=r"too large|gateway upload"):
|
|
_file_prompt_and_attachments(f"/file {path}", upload_callable=unreachable)
|
|
|
|
|
|
def test_file_command_unreachable_bridge_falls_back_to_inline_for_small_file(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""When the bridge is unreachable but the file is below the inline cap,
|
|
the helper still inlines the bytes — never silently truncates, never fails.
|
|
"""
|
|
small_csv = b"a,b\n1,2\n"
|
|
path = _write(tmp_path, "small.csv", small_csv)
|
|
|
|
def unreachable(local_path: Path, mime: str, name: str) -> str:
|
|
raise ConnectionError("would not be invoked for inline size")
|
|
|
|
prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path} read", upload_callable=unreachable
|
|
)
|
|
assert prompt == "read"
|
|
assert attachments[0]["type"] == "text/csv"
|
|
assert "data" in attachments[0]
|
|
|
|
|
|
def test_file_command_accepts_unknown_binary_as_opaque(tmp_path: Path) -> None:
|
|
# Unknown extension + binary content attaches as an opaque item: the bytes
|
|
# land in the agent workspace, never in the provider prompt.
|
|
path = _write(tmp_path, "x.bin", b"\x00\x01\x02\x03 binary blob")
|
|
prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path}", upload_callable=None
|
|
)
|
|
assert prompt == "Read this file"
|
|
att = attachments[0]
|
|
assert att["type"] == "application/octet-stream"
|
|
assert base64.b64decode(att["data"]) == b"\x00\x01\x02\x03 binary blob"
|
|
|
|
|
|
def test_file_command_stages_zip_archive(tmp_path: Path) -> None:
|
|
import io
|
|
import zipfile
|
|
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w") as archive:
|
|
archive.writestr("paper/main.tex", "x" * (CLI_INLINE_THRESHOLD_BYTES + 64))
|
|
payload = buffer.getvalue()
|
|
assert len(payload) > CLI_INLINE_THRESHOLD_BYTES
|
|
path = _write(tmp_path, "paper.zip", payload)
|
|
captured: dict[str, Any] = {}
|
|
|
|
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
|
captured.update({"mime": mime, "name": name})
|
|
return "u-zip"
|
|
|
|
_prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path}", upload_callable=fake_upload
|
|
)
|
|
|
|
assert captured["mime"] == "application/zip"
|
|
assert attachments == [
|
|
{
|
|
"type": "application/zip",
|
|
"file_uuid": "u-zip",
|
|
"name": "paper.zip",
|
|
"mime": "application/zip",
|
|
}
|
|
]
|
|
|
|
|
|
def test_file_command_accepts_unknown_utf8_text_as_plain(tmp_path: Path) -> None:
|
|
# Unknown extension whose bytes are clean UTF-8 text is accepted as
|
|
# text/plain, so the gateway's unknown-text fallback is reachable from the
|
|
# CLI rather than rejected at the client.
|
|
body = b"#!/bin/sh\necho hi\n"
|
|
path = _write(tmp_path, "script.sh", body)
|
|
|
|
prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path} run this", upload_callable=None
|
|
)
|
|
assert prompt == "run this"
|
|
assert len(attachments) == 1
|
|
att = attachments[0]
|
|
assert att["type"] == "text/plain"
|
|
assert att["name"] == "script.sh"
|
|
assert base64.b64decode(att["data"]) == body
|
|
|
|
|
|
def test_file_command_stages_large_text_family(tmp_path: Path) -> None:
|
|
# Text above the inline threshold routes through the staged upload path
|
|
# instead of dead-ending on the old text-family rejection.
|
|
path = _write(tmp_path, "large.csv", b"a" * (CLI_TEXT_ATTACHMENT_BYTES + 1))
|
|
captured: dict[str, Any] = {}
|
|
|
|
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
|
captured.update({"local_path": local_path, "mime": mime, "name": name})
|
|
return "u-large-text"
|
|
|
|
_prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path}", upload_callable=fake_upload
|
|
)
|
|
|
|
assert captured["mime"] == "text/csv"
|
|
assert attachments == [
|
|
{
|
|
"type": "text/csv",
|
|
"file_uuid": "u-large-text",
|
|
"name": "large.csv",
|
|
"mime": "text/csv",
|
|
}
|
|
]
|
|
|
|
|
|
def test_file_command_stages_large_image_within_image_cap(tmp_path: Path) -> None:
|
|
payload = b"\x89PNG\r\n\x1a\n" + b"a" * CLI_INLINE_THRESHOLD_BYTES
|
|
assert CLI_INLINE_THRESHOLD_BYTES < len(payload) <= CLI_IMAGE_ATTACHMENT_BYTES
|
|
path = _write(tmp_path, "large.png", payload)
|
|
captured: dict[str, Any] = {}
|
|
|
|
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
|
captured.update({"local_path": local_path, "mime": mime, "name": name})
|
|
return "u-image"
|
|
|
|
_prompt, attachments = _file_prompt_and_attachments(
|
|
f"/file {path}",
|
|
upload_callable=fake_upload,
|
|
)
|
|
|
|
assert attachments == [
|
|
{"type": "image/png", "file_uuid": "u-image", "name": "large.png", "mime": "image/png"}
|
|
]
|
|
assert captured["local_path"] == path
|
|
assert captured["mime"] == "image/png"
|
|
|
|
|
|
def test_file_command_rejects_image_above_image_cap_before_upload(tmp_path: Path) -> None:
|
|
path = _write(
|
|
tmp_path,
|
|
"too-large.png",
|
|
b"\x89PNG\r\n\x1a\n" + b"a" * CLI_IMAGE_ATTACHMENT_BYTES,
|
|
)
|
|
called = False
|
|
|
|
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
|
nonlocal called
|
|
called = True
|
|
return "u-should-not-upload"
|
|
|
|
with pytest.raises(ValueError, match=r"image attachment limit|too large"):
|
|
_file_prompt_and_attachments(f"/file {path}", upload_callable=fake_upload)
|
|
|
|
assert called is False
|
|
|
|
|
|
def test_file_command_rejects_pdf_above_staged_cap_before_upload(tmp_path: Path) -> None:
|
|
path = _write(tmp_path, "too-large.pdf", b"%PDF-1.4\n" + b"a" * CLI_STAGED_PDF_BYTES)
|
|
called = False
|
|
|
|
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
|
nonlocal called
|
|
called = True
|
|
return "u-should-not-upload"
|
|
|
|
with pytest.raises(ValueError, match=r"PDF limit|too large"):
|
|
_file_prompt_and_attachments(f"/file {path}", upload_callable=fake_upload)
|
|
|
|
assert called is False
|