test: add comprehensive SDK unit tests (Node.js + Python)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m29s

This commit is contained in:
OpenClaw Agent 2026-02-27 08:05:22 +00:00
parent 2eca4e700b
commit dfd410f842
5 changed files with 2212 additions and 4 deletions

View file

@ -0,0 +1,379 @@
"""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')
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")
# Note: When using ScreenshotOptions object, URL validation doesn't happen early
# This could be considered a bug in the SDK but we're testing current behavior
@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)
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()