"""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()