import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
import {FigmaClient} from '../src/figma-client.js';
const mockFetch = vi.fn();
describe('FigmaClient', () => {
const client = new FigmaClient('test-token');
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function mockResponse(status: number, body: unknown, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: {get: (key: string) => headers[key] ?? null},
json: async () => body,
text: async () => JSON.stringify(body),
};
}
describe('getFileNodes', () => {
it('calls correct endpoint with node IDs', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(200, {nodes: {}}));
await client.getFileNodes('abc123', ['1:2', '3:4'], 2);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.figma.com/v1/files/abc123/nodes?ids=1%3A2%2C3%3A4&depth=2',
{headers: {'X-Figma-Token': 'test-token'}},
);
});
it('omits depth when not specified', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(200, {nodes: {}}));
await client.getFileNodes('abc123', ['1:2']);
const url = mockFetch.mock.calls[0][0] as string;
expect(url).not.toContain('depth=');
});
});
describe('getFile', () => {
it('calls correct endpoint without optional params', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(200, {document: {}}));
await client.getFile('abc123');
expect(mockFetch).toHaveBeenCalledWith('https://api.figma.com/v1/files/abc123', {
headers: {'X-Figma-Token': 'test-token'},
});
});
it('includes ids and depth when specified', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(200, {document: {}}));
await client.getFile('abc123', ['1:2'], 3);
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('ids=1%3A2');
expect(url).toContain('depth=3');
});
});
describe('getImage', () => {
it('calls correct endpoint with format and scale', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(200, {err: null, images: {}}));
await client.getImage('abc123', ['1:2'], {format: 'svg', scale: 3});
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('/v1/images/abc123');
expect(url).toContain('ids=1%3A2');
expect(url).toContain('format=svg');
expect(url).toContain('scale=3');
});
});
describe('error handling', () => {
it('throws on 401', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(401, {}));
await expect(client.getFile('abc123')).rejects.toThrow('authentication failed');
});
it('throws on 403', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(403, {}));
await expect(client.getFile('abc123')).rejects.toThrow('access denied');
});
it('throws on 404', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(404, {}));
await expect(client.getFile('abc123')).rejects.toThrow('not found');
});
it('throws on other HTTP errors', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(500, 'Internal Server Error'));
await expect(client.getFile('abc123')).rejects.toThrow('Figma API error 500');
});
});
describe('rate limiting', () => {
it('retries on 429 with Retry-After header', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(429, {}, {'Retry-After': '0'}))
.mockResolvedValueOnce(mockResponse(200, {document: {}}));
const result = await client.getFile('abc123');
expect(result).toEqual({document: {}});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('throws after max retries on 429', async () => {
mockFetch
.mockResolvedValue(mockResponse(429, {}, {'Retry-After': '0'}));
await expect(client.getFile('abc123')).rejects.toThrow('rate limit exceeded');
}, 15000);
});
});