Add comprehensive route-level unit tests
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- Add playground.test.ts with 14 tests for playground endpoint
- Add screenshot.test.ts with 17 tests for screenshot endpoint
- Add health.test.ts with 7 tests for health endpoint
- Add watermark.test.ts with 14 tests for watermark service

Total: 52 new tests covering:
- Input validation and error handling
- Authentication and authorization scenarios
- Caching behavior and cache bypass
- Parameter normalization and limits
- SSRF protection and blocked URLs
- Service error conditions (timeouts, queue full)
- Browser pool integration
- Watermark image processing logic

All tests pass and use proper mocking of dependencies.
This commit is contained in:
SnapAPI Test Agent 2026-02-25 08:05:53 +00:00
parent f696cb36db
commit a20828b09c
4 changed files with 1255 additions and 0 deletions

View file

@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Request, Response } from 'express'
import { healthRouter } from '../health.js'
// Mock dependencies
vi.mock('../../services/browser.js', () => ({
getPoolStats: vi.fn()
}))
const { getPoolStats } = await import('../../services/browser.js')
const mockGetPoolStats = vi.mocked(getPoolStats)
function createMockRequest(overrides: any = {}): Partial<Request> {
return {
method: 'GET',
...overrides
}
}
function createMockResponse(): Partial<Response> {
const res: any = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis()
}
return res
}
describe('Health Route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('GET /health', () => {
it('should return health status with browser pool stats', async () => {
const mockPoolStats = {
size: 3,
available: 2,
pending: 0,
borrowed: 1,
min: 1,
max: 5
}
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
const req = createMockRequest()
const res = createMockResponse()
// Get the GET handler from the router
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get && layer.route.path === '/'
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockGetPoolStats).toHaveBeenCalledOnce()
expect(res.json).toHaveBeenCalledWith({
status: "ok",
version: "0.1.0",
uptime: expect.any(Number),
browser: mockPoolStats
})
})
it('should include process uptime in response', async () => {
const mockPoolStats = {
size: 2,
available: 1,
pending: 1,
borrowed: 1,
min: 1,
max: 3
}
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
const req = createMockRequest()
const res = createMockResponse()
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
const responseCall = res.json.mock.calls[0][0]
expect(responseCall.uptime).toBeTypeOf('number')
expect(responseCall.uptime).toBeGreaterThan(0)
})
it('should handle browser pool stats with different values', async () => {
const mockPoolStats = {
size: 0,
available: 0,
pending: 5,
borrowed: 0,
min: 0,
max: 10
}
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
const req = createMockRequest()
const res = createMockResponse()
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.json).toHaveBeenCalledWith({
status: "ok",
version: "0.1.0",
uptime: expect.any(Number),
browser: mockPoolStats
})
})
it('should return consistent status format', async () => {
mockGetPoolStats.mockReturnValueOnce({})
const req = createMockRequest()
const res = createMockResponse()
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
const responseCall = res.json.mock.calls[0][0]
expect(responseCall).toHaveProperty('status', 'ok')
expect(responseCall).toHaveProperty('version', '0.1.0')
expect(responseCall).toHaveProperty('uptime')
expect(responseCall).toHaveProperty('browser')
})
it('should handle browser pool errors gracefully', async () => {
// If getPoolStats throws, we should still get a response
mockGetPoolStats.mockImplementationOnce(() => {
throw new Error('Browser pool error')
})
const req = createMockRequest()
const res = createMockResponse()
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
// The handler doesn't try/catch getPoolStats, so this would throw
// But in real usage, it might be wrapped by error middleware
await expect(async () => {
await handler(req, res, vi.fn())
}).rejects.toThrow('Browser pool error')
})
it('should not require authentication', async () => {
// Health endpoint should be accessible without auth
const mockPoolStats = { size: 1, available: 1 }
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
const req = createMockRequest({
headers: {} // No auth headers
})
const res = createMockResponse()
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.json).toHaveBeenCalledWith({
status: "ok",
version: "0.1.0",
uptime: expect.any(Number),
browser: mockPoolStats
})
})
it('should return health data in expected format for monitoring', async () => {
const mockPoolStats = {
size: 3,
available: 2,
pending: 1,
borrowed: 1,
min: 1,
max: 5
}
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
const req = createMockRequest()
const res = createMockResponse()
const handler = healthRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
const response = res.json.mock.calls[0][0]
// Verify structure for monitoring systems
expect(response.status).toBe('ok')
expect(typeof response.version).toBe('string')
expect(typeof response.uptime).toBe('number')
expect(typeof response.browser).toBe('object')
// Browser stats should contain pool metrics
expect(response.browser).toEqual(mockPoolStats)
})
})
})

View file

@ -0,0 +1,321 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Request, Response } from 'express'
import { playgroundRouter } from '../playground.js'
// Mock dependencies
vi.mock('../../services/screenshot.js', () => ({
takeScreenshot: vi.fn()
}))
vi.mock('../../services/watermark.js', () => ({
addWatermark: vi.fn()
}))
vi.mock('../../services/logger.js', () => ({
default: {
error: vi.fn()
}
}))
vi.mock('express-rate-limit', () => ({
default: vi.fn(() => (req: any, res: any, next: any) => next())
}))
const { takeScreenshot } = await import('../../services/screenshot.js')
const { addWatermark } = await import('../../services/watermark.js')
const mockTakeScreenshot = vi.mocked(takeScreenshot)
const mockAddWatermark = vi.mocked(addWatermark)
function createMockRequest(body: any = {}, overrides: any = {}): Partial<Request> {
return {
body,
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' } as any,
method: 'POST',
...overrides
}
}
function createMockResponse(): Partial<Response> {
const res: any = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
setHeader: vi.fn().mockReturnThis()
}
return res
}
describe('Playground Route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST /v1/playground', () => {
it('should return 400 when URL is missing', async () => {
const req = createMockRequest({})
const res = createMockResponse()
// Get the handler from the router
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Missing required parameter: url" })
})
it('should return 400 when URL is not a string', async () => {
const req = createMockRequest({ url: 123 })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Missing required parameter: url" })
})
it('should return 400 when URL is empty string', async () => {
const req = createMockRequest({ url: "" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Missing required parameter: url" })
})
it('should successfully take screenshot with valid URL and return watermarked result', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
const mockWatermarkedBuffer = Buffer.from('fake-watermarked-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
mockAddWatermark.mockResolvedValueOnce(mockWatermarkedBuffer)
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith({
url: "https://example.com",
format: "png",
width: 1280,
height: 800,
fullPage: false,
quality: undefined,
deviceScale: 1,
waitUntil: "domcontentloaded",
waitForSelector: undefined
})
expect(mockAddWatermark).toHaveBeenCalledWith(mockBuffer, 1280, 800)
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/png")
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", mockWatermarkedBuffer.length)
expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store")
expect(res.setHeader).toHaveBeenCalledWith("X-Playground", "true")
expect(res.send).toHaveBeenCalledWith(mockWatermarkedBuffer)
})
it('should enforce width limits (min 320, max 1920)', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
width: 100 // Below minimum
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
width: 320 // Should be clamped to minimum
}))
})
it('should enforce height limits (min 200, max 1080)', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
height: 2000 // Above maximum
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
height: 1080 // Should be clamped to maximum
}))
})
it('should handle format parameter correctly', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/jpeg' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
format: "jpeg",
quality: 95
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
format: "jpeg",
quality: 95
}))
})
it('should sanitize waitForSelector parameter', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
waitForSelector: "<script>alert('xss')</script>" // Malicious selector
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
waitForSelector: undefined // Should be sanitized out
}))
})
it('should allow valid CSS selector for waitForSelector', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
waitForSelector: "#main-content .article" // Valid CSS selector
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
waitForSelector: "#main-content .article"
}))
})
it('should return 503 when service queue is full', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('QUEUE_FULL'))
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(503)
expect(res.json).toHaveBeenCalledWith({ error: "Service busy. Try again shortly." })
})
it('should return 504 when screenshot times out', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('SCREENSHOT_TIMEOUT'))
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(504)
expect(res.json).toHaveBeenCalledWith({ error: "Screenshot timed out." })
})
it('should return 400 when URL is blocked (SSRF protection)', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('URL blocked: private IP detected'))
const req = createMockRequest({ url: "http://192.168.1.1" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "URL blocked: private IP detected" })
})
it('should return 400 when URL cannot be resolved', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('Could not resolve hostname'))
const req = createMockRequest({ url: "https://nonexistent.invalid" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Could not resolve hostname" })
})
it('should return 500 for generic screenshot errors', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('Unexpected browser error'))
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(500)
expect(res.json).toHaveBeenCalledWith({ error: "Screenshot failed" })
})
it('should enforce device scale limits (1-3)', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
deviceScale: 5 // Above maximum
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
deviceScale: 3 // Should be clamped to maximum
}))
})
it('should handle fullPage parameter', async () => {
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
const req = createMockRequest({
url: "https://example.com",
fullPage: true
})
const res = createMockResponse()
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
fullPage: true
}))
})
})
})

View file

@ -0,0 +1,452 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Request, Response } from 'express'
import { screenshotRouter } from '../screenshot.js'
// Mock dependencies
vi.mock('../../services/screenshot.js', () => ({
takeScreenshot: vi.fn()
}))
vi.mock('../../services/cache.js', () => ({
screenshotCache: {
get: vi.fn(),
put: vi.fn(),
shouldBypass: vi.fn()
}
}))
vi.mock('../../services/logger.js', () => ({
default: {
error: vi.fn()
}
}))
vi.mock('../../middleware/auth.js', () => ({
authMiddleware: vi.fn((req, res, next) => {
// Mock successful authentication by default
req.apiKeyInfo = { key: 'test_key', tier: 'pro', email: 'test@test.com' }
next()
})
}))
vi.mock('../../middleware/usage.js', () => ({
usageMiddleware: vi.fn((req, res, next) => next())
}))
const { takeScreenshot } = await import('../../services/screenshot.js')
const { screenshotCache } = await import('../../services/cache.js')
const mockTakeScreenshot = vi.mocked(takeScreenshot)
const mockCache = vi.mocked(screenshotCache)
function createMockRequest(params: any = {}, overrides: any = {}): Partial<Request> {
const method = overrides.method || 'POST'
return {
method,
body: method === 'POST' ? params : {},
query: method === 'GET' ? params : {},
headers: { authorization: 'Bearer test_key' },
apiKeyInfo: { key: 'test_key', tier: 'pro', email: 'test@test.com' },
...overrides
}
}
function createMockResponse(): Partial<Response> {
const res: any = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
setHeader: vi.fn().mockReturnThis()
}
return res
}
describe('Screenshot Route', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default cache behavior - no cache hit, no bypass
mockCache.shouldBypass.mockReturnValue(false)
mockCache.get.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST /v1/screenshot', () => {
it('should return 400 when URL is missing', async () => {
const req = createMockRequest({})
const res = createMockResponse()
// Get the POST handler from the router
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post && layer.route.path === '/'
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Missing required parameter: url" })
})
it('should return 400 when URL is not a string', async () => {
const req = createMockRequest({ url: 123 })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Missing required parameter: url" })
})
it('should successfully take screenshot with valid parameters', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
width: 1920,
height: 1080,
format: "png"
})
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith({
url: "https://example.com",
format: "png",
width: 1920,
height: 1080,
fullPage: false,
quality: undefined,
waitForSelector: undefined,
deviceScale: undefined,
delay: undefined,
waitUntil: undefined,
cache: undefined
})
expect(mockCache.put).toHaveBeenCalledWith(expect.any(Object), mockBuffer, 'image/png')
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/png")
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", mockBuffer.length)
expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store")
expect(res.setHeader).toHaveBeenCalledWith("X-Cache", "MISS")
expect(res.send).toHaveBeenCalledWith(mockBuffer)
})
it('should return cached result when available', async () => {
const cachedBuffer = Buffer.from('cached-screenshot-data')
mockCache.get.mockReturnValueOnce({
buffer: cachedBuffer,
contentType: 'image/jpeg'
})
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).not.toHaveBeenCalled()
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/jpeg")
expect(res.setHeader).toHaveBeenCalledWith("X-Cache", "HIT")
expect(res.send).toHaveBeenCalledWith(cachedBuffer)
})
it('should bypass cache when cache=false', async () => {
mockCache.shouldBypass.mockReturnValue(true)
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
cache: false
})
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockCache.get).not.toHaveBeenCalled()
expect(mockCache.put).not.toHaveBeenCalled()
expect(mockTakeScreenshot).toHaveBeenCalled()
})
it('should return 503 when service queue is full', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('QUEUE_FULL'))
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(503)
expect(res.json).toHaveBeenCalledWith({ error: "Service busy. Try again shortly." })
})
it('should return 504 when screenshot times out', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('SCREENSHOT_TIMEOUT'))
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(504)
expect(res.json).toHaveBeenCalledWith({
error: "Screenshot timed out. The page may be too slow to load."
})
})
it('should return 400 for blocked/invalid URLs', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('URL blocked: private IP detected'))
const req = createMockRequest({ url: "http://192.168.1.1" })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "URL blocked: private IP detected" })
})
it('should return 500 for generic errors with details', async () => {
mockTakeScreenshot.mockRejectedValueOnce(new Error('Browser crashed'))
const req = createMockRequest({ url: "https://example.com" })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(500)
expect(res.json).toHaveBeenCalledWith({
error: "Screenshot failed",
details: "Browser crashed"
})
})
})
describe('GET /v1/screenshot', () => {
it('should handle GET request with query parameters', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
width: "800",
height: "600",
format: "png"
}, { method: 'GET' })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith({
url: "https://example.com",
format: "png",
width: 800,
height: 600,
fullPage: false,
quality: undefined,
waitForSelector: undefined,
deviceScale: undefined,
delay: undefined,
waitUntil: undefined,
cache: undefined
})
})
it('should handle fullPage parameter from query string', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
fullPage: "true"
}, { method: 'GET' })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
fullPage: true
}))
})
it('should handle deviceScale parameter from query string', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
deviceScale: "2.5"
}, { method: 'GET' })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
deviceScale: 2.5
}))
})
it('should validate waitUntil parameter', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
waitUntil: "networkidle2"
}, { method: 'GET' })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
waitUntil: "networkidle2"
}))
})
it('should ignore invalid waitUntil values', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
waitUntil: "invalid"
}, { method: 'GET' })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
waitUntil: undefined
}))
})
it('should return 400 when URL is missing in GET request', async () => {
const req = createMockRequest({}, { method: 'GET' })
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.get
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "Missing required parameter: url" })
})
})
describe('Parameter normalization', () => {
it('should parse integer parameters correctly', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/jpeg'
})
const req = createMockRequest({
url: "https://example.com",
width: "1200",
height: "900",
quality: "85",
delay: "500"
})
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
width: 1200,
height: 900,
quality: 85,
delay: 500
}))
})
it('should handle boolean parameters from strings', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({
buffer: mockBuffer,
contentType: 'image/png'
})
const req = createMockRequest({
url: "https://example.com",
fullPage: "false"
})
const res = createMockResponse()
const handler = screenshotRouter.stack.find(layer =>
layer.route?.methods.post
)?.route.stack[0].handle
await handler(req, res, vi.fn())
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
fullPage: false
}))
})
})
})

View file

@ -0,0 +1,262 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { addWatermark } from '../watermark.js'
// Mock browser service
vi.mock('../browser.js', () => ({
acquirePage: vi.fn(),
releasePage: vi.fn()
}))
const { acquirePage, releasePage } = await import('../browser.js')
const mockAcquirePage = vi.mocked(acquirePage)
const mockReleasePage = vi.mocked(releasePage)
function createMockPage() {
return {
setViewport: vi.fn(),
setContent: vi.fn(),
screenshot: vi.fn()
}
}
function createMockInstance() {
return { id: 'test-instance' }
}
describe('Watermark Service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('addWatermark', () => {
it('should add watermark to image buffer', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('input-image-data')
const outputBuffer = Buffer.from('watermarked-image-data')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(outputBuffer as any)
const result = await addWatermark(inputBuffer, 1280, 800)
expect(mockAcquirePage).toHaveBeenCalledOnce()
expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1280, height: 800 })
expect(mockPage.setContent).toHaveBeenCalledWith(
expect.stringContaining('data:image/png;base64,'),
{ waitUntil: "load" }
)
expect(mockPage.screenshot).toHaveBeenCalledWith({
type: "png",
encoding: "binary"
})
expect(mockReleasePage).toHaveBeenCalledWith(mockPage, mockInstance)
expect(result).toBeInstanceOf(Buffer)
expect(result).toEqual(outputBuffer)
})
it('should set viewport to specified dimensions', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test-image')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 1920, 1080)
expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1920, height: 1080 })
})
it('should include base64 encoded image in HTML content', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test-image-data')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 800, 600)
const expectedBase64 = inputBuffer.toString('base64')
const setContentCall = mockPage.setContent.mock.calls[0][0]
expect(setContentCall).toContain(`data:image/png;base64,${expectedBase64}`)
})
it('should include watermark text in HTML content', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 1000, 700)
const setContentCall = mockPage.setContent.mock.calls[0][0]
expect(setContentCall).toContain('snapapi.eu — upgrade for clean screenshots')
})
it('should scale font size based on image width', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 2000, 1000)
const setContentCall = mockPage.setContent.mock.calls[0][0]
const expectedFontSize = Math.max(2000 / 20, 24) // 100px for 2000px width
expect(setContentCall).toContain(`font-size: ${expectedFontSize}px`)
})
it('should enforce minimum font size', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
// Small width that would result in font size < 24px
await addWatermark(inputBuffer, 400, 300)
const setContentCall = mockPage.setContent.mock.calls[0][0]
// Should use minimum font size of 24px
expect(setContentCall).toContain('font-size: 24px')
})
it('should include CSS styling for watermark', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 1200, 800)
const setContentCall = mockPage.setContent.mock.calls[0][0]
// Check for key CSS properties
expect(setContentCall).toContain('transform: rotate(-30deg)')
expect(setContentCall).toContain('color: rgba(255, 255, 255, 0.35)')
expect(setContentCall).toContain('text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.5)')
expect(setContentCall).toContain('font-weight: 900')
expect(setContentCall).toContain('pointer-events: none')
})
it('should wait for page load before taking screenshot', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 1000, 600)
expect(mockPage.setContent).toHaveBeenCalledWith(
expect.any(String),
{ waitUntil: "load" }
)
})
it('should release page even if screenshot fails', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockRejectedValueOnce(new Error('Screenshot failed'))
await expect(addWatermark(inputBuffer, 800, 600)).rejects.toThrow('Screenshot failed')
expect(mockReleasePage).toHaveBeenCalledWith(mockPage, mockInstance)
})
it('should release page even if setContent fails', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.setContent.mockRejectedValueOnce(new Error('SetContent failed'))
await expect(addWatermark(inputBuffer, 800, 600)).rejects.toThrow('SetContent failed')
expect(mockReleasePage).toHaveBeenCalledWith(mockPage, mockInstance)
})
it('should handle page acquisition failure', async () => {
mockAcquirePage.mockRejectedValueOnce(new Error('No pages available'))
await expect(addWatermark(Buffer.from('test'), 800, 600))
.rejects.toThrow('No pages available')
expect(mockReleasePage).not.toHaveBeenCalled()
})
it('should generate valid HTML structure', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 1000, 700)
const html = mockPage.setContent.mock.calls[0][0]
// Check HTML structure
expect(html).toContain('<!DOCTYPE html>')
expect(html).toContain('<html>')
expect(html).toContain('<head>')
expect(html).toContain('<style>')
expect(html).toContain('<body>')
expect(html).toContain('<img')
expect(html).toContain('<div class="watermark">')
expect(html).toContain('</html>')
})
it('should set correct body dimensions', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const inputBuffer = Buffer.from('test')
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
await addWatermark(inputBuffer, 1500, 900)
const html = mockPage.setContent.mock.calls[0][0]
expect(html).toContain('width: 1500px; height: 900px')
})
it('should handle various image buffer sizes', async () => {
const mockPage = createMockPage()
const mockInstance = createMockInstance()
const largeBuffer = Buffer.alloc(1024 * 1024, 'test') // 1MB buffer
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
const result = await addWatermark(largeBuffer, 2000, 1200)
expect(mockPage.setContent).toHaveBeenCalled()
expect(result).toBeInstanceOf(Buffer)
})
})
})