Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
- 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.
502 lines
No EOL
18 KiB
Python
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() |