Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
366 changes: 203 additions & 163 deletions README.md

Large diffs are not rendered by default.

3,896 changes: 3,896 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

332 changes: 332 additions & 0 deletions src/__tests__/client.coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
/**
* Supplemental unit tests filling coverage gaps left by client.test.ts.
*
* Goal: every public method has a happy AND an error path, plus the request-body
* shape branches (layout/layouts/concurrency). Fully mocked — never hits the net.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { Pictify } from '../client';
import {
AuthenticationError,
TemplateNotFoundError,
QuotaExceededError,
RateLimitError,
RenderError,
ServerError,
PictifyError,
} from '../errors';
import {
createMockFetch,
mockImageResult,
mockRenderResult,
mockLayoutsRenderResult,
mockGifResponse,
mockBatchSubmitResult,
mockBatchResults,
mockTemplateResponse,
mockListTemplatesResult,
} from './helpers';

afterEach(() => {
vi.unstubAllGlobals();
});

function bodyOf(mockFetch: ReturnType<typeof vi.fn>, call = 0): Record<string, unknown> {
const [, options] = mockFetch.mock.calls[call];
return JSON.parse(options.body);
}

// ---------------------------------------------------------------------------
// render() / renderLayouts() body branches + multi-layout envelope
// ---------------------------------------------------------------------------

describe('render() — layout / layouts / quality body branches', () => {
it('sends `layout` when provided', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockRenderResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.render({ templateId: 't', layout: 'square' });
expect(bodyOf(mockFetch).layout).toBe('square');
});

it('sends `layouts` when provided', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockLayoutsRenderResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.render({ templateId: 't', layouts: ['default', 'square'] });
expect(bodyOf(mockFetch).layouts).toEqual(['default', 'square']);
});

it('omits layout/layouts when not provided', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockRenderResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.render({ templateId: 't' });
const body = bodyOf(mockFetch);
expect(body).not.toHaveProperty('layout');
expect(body).not.toHaveProperty('layouts');
});

it('forwards quality, width, height when provided', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockRenderResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.render({ templateId: 't', quality: 0.8, width: 800, height: 400 });
const body = bodyOf(mockFetch);
expect(body.quality).toBe(0.8);
expect(body.width).toBe(800);
expect(body.height).toBe(400);
});
});

describe('renderLayouts()', () => {
it('delegates to render() with the layouts array; surfaces errors[]', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockLayoutsRenderResult }]);
vi.stubGlobal('fetch', mockFetch);

const client = new Pictify({ apiKey: 'test-key' });
const result = await client.renderLayouts({
templateId: 't',
layouts: ['default', 'bogus-layout'],
});

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(bodyOf(mockFetch).layouts).toEqual(['default', 'bogus-layout']);
expect(result.url).toBe(mockLayoutsRenderResult.results[0].url);
expect(result.results).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].layout).toBe('bogus-layout');
});

it('throws TemplateNotFoundError on 404', async () => {
const mockFetch = createMockFetch([
{ status: 404, body: { message: 'Template not found' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await expect(
client.renderLayouts({ templateId: 'missing', layouts: ['default'] })
).rejects.toThrow(TemplateNotFoundError);
});
});

// ---------------------------------------------------------------------------
// Per-method error paths
// ---------------------------------------------------------------------------

describe('renderHtml() — error paths', () => {
it('throws AuthenticationError on 401 ({ message })', async () => {
const mockFetch = createMockFetch([{ status: 401, body: { message: 'Invalid Request' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'bad-key' });
const err = await client.renderHtml({ html: '<div>x</div>' }).catch((e) => e);
expect(err).toBeInstanceOf(AuthenticationError);
expect(err.message).toBe('Invalid Request');
});

it('throws RenderError on 422 and exposes field errors', async () => {
const mockFetch = createMockFetch([
{ status: 422, body: { message: 'Validation failed', errors: [{ field: 'html' }] } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const err = (await client.renderHtml({ html: '' }).catch((e) => e)) as RenderError;
expect(err).toBeInstanceOf(RenderError);
expect(err.errors).toEqual([{ field: 'html' }]);
});

it('prefers body.error over body.message for the error message', async () => {
const mockFetch = createMockFetch([
{ status: 422, body: { error: 'image boom', message: 'ignored' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const err = await client.renderHtml({ html: '<div>x</div>' }).catch((e) => e);
expect(err.message).toBe('image boom');
});
});

describe('renderUrl() — error path', () => {
it('throws ServerError on 500 (retries: 0)', async () => {
const mockFetch = createMockFetch([{ status: 500, body: { error: 'boom' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key', retries: 0 });
await expect(client.renderUrl({ url: 'https://x.com' })).rejects.toThrow(ServerError);
});
});

describe('render() — error path', () => {
it('throws ServerError on 500 (retries: 0)', async () => {
const mockFetch = createMockFetch([{ status: 500, body: { message: 'boom' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key', retries: 0 });
await expect(client.render({ templateId: 't' })).rejects.toThrow(ServerError);
});
});

describe('renderGif() — body branches + error path', () => {
it('forwards url source', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockGifResponse }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.renderGif({ url: 'https://x.com', quality: 'high' });
const body = bodyOf(mockFetch);
expect(body.url).toBe('https://x.com');
expect(body.quality).toBe('high');
});

it('throws RenderError on 422 (e.g. no animation frames)', async () => {
const mockFetch = createMockFetch([
{ status: 422, body: { error: 'No frames captured', code: 'NO_FRAMES_CAPTURED' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const err = await client.renderGif({ html: '<div>static</div>' }).catch((e) => e);
expect(err).toBeInstanceOf(RenderError);
expect(err.message).toBe('No frames captured');
});
});

describe('renderBatch() — body branches + error path', () => {
it('sends layout, concurrency, quality', async () => {
const mockFetch = createMockFetch([{ status: 202, body: mockBatchSubmitResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.renderBatch({
templateId: 't',
variableSets: [{ name: 'A' }],
layout: 'square',
concurrency: 3,
quality: 0.7,
});
const body = bodyOf(mockFetch);
expect(body.layout).toBe('square');
expect(body.concurrency).toBe(3);
expect(body.quality).toBe(0.7);
});

it('sends layouts when provided', async () => {
const mockFetch = createMockFetch([{ status: 202, body: mockBatchSubmitResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await client.renderBatch({
templateId: 't',
variableSets: [{ name: 'A' }],
layouts: ['default', 'square'],
});
expect(bodyOf(mockFetch).layouts).toEqual(['default', 'square']);
});

it('throws RateLimitError on 429 without a quota code', async () => {
const mockFetch = createMockFetch([{ status: 429, body: { message: 'slow down' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await expect(
client.renderBatch({ templateId: 't', variableSets: [{}] })
).rejects.toThrow(RateLimitError);
});

it('throws QuotaExceededError on 429 with code quota_exceeded', async () => {
const mockFetch = createMockFetch([
{ status: 429, body: { message: 'over limit', code: 'quota_exceeded' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const err = await client
.renderBatch({ templateId: 't', variableSets: [{}] })
.catch((e) => e);
expect(err).toBeInstanceOf(QuotaExceededError);
expect(err.statusCode).toBe(429);
});
});

describe('getBatchResults() — error path', () => {
it('throws TemplateNotFoundError on 404 (batch not found)', async () => {
const mockFetch = createMockFetch([
{ status: 404, body: { message: 'Batch job not found' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await expect(client.getBatchResults('nope')).rejects.toThrow(TemplateNotFoundError);
});
});

describe('getTemplate() — error path', () => {
it('throws TemplateNotFoundError on 404', async () => {
const mockFetch = createMockFetch([
{ status: 404, body: { message: 'Template not found' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await expect(client.getTemplate('missing')).rejects.toThrow(TemplateNotFoundError);
});

it('throws AuthenticationError on 401', async () => {
const mockFetch = createMockFetch([{ status: 401, body: { message: 'Invalid Request' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'bad-key' });
await expect(client.getTemplate('t')).rejects.toThrow(AuthenticationError);
});
});

describe('listTemplates() — error path', () => {
it('throws QuotaExceededError on 402', async () => {
const mockFetch = createMockFetch([{ status: 402, body: { message: 'over quota' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
await expect(client.listTemplates()).rejects.toThrow(QuotaExceededError);
});

it('maps an unexpected 4xx (418) to RenderError', async () => {
const mockFetch = createMockFetch([{ status: 418, body: { message: 'teapot' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const err = await client.listTemplates().catch((e) => e);
expect(err).toBeInstanceOf(RenderError);
expect(err.statusCode).toBe(418);
});

it('returns the result on success with default options', async () => {
const mockFetch = createMockFetch([{ status: 200, body: mockListTemplatesResult }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const result = await client.listTemplates();
expect(result.templates).toHaveLength(1);
});
});

describe('createTemplate() — error path', () => {
it('throws RenderError on 422 (invalid template HTML)', async () => {
const mockFetch = createMockFetch([
{ status: 422, body: { error: 'Template too large', code: 'TEMPLATE_TOO_LARGE' } },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key' });
const err = await client.createTemplate({ html: '<div>x</div>' }).catch((e) => e);
expect(err).toBeInstanceOf(RenderError);
expect(err.message).toBe('Template too large');
});
});

describe('error mapping — falls back to statusText when body has no message', () => {
it('uses statusText when body is empty', async () => {
const mockFetch = createMockFetch([
{ status: 500, body: {}, statusText: 'Internal Server Error' },
]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key', retries: 0 });
const err = await client.renderHtml({ html: '<div>x</div>' }).catch((e) => e);
expect(err).toBeInstanceOf(ServerError);
expect(err.message).toBe('Internal Server Error');
});

it('returns a generic PictifyError shape for a 3xx-ish unexpected code', async () => {
// 399 is < 400 -> not ok and not handled by 4xx/5xx branches -> UNKNOWN_ERROR
const mockFetch = createMockFetch([{ status: 399, body: { message: 'weird' } }]);
vi.stubGlobal('fetch', mockFetch);
const client = new Pictify({ apiKey: 'test-key', retries: 0 });
const err = await client.renderHtml({ html: '<div>x</div>' }).catch((e) => e);
expect(err).toBeInstanceOf(PictifyError);
expect(err.code).toBe('UNKNOWN_ERROR');
});
});
Loading