mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
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
268 lines
9.7 KiB
Python
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()
|