From a20828b09ca0d9ab12be1c510af615381585e75b Mon Sep 17 00:00:00 2001 From: SnapAPI Test Agent Date: Wed, 25 Feb 2026 08:05:53 +0000 Subject: [PATCH] Add comprehensive route-level unit tests - 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. --- src/routes/__tests__/health.test.ts | 220 +++++++++++ src/routes/__tests__/playground.test.ts | 321 ++++++++++++++++ src/routes/__tests__/screenshot.test.ts | 452 +++++++++++++++++++++++ src/services/__tests__/watermark.test.ts | 262 +++++++++++++ 4 files changed, 1255 insertions(+) create mode 100644 src/routes/__tests__/health.test.ts create mode 100644 src/routes/__tests__/playground.test.ts create mode 100644 src/routes/__tests__/screenshot.test.ts create mode 100644 src/services/__tests__/watermark.test.ts diff --git a/src/routes/__tests__/health.test.ts b/src/routes/__tests__/health.test.ts new file mode 100644 index 0000000..5f1a1b5 --- /dev/null +++ b/src/routes/__tests__/health.test.ts @@ -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 { + return { + method: 'GET', + ...overrides + } +} + +function createMockResponse(): Partial { + 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) + }) + }) +}) \ No newline at end of file diff --git a/src/routes/__tests__/playground.test.ts b/src/routes/__tests__/playground.test.ts new file mode 100644 index 0000000..1dfd56d --- /dev/null +++ b/src/routes/__tests__/playground.test.ts @@ -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 { + return { + body, + ip: '127.0.0.1', + socket: { remoteAddress: '127.0.0.1' } as any, + method: 'POST', + ...overrides + } +} + +function createMockResponse(): Partial { + 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: "" // 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 + })) + }) + }) +}) \ No newline at end of file diff --git a/src/routes/__tests__/screenshot.test.ts b/src/routes/__tests__/screenshot.test.ts new file mode 100644 index 0000000..6c4c78b --- /dev/null +++ b/src/routes/__tests__/screenshot.test.ts @@ -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 { + 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 { + 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 + })) + }) + }) +}) \ No newline at end of file diff --git a/src/services/__tests__/watermark.test.ts b/src/services/__tests__/watermark.test.ts new file mode 100644 index 0000000..4cd549c --- /dev/null +++ b/src/services/__tests__/watermark.test.ts @@ -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('') + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('