Files
AstrBot/tests/unit/test_file_extract.py
LIghtJUNction 169a856a3a test: mass-coverage push — ~1000+ new unit tests
Coverage improvements:
- agent/tool/core: 150+ tests (FunctionTool, ToolSet, ToolSchema, run_context, messages, registry)
- provider: 152 tests (entities, Provider ABC, register, manager)
- star module: 58 tests (Context, PluginManager, StarHandler)
- platform: 71 tests (Platform ABC, manager, MessageSession)
- pipeline: 130 tests (process/respond stages, scheduler, bootstrap, conversation_mgr)
- kb+skills: 96 tests (mgr, helpers, chunkers, skill_manager)
- config+builtins: 92 tests (astrbot_config, config_mgr, default, LTM, star main)
- cron+db+event_bus: 89 tests (cron events/manager, PO models, BaseDatabase, EventBus)
- dashboard routes: expanded smoke tests for all route modules
2026-04-29 08:40:26 +08:00

268 lines
9.7 KiB
Python

"""Mock-based unit tests for astrbot.core.utils.file_extract.
All external calls (httpx.AsyncClient and anyio.Path) are mocked so that
the tests never touch the network or the filesystem.
"""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from astrbot.core.utils.file_extract import extract_file_moonshotai
def _make_http_status_error(status_code: int = 400) -> httpx.HTTPStatusError:
"""Return a realistic HTTPStatusError for use as a side_effect."""
request = httpx.Request("POST", "https://api.moonshot.cn/v1/files")
response = httpx.Response(status_code, request=request)
return httpx.HTTPStatusError(
f"HTTP {status_code}",
request=request,
response=response,
)
# ---------------------------------------------------------------------------
# mocks
# ---------------------------------------------------------------------------
def _mock_client() -> tuple[MagicMock, AsyncMock]:
"""Create a pre-configured AsyncMock httpx client.
Returns (mock_client, mock_httpx_cls) where:
- mock_httpx_cls.return_value = mock_client
- mock_client.__aenter__.return_value = mock_client
"""
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
return mock_client
def _mock_path(file_bytes: bytes = b"dummy file content") -> MagicMock:
"""Create a pre-configured MagicMock anyio.Path.
Returns mock_path where:
- mock_path.read_bytes is an AsyncMock returning *file_bytes*
"""
mock_path = MagicMock(spec=Path) # anyio.Path implements Path-like interface
mock_path.name = "test-file.pdf" # used for the upload filename
mock_path.read_bytes = AsyncMock(return_value=file_bytes)
return mock_path
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestExtractFileMoonshotaiSuccess:
FAKE_CONTENT = b"%PDF-1.4 fake binary content..."
EXTRACTED_TEXT = "Extracted text from Moonshot AI API"
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_successful_extraction(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""Happy path: file is uploaded, file-id returned, content fetched."""
mock_path = _mock_path(self.FAKE_CONTENT)
mock_path_cls.return_value = mock_path
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
upload_resp = MagicMock()
upload_resp.json.return_value = {"id": "file-mock-001"}
mock_client.post.return_value = upload_resp
content_resp = MagicMock()
content_resp.text = self.EXTRACTED_TEXT
mock_client.get.return_value = content_resp
result = await extract_file_moonshotai("/fake/path/report.pdf", "sk-test-key")
assert result == self.EXTRACTED_TEXT
mock_path_cls.assert_called_once()
mock_path.read_bytes.assert_called_once()
mock_httpx_cls.assert_called_once()
mock_client.post.assert_called_once()
mock_client.get.assert_called_once_with("/files/file-mock-001/content")
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_client_created_with_authorization_header(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""The Bearer token should match the api_key passed."""
mock_path_cls.return_value = _mock_path(b"data")
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
mock_client.post.return_value = MagicMock(json=lambda: {"id": "f1"})
mock_client.get.return_value = MagicMock(text="ok")
await extract_file_moonshotai("/f.pdf", "sk-secret-42")
call_headers = mock_httpx_cls.call_args[1].get("headers", {})
assert call_headers.get("Authorization") == "Bearer sk-secret-42"
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_passes_file_path_to_anyio_path(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""The file_path argument should be forwarded to anyio.Path()."""
mock_path_cls.return_value = _mock_path(b"data")
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
mock_client.post.return_value = MagicMock(json=lambda: {"id": "f1"})
mock_client.get.return_value = MagicMock(text="ok")
await extract_file_moonshotai("/my/custom/document.txt", "key")
args, _ = mock_path_cls.call_args
assert str(args[0]) == "/my/custom/document.txt"
# ---------------------------------------------------------------------------
# Error paths
# ---------------------------------------------------------------------------
class TestExtractFileMoonshotaiErrors:
FAKE_CONTENT = b"fake bytes"
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_missing_file_id_raises_value_error(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""Upload response without an 'id' key should raise ValueError."""
mock_path_cls.return_value = _mock_path(self.FAKE_CONTENT)
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
resp = MagicMock()
resp.json.return_value = {} # no "id"
mock_client.post.return_value = resp
with pytest.raises(ValueError, match="valid file id"):
await extract_file_moonshotai("/f.pdf", "key")
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_null_file_id_raises_value_error(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""Upload response with 'id': None should raise ValueError."""
mock_path_cls.return_value = _mock_path(self.FAKE_CONTENT)
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
resp = MagicMock()
resp.json.return_value = {"id": None}
mock_client.post.return_value = resp
with pytest.raises(ValueError, match="valid file id"):
await extract_file_moonshotai("/f.pdf", "key")
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_upload_http_error_propagates(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""Non-2xx status from upload POST should propagate as HTTPStatusError."""
mock_path_cls.return_value = _mock_path(self.FAKE_CONTENT)
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
resp = MagicMock()
resp.raise_for_status.side_effect = _make_http_status_error(400)
mock_client.post.return_value = resp
with pytest.raises(httpx.HTTPStatusError):
await extract_file_moonshotai("/f.pdf", "key")
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_content_fetch_http_error_propagates(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""Non-2xx status from content GET should propagate as HTTPStatusError."""
mock_path_cls.return_value = _mock_path(self.FAKE_CONTENT)
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
upload_resp = MagicMock()
upload_resp.json.return_value = {"id": "file-001"}
mock_client.post.return_value = upload_resp
content_resp = MagicMock()
content_resp.raise_for_status.side_effect = _make_http_status_error(500)
mock_client.get.return_value = content_resp
with pytest.raises(httpx.HTTPStatusError):
await extract_file_moonshotai("/f.pdf", "key")
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_connection_error_during_upload_propagates(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""A network-level error on POST should propagate as ConnectError."""
mock_path_cls.return_value = _mock_path(self.FAKE_CONTENT)
mock_client = _mock_client()
mock_httpx_cls.return_value = mock_client
mock_client.post.side_effect = httpx.ConnectError("connection refused")
with pytest.raises(httpx.ConnectError):
await extract_file_moonshotai("/f.pdf", "key")
@pytest.mark.asyncio
@patch("astrbot.core.utils.file_extract.httpx.AsyncClient")
@patch("astrbot.core.utils.file_extract.anyio.Path")
async def test_file_read_error_prevents_http_call(
self,
mock_path_cls: MagicMock,
mock_httpx_cls: MagicMock,
):
"""If anyio.Path.read_bytes raises, the function should raise and
httpx.AsyncClient should never be constructed."""
mock_path = _mock_path(b"ignored")
mock_path.read_bytes = AsyncMock(side_effect=OSError(2, "No such file"))
mock_path_cls.return_value = mock_path
with pytest.raises(OSError):
await extract_file_moonshotai("/missing/file.txt", "key")
mock_httpx_cls.assert_not_called()