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
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m29s
This commit is contained in:
parent
2eca4e700b
commit
dfd410f842
5 changed files with 2212 additions and 4 deletions
1484
sdk/node/package-lock.json
generated
Normal file
1484
sdk/node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,12 +12,25 @@
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && tsc --module commonjs --outDir dist/cjs && mv dist/cjs/index.js dist/index.cjs && rm -rf dist/cjs",
|
"build": "tsc && tsc --module commonjs --outDir dist/cjs && mv dist/cjs/index.js dist/index.cjs && rm -rf dist/cjs",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": ["screenshot", "api", "webpage", "capture", "puppeteer", "headless", "eu", "gdpr"],
|
"keywords": [
|
||||||
|
"screenshot",
|
||||||
|
"api",
|
||||||
|
"webpage",
|
||||||
|
"capture",
|
||||||
|
"puppeteer",
|
||||||
|
"headless",
|
||||||
|
"eu",
|
||||||
|
"gdpr"
|
||||||
|
],
|
||||||
"author": "Cloonar Technologies GmbH",
|
"author": "Cloonar Technologies GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://snapapi.eu",
|
"homepage": "https://snapapi.eu",
|
||||||
|
|
@ -29,6 +42,7 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
323
sdk/node/test/snapapi.test.ts
Normal file
323
sdk/node/test/snapapi.test.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { SnapAPI, SnapAPIError, ScreenshotOptions } from '../src/index.js';
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
// Mock AbortController and AbortSignal
|
||||||
|
const mockAbort = vi.fn();
|
||||||
|
const mockAbortController = {
|
||||||
|
abort: mockAbort,
|
||||||
|
signal: {
|
||||||
|
aborted: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
} as AbortSignal,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Properly mock AbortController as a constructor
|
||||||
|
class MockAbortController {
|
||||||
|
abort = mockAbort;
|
||||||
|
signal = mockAbortController.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('AbortController', MockAbortController);
|
||||||
|
vi.stubGlobal('setTimeout', vi.fn((fn: () => void, delay: number) => {
|
||||||
|
// Return a timer ID that can be cleared
|
||||||
|
return 123;
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('clearTimeout', vi.fn());
|
||||||
|
|
||||||
|
describe('SnapAPI', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockAbort.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('throws if no apiKey', () => {
|
||||||
|
expect(() => new SnapAPI('')).toThrow('SnapAPI: apiKey is required');
|
||||||
|
expect(() => new SnapAPI(null as any)).toThrow('SnapAPI: apiKey is required');
|
||||||
|
expect(() => new SnapAPI(undefined as any)).toThrow('SnapAPI: apiKey is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets defaults (baseUrl, timeout)', () => {
|
||||||
|
const snap = new SnapAPI('test-key');
|
||||||
|
expect(snap['baseUrl']).toBe('https://snapapi.eu');
|
||||||
|
expect(snap['timeout']).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts custom config', () => {
|
||||||
|
const snap = new SnapAPI('test-key', {
|
||||||
|
baseUrl: 'https://custom.snapapi.com',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
expect(snap['baseUrl']).toBe('https://custom.snapapi.com');
|
||||||
|
expect(snap['timeout']).toBe(60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing slash from baseUrl', () => {
|
||||||
|
const snap = new SnapAPI('test-key', {
|
||||||
|
baseUrl: 'https://custom.snapapi.com/',
|
||||||
|
});
|
||||||
|
expect(snap['baseUrl']).toBe('https://custom.snapapi.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capture()', () => {
|
||||||
|
let snap: SnapAPI;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
snap = new SnapAPI('test-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct POST with Bearer auth', async () => {
|
||||||
|
const mockArrayBuffer = new ArrayBuffer(100);
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
await snap.capture('https://example.com');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://snapapi.eu/v1/screenshot',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer test-api-key',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: 'https://example.com' }),
|
||||||
|
signal: mockAbortController.signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends all options in body', async () => {
|
||||||
|
const mockArrayBuffer = new ArrayBuffer(100);
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
await snap.capture('https://example.com', {
|
||||||
|
format: 'jpeg',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
quality: 90,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://snapapi.eu/v1/screenshot',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'jpeg',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
quality: 90,
|
||||||
|
fullPage: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with ScreenshotOptions object form', async () => {
|
||||||
|
const mockArrayBuffer = new ArrayBuffer(100);
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: ScreenshotOptions = {
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'png',
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
deviceScale: 2,
|
||||||
|
waitForSelector: '.content',
|
||||||
|
};
|
||||||
|
|
||||||
|
await snap.capture(options);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://snapapi.eu/v1/screenshot',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify(options),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if no url', async () => {
|
||||||
|
await expect(snap.capture('')).rejects.toThrow('SnapAPI: url is required');
|
||||||
|
|
||||||
|
const optionsWithoutUrl = {} as ScreenshotOptions;
|
||||||
|
await expect(snap.capture(optionsWithoutUrl)).rejects.toThrow('SnapAPI: url is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws SnapAPIError on 401/403/429 with error detail', async () => {
|
||||||
|
// Test 401
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: () => Promise.resolve({ error: 'Invalid API key' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||||
|
name: 'SnapAPIError',
|
||||||
|
status: 401,
|
||||||
|
detail: 'Invalid API key',
|
||||||
|
message: 'SnapAPI error 401: Invalid API key',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 403
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: () => Promise.resolve({ error: 'Forbidden' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||||
|
name: 'SnapAPIError',
|
||||||
|
status: 403,
|
||||||
|
detail: 'Forbidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 429
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
json: () => Promise.resolve({ error: 'Rate limit exceeded' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||||
|
name: 'SnapAPIError',
|
||||||
|
status: 429,
|
||||||
|
detail: 'Rate limit exceeded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws SnapAPIError with fallback message when error JSON fails', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||||
|
name: 'SnapAPIError',
|
||||||
|
status: 500,
|
||||||
|
detail: 'HTTP 500',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Buffer on success', async () => {
|
||||||
|
const mockArrayBuffer = new ArrayBuffer(100);
|
||||||
|
// Fill with some test data
|
||||||
|
const view = new Uint8Array(mockArrayBuffer);
|
||||||
|
view.fill(0xFF);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await snap.capture('https://example.com');
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Buffer);
|
||||||
|
expect(result.length).toBe(100);
|
||||||
|
expect(result[0]).toBe(0xFF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets up AbortController correctly for timeout', async () => {
|
||||||
|
const mockArrayBuffer = new ArrayBuffer(100);
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customSnap = new SnapAPI('test-key', { timeout: 15000 });
|
||||||
|
await customSnap.capture('https://example.com');
|
||||||
|
|
||||||
|
// Verify setTimeout was called with correct timeout
|
||||||
|
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 15000);
|
||||||
|
|
||||||
|
// Verify clearTimeout was called (cleanup)
|
||||||
|
expect(clearTimeout).toHaveBeenCalledWith(123);
|
||||||
|
|
||||||
|
// Verify fetch was called with the abort signal
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
signal: mockAbortController.signal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('health()', () => {
|
||||||
|
let snap: SnapAPI;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
snap = new SnapAPI('test-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls GET /health and returns parsed JSON', async () => {
|
||||||
|
const mockHealthResponse = {
|
||||||
|
status: 'ok',
|
||||||
|
version: '1.0.0',
|
||||||
|
uptime: 12345,
|
||||||
|
browser: {
|
||||||
|
browsers: 2,
|
||||||
|
totalPages: 10,
|
||||||
|
availablePages: 8,
|
||||||
|
queueDepth: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockHealthResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await snap.health();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://snapapi.eu/health');
|
||||||
|
expect(result).toEqual(mockHealthResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws SnapAPIError on failure', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(snap.health()).rejects.toMatchObject({
|
||||||
|
name: 'SnapAPIError',
|
||||||
|
status: 503,
|
||||||
|
detail: 'Health check failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SnapAPIError', () => {
|
||||||
|
it('creates proper error with status and detail', () => {
|
||||||
|
const error = new SnapAPIError(400, 'Bad Request');
|
||||||
|
|
||||||
|
expect(error.name).toBe('SnapAPIError');
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
expect(error.detail).toBe('Bad Request');
|
||||||
|
expect(error.message).toBe('SnapAPI error 400: Bad Request');
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(error).toBeInstanceOf(SnapAPIError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
8
sdk/node/vitest.config.ts
Normal file
8
sdk/node/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
379
sdk/python/tests/test_snapapi.py
Normal file
379
sdk/python/tests/test_snapapi.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue