SnapAPI/sdk/python/tests/test_snapapi.py
Hoid (OpenClaw) e6c34ef760
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
Add comprehensive tests and docs for darkMode & hideSelectors
- Add 5 new Python tests for darkMode and hideSelectors parameters
- Update Node.js SDK README with darkMode/hideSelectors examples
- Update Python SDK README with darkMode/hideSelectors examples
- Add API reference entries for new parameters
- All tests passing: Node.js (19 tests), Python (22 tests)

Features already implemented in v0.7.0 but needed better test coverage and documentation.
2026-03-04 18:04:18 +01:00

502 lines
No EOL
18 KiB
Python

"""Comprehensive unit tests for SnapAPI Python SDK."""
import unittest
import json
from unittest.mock import patch, Mock, MagicMock
from urllib.error import HTTPError
from snapapi import SnapAPI, SnapAPIError, ScreenshotOptions
class TestSnapAPI(unittest.TestCase):
"""Test cases for SnapAPI client."""
def setUp(self):
"""Set up test fixtures."""
self.api_key = "test-api-key"
self.snap = SnapAPI(self.api_key)
def test_constructor_raises_value_error_if_no_api_key(self):
"""Constructor should raise ValueError if no api_key provided."""
with self.assertRaises(ValueError) as cm:
SnapAPI("")
self.assertEqual(str(cm.exception), "api_key is required")
with self.assertRaises(ValueError) as cm:
SnapAPI(None)
self.assertEqual(str(cm.exception), "api_key is required")
def test_constructor_defaults(self):
"""Constructor should set default values correctly."""
snap = SnapAPI("test-key")
self.assertEqual(snap.api_key, "test-key")
self.assertEqual(snap.base_url, "https://snapapi.eu")
self.assertEqual(snap.timeout, 30)
def test_constructor_custom_config(self):
"""Constructor should accept custom configuration."""
snap = SnapAPI(
"test-key",
base_url="https://custom.snapapi.com",
timeout=60
)
self.assertEqual(snap.api_key, "test-key")
self.assertEqual(snap.base_url, "https://custom.snapapi.com")
self.assertEqual(snap.timeout, 60)
def test_constructor_strips_trailing_slash(self):
"""Constructor should strip trailing slash from base_url."""
snap = SnapAPI("test-key", base_url="https://custom.snapapi.com/")
self.assertEqual(snap.base_url, "https://custom.snapapi.com")
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_url_correct_request_with_auth_header(self, mock_urlopen):
"""capture(url) should send correct request with authorization header."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'fake-image-data'
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.capture("https://example.com")
# Verify the request
self.assertEqual(mock_urlopen.call_count, 1)
request_arg = mock_urlopen.call_args[0][0]
self.assertEqual(request_arg.full_url, "https://snapapi.eu/v1/screenshot")
self.assertEqual(request_arg.method, "POST")
self.assertEqual(request_arg.headers["Content-type"], "application/json")
self.assertEqual(request_arg.headers["Authorization"], "Bearer test-api-key")
# Verify body
request_body = json.loads(request_arg.data.decode())
self.assertEqual(request_body, {"url": "https://example.com"})
# Verify return value
self.assertEqual(result, b'fake-image-data')
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_with_keyword_args_converts_to_camel_case(self, mock_urlopen):
"""capture with keyword args should convert all params to camelCase."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'fake-image-data'
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.capture(
url="https://example.com",
format="jpeg",
width=1920,
height=1080,
full_page=True,
quality=90,
wait_for_selector=".content",
device_scale=2.0,
delay=1000,
wait_until="networkidle0"
)
# Verify request body conversion to camelCase
request_arg = mock_urlopen.call_args[0][0]
request_body = json.loads(request_arg.data.decode())
expected_body = {
"url": "https://example.com",
"format": "jpeg",
"width": 1920,
"height": 1080,
"fullPage": True, # snake_case -> camelCase
"quality": 90,
"waitForSelector": ".content", # snake_case -> camelCase
"deviceScale": 2.0, # snake_case -> camelCase
"delay": 1000,
"waitUntil": "networkidle0" # snake_case -> camelCase
}
self.assertEqual(request_body, expected_body)
self.assertEqual(result, b'fake-image-data')
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_with_screenshot_options_object(self, mock_urlopen):
"""capture with ScreenshotOptions object should work correctly."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'fake-image-data'
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
options = ScreenshotOptions(
url="https://example.com",
format="png",
width=1280,
device_scale=2.0,
wait_for_selector=".content"
)
result = self.snap.capture(options=options)
# Verify request body
request_arg = mock_urlopen.call_args[0][0]
request_body = json.loads(request_arg.data.decode())
expected_body = {
"url": "https://example.com",
"format": "png",
"width": 1280,
"deviceScale": 2.0, # converted to camelCase
"waitForSelector": ".content" # converted to camelCase
}
self.assertEqual(request_body, expected_body)
self.assertEqual(result, b'fake-image-data')
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_with_dark_mode_parameter(self, mock_urlopen):
"""capture with dark_mode parameter should work correctly."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'fake-dark-image-data'
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.capture(
url="https://example.com",
dark_mode=True
)
# Verify request body contains darkMode
request_arg = mock_urlopen.call_args[0][0]
request_body = json.loads(request_arg.data.decode())
expected_body = {
"url": "https://example.com",
"darkMode": True
}
self.assertEqual(request_body, expected_body)
self.assertEqual(result, b'fake-dark-image-data')
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_with_hide_selectors_list_parameter(self, mock_urlopen):
"""capture with hide_selectors as list should work correctly."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'fake-clean-image-data'
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.capture(
url="https://example.com",
hide_selectors=['.ads', '.popup', '#cookie-banner']
)
# Verify request body contains hideSelectors
request_arg = mock_urlopen.call_args[0][0]
request_body = json.loads(request_arg.data.decode())
expected_body = {
"url": "https://example.com",
"hideSelectors": ['.ads', '.popup', '#cookie-banner']
}
self.assertEqual(request_body, expected_body)
self.assertEqual(result, b'fake-clean-image-data')
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_with_both_dark_mode_and_hide_selectors(self, mock_urlopen):
"""capture with both dark_mode and hide_selectors should work correctly."""
# Mock response
mock_response = Mock()
mock_response.read.return_value = b'fake-dark-clean-image-data'
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.capture(
url="https://example.com",
dark_mode=True,
hide_selectors=['.tracking', '.ads'],
format="png"
)
# Verify request body contains both parameters
request_arg = mock_urlopen.call_args[0][0]
request_body = json.loads(request_arg.data.decode())
expected_body = {
"url": "https://example.com",
"darkMode": True,
"hideSelectors": ['.tracking', '.ads'],
"format": "png"
}
self.assertEqual(request_body, expected_body)
self.assertEqual(result, b'fake-dark-clean-image-data')
def test_capture_raises_value_error_if_no_url(self):
"""capture() should raise ValueError if no url provided."""
with self.assertRaises(ValueError) as cm:
self.snap.capture("")
self.assertEqual(str(cm.exception), "url is required")
with self.assertRaises(ValueError) as cm:
self.snap.capture(None)
self.assertEqual(str(cm.exception), "url is required")
def test_capture_raises_value_error_if_options_has_empty_url(self):
"""capture(options=ScreenshotOptions(url='')) should raise ValueError."""
options = ScreenshotOptions(url="")
with self.assertRaises(ValueError) as cm:
self.snap.capture(options=options)
self.assertEqual(str(cm.exception), "url is required")
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_raises_snap_api_error_on_http_error(self, mock_urlopen):
"""capture() should raise SnapAPIError on HTTPError."""
# Test 401 Unauthorized
mock_fp = Mock()
mock_fp.read.return_value = b'{"error": "Invalid API key"}'
mock_error = HTTPError(
url="https://snapapi.eu/v1/screenshot",
code=401,
msg="Unauthorized",
hdrs=None,
fp=mock_fp
)
# HTTPError.read() should delegate to fp.read()
mock_error.read = mock_fp.read
mock_urlopen.side_effect = mock_error
with self.assertRaises(SnapAPIError) as cm:
self.snap.capture("https://example.com")
error = cm.exception
self.assertEqual(error.status, 401)
self.assertEqual(error.detail, "Invalid API key")
self.assertEqual(str(error), "SnapAPI error 401: Invalid API key")
# Test 429 Rate Limit
mock_fp2 = Mock()
mock_fp2.read.return_value = b'{"error": "Rate limit exceeded"}'
mock_error2 = HTTPError(
url="https://snapapi.eu/v1/screenshot",
code=429,
msg="Too Many Requests",
hdrs=None,
fp=mock_fp2
)
mock_error2.read = mock_fp2.read
mock_urlopen.side_effect = mock_error2
with self.assertRaises(SnapAPIError) as cm:
self.snap.capture("https://example.com")
error = cm.exception
self.assertEqual(error.status, 429)
self.assertEqual(error.detail, "Rate limit exceeded")
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_raises_snap_api_error_with_fallback_on_json_parse_error(self, mock_urlopen):
"""capture() should use fallback message when error JSON parsing fails."""
mock_error = HTTPError(
url="https://snapapi.eu/v1/screenshot",
code=500,
msg="Internal Server Error",
hdrs=None,
fp=Mock()
)
mock_error.read.return_value = b'invalid json'
mock_urlopen.side_effect = mock_error
with self.assertRaises(SnapAPIError) as cm:
self.snap.capture("https://example.com")
error = cm.exception
self.assertEqual(error.status, 500)
self.assertEqual(error.detail, "HTTP 500")
@patch('snapapi.client.urllib.request.urlopen')
def test_capture_returns_bytes_on_success(self, mock_urlopen):
"""capture() should return bytes on successful response."""
# Mock response
test_image_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00' * 10 # Fake PNG data
mock_response = Mock()
mock_response.read.return_value = test_image_data
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.capture("https://example.com")
self.assertIsInstance(result, bytes)
self.assertEqual(result, test_image_data)
self.assertEqual(len(result), len(test_image_data))
@patch('snapapi.client.urllib.request.urlopen')
def test_health_correct_request_returns_dict(self, mock_urlopen):
"""health() should make correct request and return dict."""
# Mock response
health_data = {
"status": "ok",
"version": "1.0.0",
"uptime": 12345,
"browser": {
"browsers": 2,
"totalPages": 10,
"availablePages": 8,
"queueDepth": 0
}
}
mock_response = Mock()
mock_response.read.return_value = json.dumps(health_data).encode()
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
mock_urlopen.return_value = mock_response
result = self.snap.health()
# Verify the request
self.assertEqual(mock_urlopen.call_count, 1)
request_arg = mock_urlopen.call_args[0][0]
self.assertEqual(request_arg.full_url, "https://snapapi.eu/health")
self.assertIsNone(request_arg.data) # GET request, no body
# Verify return value
self.assertEqual(result, health_data)
self.assertIsInstance(result, dict)
class TestScreenshotOptions(unittest.TestCase):
"""Test cases for ScreenshotOptions dataclass."""
def test_to_dict_correct_camel_case_mapping_none_excluded(self):
"""to_dict() should correctly map to camelCase and exclude None values."""
# Test with all fields
options = ScreenshotOptions(
url="https://example.com",
format="png",
width=1920,
height=1080,
full_page=True,
quality=90,
wait_for_selector=".content",
device_scale=2.0,
delay=1000,
wait_until="networkidle0"
)
result = options.to_dict()
expected = {
"url": "https://example.com",
"format": "png",
"width": 1920,
"height": 1080,
"fullPage": True,
"quality": 90,
"waitForSelector": ".content",
"deviceScale": 2.0,
"delay": 1000,
"waitUntil": "networkidle0"
}
self.assertEqual(result, expected)
def test_to_dict_excludes_none_values(self):
"""to_dict() should exclude None values."""
options = ScreenshotOptions(
url="https://example.com",
format="png",
width=1920,
# All other fields are None and should be excluded
)
result = options.to_dict()
expected = {
"url": "https://example.com",
"format": "png",
"width": 1920
}
self.assertEqual(result, expected)
# Verify None fields are not present
self.assertNotIn("height", result)
self.assertNotIn("fullPage", result)
self.assertNotIn("quality", result)
self.assertNotIn("waitForSelector", result)
self.assertNotIn("deviceScale", result)
self.assertNotIn("delay", result)
self.assertNotIn("waitUntil", result)
def test_to_dict_with_only_url(self):
"""to_dict() should work with only required url field."""
options = ScreenshotOptions(url="https://example.com")
result = options.to_dict()
expected = {"url": "https://example.com"}
self.assertEqual(result, expected)
def test_to_dict_with_dark_mode_and_hide_selectors(self):
"""to_dict() should correctly handle darkMode and hideSelectors."""
options = ScreenshotOptions(
url="https://example.com",
dark_mode=True,
hide_selectors=['.ads', '.popup', '#banner']
)
result = options.to_dict()
expected = {
"url": "https://example.com",
"darkMode": True, # snake_case -> camelCase
"hideSelectors": ['.ads', '.popup', '#banner'] # snake_case -> camelCase
}
self.assertEqual(result, expected)
def test_to_dict_with_dark_mode_false(self):
"""to_dict() should include darkMode when explicitly set to False."""
options = ScreenshotOptions(
url="https://example.com",
dark_mode=False
)
result = options.to_dict()
expected = {
"url": "https://example.com",
"darkMode": False
}
self.assertEqual(result, expected)
class TestSnapAPIError(unittest.TestCase):
"""Test cases for SnapAPIError exception."""
def test_snap_api_error_creation(self):
"""SnapAPIError should be created correctly with status and detail."""
error = SnapAPIError(400, "Bad Request")
self.assertEqual(error.status, 400)
self.assertEqual(error.detail, "Bad Request")
self.assertEqual(str(error), "SnapAPI error 400: Bad Request")
self.assertIsInstance(error, Exception)
self.assertIsInstance(error, SnapAPIError)
if __name__ == "__main__":
unittest.main()