test(sveta): Write unit tests for rsearch response parsing and error-in-body handling
Outcome: done
Changed: tests/test_research_client.py:1-700
Verified by: make verify -> exit_code 0, 140 tests OK (36 new), "verification passed"; only pre-existing StarletteDeprecationWarning from tests/test_api.py:1, none introduced
Findings: 36 stdlib-unittest tests with retoor header; parsing covered for web results, ai memory/provider variants, deep block (sources, grades, rounds, queries_tried), images extra metadata, chat usage, describe get/upload/raw; error-in-body asserted via real _request (patched urllib.request.urlopen): {success:false,error:"Empty query"}->RsearchError 400, providers-exhausted 503, success:false with HTTP 200, detail/title fallback, empty/invalid/non-dict body, URLError; count clamping contract asserted at client boundary: count=0 sent and parsed server clamp 1, count=25 -> 10, invalid -> 10, count=None omits param; request construction asserted (params, deep timeout 180 vs 30, cache=false, content cache fill); each parsing test asserts exact mapped values so any field-mapping regression fails; no test skipped or weakened
Open: none
Confidence: high - all acceptance criteria asserted by passing tests against verified pre-change baseline
Typosaurus-Run: 4e2afb673c7f4578a12276d9181b982d
Typosaurus-Node: 05afdbb5c5324f2ca0b0dfd8ce320f12
Typosaurus-Agent: @sveta
Refs: #31
This commit is contained in:
parent
31f2c6451f
commit
be31a2774f
700
tests/test_research_client.py
Normal file
700
tests/test_research_client.py
Normal file
@ -0,0 +1,700 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from typosaurus_sandbox.research.client import RsearchClient, RsearchError
|
||||
from typosaurus_sandbox.research.envelopes import (
|
||||
ChatResponse,
|
||||
ChatUsage,
|
||||
DeepReport,
|
||||
DescribeResponse,
|
||||
SearchGrade,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
WEB_RESPONSE: dict[str, Any] = {
|
||||
"query": "asyncio python",
|
||||
"source": "duckduckgo",
|
||||
"count": 3,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"timestamp": "2026-08-07T12:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"title": "asyncio documentation",
|
||||
"url": "https://docs.python.org/3/library/asyncio.html",
|
||||
"description": "Asynchronous I/O event loop.",
|
||||
"source": "docs.python.org",
|
||||
"extra": {"rank": 1},
|
||||
"index": 0,
|
||||
},
|
||||
{
|
||||
"title": "asyncio in Python",
|
||||
"url": "https://example.com/asyncio",
|
||||
"description": "Tutorial on asyncio.",
|
||||
"source": "example.com",
|
||||
"extra": {"rank": 2},
|
||||
"index": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
AI_MEMORY_RESPONSE: dict[str, Any] = {
|
||||
"query": "python history",
|
||||
"source": "ai",
|
||||
"count": 0,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [],
|
||||
"ai_response": "From memory: Python was released in 1991 by Guido van Rossum.",
|
||||
"ai_error": None,
|
||||
}
|
||||
|
||||
AI_PROVIDER_RESPONSE: dict[str, Any] = {
|
||||
"query": "quantum computing",
|
||||
"source": "google",
|
||||
"count": 1,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [
|
||||
{
|
||||
"title": "Quantum computing overview",
|
||||
"url": "https://example.com/quantum",
|
||||
"description": "Overview of quantum computing.",
|
||||
"source": "example.com",
|
||||
"extra": {},
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"ai_response": "Quantum computing uses qubits. [citation:1]",
|
||||
"ai_error": None,
|
||||
}
|
||||
|
||||
GRADED_RESPONSE: dict[str, Any] = {
|
||||
"query": "deep research",
|
||||
"source": "google",
|
||||
"count": 1,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [
|
||||
{
|
||||
"title": "Deep research systems",
|
||||
"url": "https://example.com/deep-research",
|
||||
"description": "Survey of deep research systems.",
|
||||
"source": "example.com",
|
||||
"extra": {},
|
||||
"index": 0,
|
||||
"grade": {
|
||||
"overall": 9.2,
|
||||
"relevance": 8.8,
|
||||
"depth": 9.0,
|
||||
"authority": 9.5,
|
||||
"freshness": 7.0,
|
||||
"word_count": 1200,
|
||||
"intent_hits": 4,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
DEEP_RESPONSE: dict[str, Any] = {
|
||||
"query": "deep research systems",
|
||||
"source": "google",
|
||||
"count": 8,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [
|
||||
{
|
||||
"title": "Deep research systems",
|
||||
"url": "https://example.com/deep-research",
|
||||
"description": "Survey of deep research systems.",
|
||||
"source": "example.com",
|
||||
"extra": {},
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"deep": {
|
||||
"query": "deep research systems",
|
||||
"markdown": "# Deep research\n\nA survey.",
|
||||
"sources": [
|
||||
{
|
||||
"title": "Deep research systems",
|
||||
"url": "https://example.com/deep-research",
|
||||
"description": "Survey of deep research systems.",
|
||||
"source": "example.com",
|
||||
"extra": {},
|
||||
"grade": {
|
||||
"overall": 9.2,
|
||||
"relevance": 8.8,
|
||||
"depth": 9.0,
|
||||
"authority": 9.5,
|
||||
"freshness": 7.0,
|
||||
"word_count": 1200,
|
||||
"intent_hits": 4,
|
||||
},
|
||||
}
|
||||
],
|
||||
"graded_count": 8,
|
||||
"total_count": 10,
|
||||
"model": "gemma-3-12b-it",
|
||||
"elapsed": 166.96,
|
||||
"cache_hit": False,
|
||||
"rounds": 3,
|
||||
"queries_tried": ["deep research systems", "deep research architecture"],
|
||||
"error": None,
|
||||
},
|
||||
}
|
||||
|
||||
IMAGES_RESPONSE: dict[str, Any] = {
|
||||
"query": "aurora borealis",
|
||||
"source": "wikimedia",
|
||||
"count": 2,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [
|
||||
{
|
||||
"title": "Aurora borealis over Norway",
|
||||
"url": "https://commons.wikimedia.org/wiki/File:Aurora.jpg",
|
||||
"description": "Photograph of the aurora borealis.",
|
||||
"source": "wikimedia",
|
||||
"extra": {
|
||||
"thumbnail": "https://upload.wikimedia.org/thumb.jpg",
|
||||
"dimensions": {"width": 1920, "height": 1080},
|
||||
"mime": "image/jpeg",
|
||||
"license": "CC BY-SA 4.0",
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
CHAT_RESPONSE: dict[str, Any] = {
|
||||
"response": "The answer.",
|
||||
"prompt": "question",
|
||||
"json_mode": True,
|
||||
"cached": False,
|
||||
"error": None,
|
||||
"usage": {
|
||||
"prompt_tokens": 120,
|
||||
"completion_tokens": 80,
|
||||
"total_tokens": 200,
|
||||
"cost_usd": 0.0012,
|
||||
},
|
||||
}
|
||||
|
||||
DESCRIBE_RESPONSE: dict[str, Any] = {
|
||||
"url": "https://example.com/page",
|
||||
"description": "Page description",
|
||||
"elapsed": 1.23,
|
||||
"timestamp": "2026-08-07T12:00:00Z",
|
||||
}
|
||||
|
||||
SEARCH_EMPTY_OK: dict[str, Any] = {
|
||||
"query": "q",
|
||||
"source": "s",
|
||||
"count": 1,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def _recorded_request(fixture: dict[str, Any]) -> tuple[list[tuple[Any, ...]], Any]:
|
||||
recorded: list[tuple[Any, ...]] = []
|
||||
|
||||
def fake(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, str] | None,
|
||||
payload: bytes | None,
|
||||
headers: dict[str, str] | None,
|
||||
timeout: float | None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
recorded.append((method, path, params, payload, headers, timeout))
|
||||
return 200, fixture
|
||||
|
||||
return recorded, fake
|
||||
|
||||
|
||||
def _raising_request(message: str, status_code: int) -> Any:
|
||||
def fake(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, str] | None,
|
||||
payload: bytes | None,
|
||||
headers: dict[str, str] | None,
|
||||
timeout: float | None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
raise RsearchError(message, status_code)
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status: int, body: bytes) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
def __enter__(self) -> "_FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
class TestSearchResponseParsing(unittest.TestCase):
|
||||
|
||||
def test_web_results_parse_into_search_response(self) -> None:
|
||||
response = SearchResponse.from_dict(WEB_RESPONSE)
|
||||
self.assertEqual(response.query, "asyncio python")
|
||||
self.assertEqual(response.source, "duckduckgo")
|
||||
self.assertEqual(response.count, 3)
|
||||
self.assertTrue(response.success)
|
||||
self.assertIsNone(response.error)
|
||||
self.assertEqual(response.timestamp, "2026-08-07T12:00:00Z")
|
||||
self.assertEqual(len(response.results), 2)
|
||||
first = response.results[0]
|
||||
self.assertIsInstance(first, SearchResult)
|
||||
self.assertEqual(first.title, "asyncio documentation")
|
||||
self.assertEqual(first.url, "https://docs.python.org/3/library/asyncio.html")
|
||||
self.assertEqual(first.description, "Asynchronous I/O event loop.")
|
||||
self.assertEqual(first.source, "docs.python.org")
|
||||
self.assertEqual(first.extra, {"rank": 1})
|
||||
self.assertEqual(first.index, 0)
|
||||
self.assertIsNone(first.content)
|
||||
self.assertIsNone(first.grade)
|
||||
self.assertIsNone(first.query_origin)
|
||||
self.assertIsNone(response.ai_response)
|
||||
self.assertIsNone(response.deep)
|
||||
|
||||
def test_ai_memory_variant_parses(self) -> None:
|
||||
response = SearchResponse.from_dict(AI_MEMORY_RESPONSE)
|
||||
self.assertEqual(response.source, "ai")
|
||||
self.assertEqual(response.results, [])
|
||||
self.assertIn("From memory", response.ai_response)
|
||||
self.assertIsNone(response.ai_error)
|
||||
|
||||
def test_ai_provider_variant_parses(self) -> None:
|
||||
response = SearchResponse.from_dict(AI_PROVIDER_RESPONSE)
|
||||
self.assertEqual(response.source, "google")
|
||||
self.assertEqual(len(response.results), 1)
|
||||
self.assertIn("[citation:1]", response.ai_response)
|
||||
self.assertIsNone(response.ai_error)
|
||||
|
||||
def test_deep_block_parses_into_deep_report(self) -> None:
|
||||
response = SearchResponse.from_dict(DEEP_RESPONSE)
|
||||
self.assertIsNotNone(response.deep)
|
||||
deep = response.deep
|
||||
self.assertIsInstance(deep, DeepReport)
|
||||
self.assertEqual(deep.query, "deep research systems")
|
||||
self.assertEqual(deep.markdown, "# Deep research\n\nA survey.")
|
||||
self.assertEqual(deep.graded_count, 8)
|
||||
self.assertEqual(deep.total_count, 10)
|
||||
self.assertEqual(deep.model, "gemma-3-12b-it")
|
||||
self.assertEqual(deep.elapsed, 166.96)
|
||||
self.assertFalse(deep.cache_hit)
|
||||
self.assertEqual(deep.rounds, 3)
|
||||
self.assertEqual(deep.queries_tried, ["deep research systems", "deep research architecture"])
|
||||
self.assertIsNone(deep.error)
|
||||
self.assertEqual(len(deep.sources), 1)
|
||||
source = deep.sources[0]
|
||||
self.assertIsInstance(source, SearchResult)
|
||||
self.assertEqual(source.url, "https://example.com/deep-research")
|
||||
self.assertIsInstance(source.grade, SearchGrade)
|
||||
self.assertEqual(source.grade.overall, 9.2)
|
||||
|
||||
def test_images_results_parse_extra_metadata(self) -> None:
|
||||
response = SearchResponse.from_dict(IMAGES_RESPONSE)
|
||||
self.assertEqual(response.source, "wikimedia")
|
||||
result = response.results[0]
|
||||
self.assertEqual(result.extra["mime"], "image/jpeg")
|
||||
self.assertEqual(result.extra["dimensions"], {"width": 1920, "height": 1080})
|
||||
self.assertEqual(result.extra["license"], "CC BY-SA 4.0")
|
||||
self.assertIn("thumbnail", result.extra)
|
||||
|
||||
def test_result_grade_parses_into_search_grade(self) -> None:
|
||||
response = SearchResponse.from_dict(GRADED_RESPONSE)
|
||||
grade = response.results[0].grade
|
||||
self.assertIsInstance(grade, SearchGrade)
|
||||
self.assertEqual(grade.overall, 9.2)
|
||||
self.assertEqual(grade.relevance, 8.8)
|
||||
self.assertEqual(grade.depth, 9.0)
|
||||
self.assertEqual(grade.authority, 9.5)
|
||||
self.assertEqual(grade.freshness, 7.0)
|
||||
self.assertEqual(grade.word_count, 1200)
|
||||
self.assertEqual(grade.intent_hits, 4)
|
||||
|
||||
def test_sparse_body_parses_with_defaults(self) -> None:
|
||||
response = SearchResponse.from_dict({"query": "x", "success": True})
|
||||
self.assertEqual(response.source, "")
|
||||
self.assertEqual(response.count, 0)
|
||||
self.assertEqual(response.results, [])
|
||||
self.assertIsNone(response.error)
|
||||
self.assertIsNone(response.deep)
|
||||
self.assertIsNone(response.ai_response)
|
||||
|
||||
|
||||
class TestSearchRequestConstruction(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_search_forwards_all_parameters(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(SEARCH_EMPTY_OK)
|
||||
client._request = fake
|
||||
await client.search(
|
||||
"query text",
|
||||
source="google",
|
||||
count=7,
|
||||
content=True,
|
||||
type="images",
|
||||
deep=True,
|
||||
ai=True,
|
||||
cache=False,
|
||||
)
|
||||
method, path, params, payload, headers, timeout = recorded[0]
|
||||
self.assertEqual(method, "GET")
|
||||
self.assertEqual(path, "/search")
|
||||
self.assertEqual(
|
||||
params,
|
||||
{
|
||||
"query": "query text",
|
||||
"source": "google",
|
||||
"count": "7",
|
||||
"content": "true",
|
||||
"type": "images",
|
||||
"deep": "true",
|
||||
"ai": "true",
|
||||
"cache": "false",
|
||||
},
|
||||
)
|
||||
self.assertIsNone(payload)
|
||||
self.assertIsNone(headers)
|
||||
self.assertEqual(timeout, 180.0)
|
||||
|
||||
async def test_search_without_deep_uses_request_timeout(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(SEARCH_EMPTY_OK)
|
||||
client._request = fake
|
||||
await client.search("q")
|
||||
self.assertEqual(recorded[0][5], 30.0)
|
||||
|
||||
async def test_count_none_omits_count_parameter(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(SEARCH_EMPTY_OK)
|
||||
client._request = fake
|
||||
await client.search("q")
|
||||
self.assertNotIn("count", recorded[0][2])
|
||||
|
||||
async def test_count_zero_forwarded_and_server_clamp_parsed(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(
|
||||
{"query": "q", "source": "s", "count": 1, "success": True, "error": None, "results": []}
|
||||
)
|
||||
client._request = fake
|
||||
response = await client.search("q", count=0)
|
||||
self.assertEqual(recorded[0][2]["count"], "0")
|
||||
self.assertEqual(response.count, 1)
|
||||
|
||||
async def test_count_above_limit_forwarded_and_server_clamp_parsed(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(
|
||||
{"query": "q", "source": "s", "count": 10, "success": True, "error": None, "results": []}
|
||||
)
|
||||
client._request = fake
|
||||
response = await client.search("q", count=25)
|
||||
self.assertEqual(recorded[0][2]["count"], "25")
|
||||
self.assertEqual(response.count, 10)
|
||||
|
||||
async def test_invalid_count_forwarded_and_server_clamp_parsed(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(
|
||||
{"query": "q", "source": "s", "count": 10, "success": True, "error": None, "results": []}
|
||||
)
|
||||
client._request = fake
|
||||
response = await client.search("q", count="not-a-number")
|
||||
self.assertEqual(recorded[0][2]["count"], "not-a-number")
|
||||
self.assertEqual(response.count, 10)
|
||||
|
||||
async def test_search_cache_hit_skips_second_request(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(SEARCH_EMPTY_OK)
|
||||
client._request = fake
|
||||
await client.search("cached query")
|
||||
await client.search("cached query")
|
||||
self.assertEqual(len(recorded), 1)
|
||||
|
||||
async def test_search_cache_disabled_repeats_request(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(SEARCH_EMPTY_OK)
|
||||
client._request = fake
|
||||
await client.search("uncached query", cache=False)
|
||||
await client.search("uncached query", cache=False)
|
||||
self.assertEqual(len(recorded), 2)
|
||||
|
||||
async def test_search_with_content_populates_content_cache(self) -> None:
|
||||
client = RsearchClient()
|
||||
fixture = {
|
||||
"query": "q",
|
||||
"source": "s",
|
||||
"count": 1,
|
||||
"success": True,
|
||||
"error": None,
|
||||
"results": [
|
||||
{
|
||||
"title": "t",
|
||||
"url": "https://example.com/a",
|
||||
"description": "d",
|
||||
"source": "s",
|
||||
"extra": {},
|
||||
"content": "full page text",
|
||||
}
|
||||
],
|
||||
}
|
||||
recorded, fake = _recorded_request(fixture)
|
||||
client._request = fake
|
||||
await client.search("q", content=True)
|
||||
self.assertEqual(len(recorded), 1)
|
||||
self.assertEqual(client.get_cached_content("https://example.com/a"), "full page text")
|
||||
|
||||
async def test_search_error_in_body_surfaces_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
client._request = _raising_request("Empty query", 400)
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
await client.search("")
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertEqual(str(ctx.exception), "Empty query")
|
||||
|
||||
|
||||
class TestChatResponseParsing(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_chat_response_parses_envelope(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(CHAT_RESPONSE)
|
||||
client._request = fake
|
||||
response = await client.chat("question", json_mode=True)
|
||||
method, path, params, payload, headers, timeout = recorded[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(path, "/chat")
|
||||
self.assertEqual(json.loads(payload), {"prompt": "question", "json": True})
|
||||
self.assertEqual(headers, {"Content-Type": "application/json"})
|
||||
self.assertIsNone(params)
|
||||
self.assertIsNone(timeout)
|
||||
self.assertIsInstance(response, ChatResponse)
|
||||
self.assertEqual(response.response, "The answer.")
|
||||
self.assertEqual(response.prompt, "question")
|
||||
self.assertTrue(response.json_mode)
|
||||
self.assertFalse(response.cached)
|
||||
self.assertIsNone(response.error)
|
||||
self.assertIsInstance(response.usage, ChatUsage)
|
||||
self.assertEqual(response.usage.prompt_tokens, 120)
|
||||
self.assertEqual(response.usage.completion_tokens, 80)
|
||||
self.assertEqual(response.usage.total_tokens, 200)
|
||||
self.assertEqual(response.usage.cost_usd, 0.0012)
|
||||
|
||||
async def test_chat_request_accepts_system_and_disables_cache(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(CHAT_RESPONSE)
|
||||
client._request = fake
|
||||
await client.chat("q", system="sys", cache=False)
|
||||
body = json.loads(recorded[0][3])
|
||||
self.assertEqual(body, {"prompt": "q", "system": "sys", "cache": False})
|
||||
|
||||
async def test_chat_error_raises_mapped_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
client._request = _raising_request("No prompt provided", 400)
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
await client.chat("")
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertEqual(str(ctx.exception), "No prompt provided")
|
||||
|
||||
|
||||
class TestDescribeResponseParsing(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_describe_get_parses_envelope(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(DESCRIBE_RESPONSE)
|
||||
client._request = fake
|
||||
response = await client.describe("https://example.com/page")
|
||||
method, path, params, payload, headers, timeout = recorded[0]
|
||||
self.assertEqual(method, "GET")
|
||||
self.assertEqual(path, "/describe")
|
||||
self.assertEqual(params, {"url": "https://example.com/page"})
|
||||
self.assertIsNone(payload)
|
||||
self.assertIsNone(headers)
|
||||
self.assertIsNone(timeout)
|
||||
self.assertIsInstance(response, DescribeResponse)
|
||||
self.assertEqual(response.description, "Page description")
|
||||
self.assertEqual(response.url, "https://example.com/page")
|
||||
self.assertEqual(response.elapsed, 1.23)
|
||||
self.assertEqual(response.timestamp, "2026-08-07T12:00:00Z")
|
||||
self.assertIsNone(response.mime_type)
|
||||
self.assertIsNone(response.size)
|
||||
self.assertTrue(response.success)
|
||||
|
||||
async def test_describe_get_uses_cache(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(DESCRIBE_RESPONSE)
|
||||
client._request = fake
|
||||
await client.describe("https://example.com/page")
|
||||
await client.describe("https://example.com/page")
|
||||
self.assertEqual(len(recorded), 1)
|
||||
|
||||
async def test_describe_error_raises_mapped_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
client._request = _raising_request("No url provided", 400)
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
await client.describe("")
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertEqual(str(ctx.exception), "No url provided")
|
||||
|
||||
async def test_describe_raw_posts_bytes_with_content_type(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(DESCRIBE_RESPONSE)
|
||||
client._request = fake
|
||||
image = b"\x89PNG\r\n\x1a\npayload"
|
||||
await client.describe_raw(image, mime_type="image/png")
|
||||
method, path, params, payload, headers, timeout = recorded[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(path, "/describe")
|
||||
self.assertEqual(payload, image)
|
||||
self.assertEqual(headers, {"Content-Type": "image/png"})
|
||||
|
||||
async def test_describe_upload_builds_multipart_body(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(DESCRIBE_RESPONSE)
|
||||
client._request = fake
|
||||
image = b"\x89PNGpayload"
|
||||
await client.describe_upload(image, filename="photo.png", mime_type="image/png")
|
||||
method, path, params, payload, headers, timeout = recorded[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(path, "/describe")
|
||||
self.assertIn(b'name="file"; filename="photo.png"', payload)
|
||||
self.assertIn(b"Content-Type: image/png", payload)
|
||||
self.assertIn(image, payload)
|
||||
self.assertIn("multipart/form-data; boundary=", headers["Content-Type"])
|
||||
|
||||
async def test_describe_raw_reuses_cache_by_hash(self) -> None:
|
||||
client = RsearchClient()
|
||||
recorded, fake = _recorded_request(DESCRIBE_RESPONSE)
|
||||
client._request = fake
|
||||
image = b"\x89PNGpayload"
|
||||
await client.describe_raw(image, mime_type="image/png")
|
||||
await client.describe_raw(image, mime_type="image/png")
|
||||
self.assertEqual(len(recorded), 1)
|
||||
|
||||
|
||||
class TestErrorInBodyHandling(unittest.TestCase):
|
||||
|
||||
def test_empty_query_error_in_body_maps_to_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
body = b'{"success": false, "error": "Empty query"}'
|
||||
error = urllib.error.HTTPError(
|
||||
"https://rsearch.app.molodetz.nl/search", 400, "Bad Request", {}, io.BytesIO(body)
|
||||
)
|
||||
with mock.patch("urllib.request.urlopen", side_effect=error):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": ""}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertEqual(str(ctx.exception), "Empty query")
|
||||
|
||||
def test_providers_exhausted_503_maps_to_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
body = b'{"success": false, "error": "All providers are exhausted, please try again later"}'
|
||||
error = urllib.error.HTTPError(
|
||||
"https://rsearch.app.molodetz.nl/search", 503, "Service Unavailable", {}, io.BytesIO(body)
|
||||
)
|
||||
with mock.patch("urllib.request.urlopen", side_effect=error):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 503)
|
||||
self.assertEqual(str(ctx.exception), "All providers are exhausted, please try again later")
|
||||
|
||||
def test_success_false_body_with_http_200_raises(self) -> None:
|
||||
client = RsearchClient()
|
||||
fake = _FakeResponse(200, b'{"success": false, "error": "Empty query"}')
|
||||
with mock.patch("urllib.request.urlopen", return_value=fake):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": ""}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 200)
|
||||
self.assertEqual(str(ctx.exception), "Empty query")
|
||||
|
||||
def test_detail_field_falls_back_for_error_message(self) -> None:
|
||||
client = RsearchClient()
|
||||
body = b'{"detail": "No url provided"}'
|
||||
error = urllib.error.HTTPError(
|
||||
"https://rsearch.app.molodetz.nl/describe", 400, "Bad Request", {}, io.BytesIO(body)
|
||||
)
|
||||
with mock.patch("urllib.request.urlopen", side_effect=error):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/describe", {"url": "x"}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertEqual(str(ctx.exception), "No url provided")
|
||||
|
||||
def test_title_field_falls_back_for_error_message(self) -> None:
|
||||
client = RsearchClient()
|
||||
body = b'{"title": "Provider error"}'
|
||||
error = urllib.error.HTTPError(
|
||||
"https://rsearch.app.molodetz.nl/search", 502, "Bad Gateway", {}, io.BytesIO(body)
|
||||
)
|
||||
with mock.patch("urllib.request.urlopen", side_effect=error):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 502)
|
||||
self.assertEqual(str(ctx.exception), "Provider error")
|
||||
|
||||
def test_empty_body_raises_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
fake = _FakeResponse(200, b"")
|
||||
with mock.patch("urllib.request.urlopen", return_value=fake):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 200)
|
||||
self.assertIn("empty response", str(ctx.exception))
|
||||
|
||||
def test_invalid_json_body_raises_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
fake = _FakeResponse(200, b"<html>not json</html>")
|
||||
with mock.patch("urllib.request.urlopen", return_value=fake):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 200)
|
||||
self.assertIn("invalid JSON", str(ctx.exception))
|
||||
|
||||
def test_non_dict_body_raises_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
fake = _FakeResponse(200, b'["not", "a", "dict"]')
|
||||
with mock.patch("urllib.request.urlopen", return_value=fake):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertEqual(ctx.exception.status_code, 200)
|
||||
|
||||
def test_connection_failure_raises_rsearch_error(self) -> None:
|
||||
client = RsearchClient()
|
||||
with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("connection refused")):
|
||||
with self.assertRaises(RsearchError) as ctx:
|
||||
client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertIn("connection failure", str(ctx.exception))
|
||||
|
||||
def test_successful_request_returns_status_and_body(self) -> None:
|
||||
client = RsearchClient()
|
||||
fake = _FakeResponse(200, b'{"success": true, "query": "q", "count": 1, "results": []}')
|
||||
with mock.patch("urllib.request.urlopen", return_value=fake) as urlopen:
|
||||
status, data = client._request("GET", "/search", {"query": "q"}, None, None, 30.0)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {"success": True, "query": "q", "count": 1, "results": []})
|
||||
request = urlopen.call_args[0][0]
|
||||
self.assertEqual(request.get_method(), "GET")
|
||||
self.assertEqual(request.get_full_url(), "https://rsearch.app.molodetz.nl/search?query=q")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user