/**
* Unit tests for ShopifyClient (src/shopify/client.ts).
* Uses MSW to intercept HTTP requests and validate headers, URLs, retry logic.
*/
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { ShopifyClient } from '../../src/shopify/client.js';
// ─── Constants ───
const DOMAIN = 'test.myshopify.com';
const STOREFRONT_URL = `https://${DOMAIN}/api/2025-01/graphql.json`;
const ADMIN_URL = `https://${DOMAIN}/admin/api/2025-01/graphql.json`;
const validConfig = {
storeDomain: DOMAIN,
accessToken: 'shpat_test_admin_token',
storefrontToken: 'test_storefront_token',
};
// ─── MSW Server ───
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// ─── Constructor Tests ───
describe('ShopifyClient constructor', () => {
it('throws when storeDomain is empty', () => {
expect(
() => new ShopifyClient({ ...validConfig, storeDomain: '' }),
).toThrow('storeDomain is required');
});
it('throws when accessToken is empty', () => {
expect(
() => new ShopifyClient({ ...validConfig, accessToken: '' }),
).toThrow('accessToken is required');
});
it('throws when storefrontToken is empty', () => {
expect(
() => new ShopifyClient({ ...validConfig, storefrontToken: '' }),
).toThrow('storefrontToken is required');
});
it('creates successfully with all required fields', () => {
const client = new ShopifyClient(validConfig);
expect(client).toBeInstanceOf(ShopifyClient);
});
});
// ─── storefrontQuery Tests ───
describe('ShopifyClient.storefrontQuery', () => {
it('sends request to the correct Storefront URL with correct headers', async () => {
let capturedUrl = '';
let capturedHeaders: Record<string, string> = {};
server.use(
http.post(STOREFRONT_URL, ({ request }) => {
capturedUrl = request.url;
capturedHeaders = {
'content-type': request.headers.get('content-type') ?? '',
'x-shopify-storefront-access-token':
request.headers.get('x-shopify-storefront-access-token') ?? '',
};
return HttpResponse.json({ data: { shop: { name: 'Test' } } });
}),
);
const client = new ShopifyClient(validConfig);
await client.storefrontQuery('{ shop { name } }');
expect(capturedUrl).toBe(STOREFRONT_URL);
expect(capturedHeaders['content-type']).toBe('application/json');
expect(capturedHeaders['x-shopify-storefront-access-token']).toBe(
'test_storefront_token',
);
});
it('sends query and variables in the request body', async () => {
let capturedBody: { query?: string; variables?: Record<string, unknown> } = {};
server.use(
http.post(STOREFRONT_URL, async ({ request }) => {
capturedBody = (await request.json()) as typeof capturedBody;
return HttpResponse.json({ data: null });
}),
);
const client = new ShopifyClient(validConfig);
const query = '{ products(first: $n) { edges { node { id } } } }';
const variables = { n: 5 };
await client.storefrontQuery(query, variables);
expect(capturedBody.query).toBe(query);
expect(capturedBody.variables).toEqual({ n: 5 });
});
it('returns parsed JSON response', async () => {
const mockData = { products: { edges: [{ node: { id: 'p1' } }] } };
server.use(
http.post(STOREFRONT_URL, () => {
return HttpResponse.json({ data: mockData });
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.storefrontQuery('{products{edges{node{id}}}}');
expect(result.data).toEqual(mockData);
});
});
// ─── adminQuery Tests ───
describe('ShopifyClient.adminQuery', () => {
it('sends request to the correct Admin URL with correct headers', async () => {
let capturedUrl = '';
let capturedHeaders: Record<string, string> = {};
server.use(
http.post(ADMIN_URL, ({ request }) => {
capturedUrl = request.url;
capturedHeaders = {
'content-type': request.headers.get('content-type') ?? '',
'x-shopify-access-token':
request.headers.get('x-shopify-access-token') ?? '',
};
return HttpResponse.json({ data: { order: { id: 'o1' } } });
}),
);
const client = new ShopifyClient(validConfig);
await client.adminQuery('{ order(id: "o1") { id } }');
expect(capturedUrl).toBe(ADMIN_URL);
expect(capturedHeaders['content-type']).toBe('application/json');
expect(capturedHeaders['x-shopify-access-token']).toBe('shpat_test_admin_token');
});
it('returns parsed JSON response', async () => {
const mockData = { order: { id: 'gid://shopify/Order/1', name: '#1001' } };
server.use(
http.post(ADMIN_URL, () => {
return HttpResponse.json({ data: mockData });
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.adminQuery('{ order(id: "1") { id name } }');
expect(result.data).toEqual(mockData);
});
});
// ─── Retry Logic Tests ───
describe('ShopifyClient retry logic', () => {
it('retries on HTTP 429 and uses Retry-After header', async () => {
let requestCount = 0;
server.use(
http.post(STOREFRONT_URL, () => {
requestCount++;
if (requestCount <= 2) {
return new HttpResponse(null, {
status: 429,
headers: { 'Retry-After': '0' },
});
}
return HttpResponse.json({ data: { shop: { name: 'OK' } } });
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.storefrontQuery('{ shop { name } }');
expect(requestCount).toBe(3);
expect(result.data).toEqual({ shop: { name: 'OK' } });
});
it('retries on THROTTLED GraphQL error code', async () => {
let requestCount = 0;
server.use(
http.post(ADMIN_URL, () => {
requestCount++;
if (requestCount === 1) {
return HttpResponse.json({
data: null,
errors: [
{
message: 'Throttled',
extensions: { code: 'THROTTLED' },
},
],
});
}
return HttpResponse.json({ data: { order: { id: 'o1' } } });
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.adminQuery('{ order { id } }');
expect(requestCount).toBe(2);
expect(result.data).toEqual({ order: { id: 'o1' } });
});
it('throws after MAX_RETRIES (3) when all attempts return 429', async () => {
let requestCount = 0;
server.use(
http.post(STOREFRONT_URL, () => {
requestCount++;
return new HttpResponse(null, {
status: 429,
headers: { 'Retry-After': '0' },
});
}),
);
const client = new ShopifyClient(validConfig);
await expect(
client.storefrontQuery('{ shop { name } }'),
).rejects.toThrow();
// All 3 retry attempts should have been made
expect(requestCount).toBe(3);
});
it('throws on non-200 non-429 response (e.g. 500)', async () => {
server.use(
http.post(ADMIN_URL, () => {
return new HttpResponse('Internal Server Error', {
status: 500,
statusText: 'Internal Server Error',
});
}),
);
const client = new ShopifyClient(validConfig);
await expect(
client.adminQuery('{ order { id } }'),
).rejects.toThrow('Shopify GraphQL error: 500');
});
it('backs off when approaching rate limit (X-Shopify-Shop-Api-Call-Limit >= 90%)', async () => {
let requestCount = 0;
server.use(
http.post(ADMIN_URL, () => {
requestCount++;
// Return a response with rate limit header showing 90% usage
return HttpResponse.json(
{ data: { order: { id: 'o1' } } },
{
headers: {
'X-Shopify-Shop-Api-Call-Limit': '36/40',
},
},
);
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.adminQuery('{ order { id } }');
// Should still succeed despite hitting the rate limit threshold
expect(requestCount).toBe(1);
expect(result.data).toEqual({ order: { id: 'o1' } });
});
it('does not back off when rate limit usage is below 90%', async () => {
server.use(
http.post(ADMIN_URL, () => {
return HttpResponse.json(
{ data: { shop: { name: 'Fast' } } },
{
headers: {
'X-Shopify-Shop-Api-Call-Limit': '10/40',
},
},
);
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.adminQuery('{ shop { name } }');
expect(result.data).toEqual({ shop: { name: 'Fast' } });
});
it('retries on network errors and eventually throws', async () => {
let requestCount = 0;
server.use(
http.post(STOREFRONT_URL, () => {
requestCount++;
return HttpResponse.error();
}),
);
const client = new ShopifyClient(validConfig);
await expect(
client.storefrontQuery('{ shop { name } }'),
).rejects.toThrow();
expect(requestCount).toBe(3);
});
it('recovers from network error on a subsequent retry', async () => {
let requestCount = 0;
server.use(
http.post(STOREFRONT_URL, () => {
requestCount++;
if (requestCount === 1) {
return HttpResponse.error();
}
return HttpResponse.json({ data: { shop: { name: 'Recovered' } } });
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.storefrontQuery('{ shop { name } }');
expect(requestCount).toBe(2);
expect(result.data).toEqual({ shop: { name: 'Recovered' } });
});
it('returns GraphQL response with non-THROTTLED errors without retry', async () => {
let requestCount = 0;
server.use(
http.post(ADMIN_URL, () => {
requestCount++;
return HttpResponse.json({
data: null,
errors: [
{
message: 'Field not found',
extensions: { code: 'FIELD_NOT_FOUND' },
},
],
});
}),
);
const client = new ShopifyClient(validConfig);
const result = await client.adminQuery('{ nonExistentField }');
// Non-THROTTLED errors should be returned as-is without retry
expect(requestCount).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors![0]!.extensions!['code']).toBe('FIELD_NOT_FOUND');
});
});