/**
* Unit tests for StorefrontAPI.
* Mocks ShopifyClient directly via vi.fn() — no MSW needed.
*/
import { StorefrontAPI } from '../../src/shopify/storefront.js';
import type { ShopifyClient } from '../../src/shopify/client.js';
import type { ShopifyProduct, ShopifyCart } from '../../src/shopify/types.js';
// ─── Mock Client Factory ───
function createMockClient() {
return {
storefrontQuery: vi.fn(),
adminQuery: vi.fn(),
} as unknown as ShopifyClient;
}
// ─── Mock Data ───
const mockProduct: ShopifyProduct = {
id: 'gid://shopify/Product/1',
title: 'Test Product',
description: 'desc',
vendor: 'Vendor',
productType: 'Type',
tags: ['tag1'],
availableForSale: true,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
variants: {
edges: [
{
node: {
id: 'v1',
title: 'Default',
sku: 'SKU1',
price: { amount: '29.99', currencyCode: 'USD' },
availableForSale: true,
quantityAvailable: 10,
barcode: null,
},
},
],
},
images: {
edges: [
{
node: {
url: 'https://img.test/1.jpg',
altText: null,
width: 800,
height: 600,
},
},
],
},
};
const mockCart: ShopifyCart = {
id: 'gid://shopify/Cart/1',
checkoutUrl: 'https://test-store.myshopify.com/cart/c/1',
totalQuantity: 2,
lines: {
edges: [
{
node: {
id: 'line1',
quantity: 2,
merchandise: {
id: 'v1',
title: 'Default',
sku: 'SKU1',
product: { id: 'p1', title: 'Product' },
image: null,
price: { amount: '29.99', currencyCode: 'USD' },
},
cost: {
totalAmount: { amount: '59.98', currencyCode: 'USD' },
amountPerQuantity: { amount: '29.99', currencyCode: 'USD' },
},
},
},
],
},
cost: {
subtotalAmount: { amount: '59.98', currencyCode: 'USD' },
totalTaxAmount: { amount: '4.80', currencyCode: 'USD' },
totalAmount: { amount: '64.78', currencyCode: 'USD' },
},
};
// ─── searchProducts ───
describe('StorefrontAPI.searchProducts', () => {
let client: ShopifyClient;
let api: StorefrontAPI;
beforeEach(() => {
client = createMockClient();
api = new StorefrontAPI(client);
});
it('returns mapped CatalogProduct array on success', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
products: {
edges: [{ node: mockProduct }],
},
},
});
const products = await api.searchProducts('test');
expect(products).toHaveLength(1);
const p = products[0]!;
expect(p.id).toBe('gid://shopify/Product/1');
expect(p.title).toBe('Test Product');
expect(p.description).toBe('desc');
expect(p.vendor).toBe('Vendor');
expect(p.product_type).toBe('Type');
expect(p.tags).toEqual(['tag1']);
expect(p.available).toBe(true);
expect(p.variants).toHaveLength(1);
expect(p.variants[0]!.id).toBe('v1');
expect(p.variants[0]!.price).toBe(2999); // $29.99 in cents
expect(p.variants[0]!.currency).toBe('USD');
expect(p.variants[0]!.sku).toBe('SKU1');
expect(p.variants[0]!.available).toBe(true);
expect(p.variants[0]!.inventory_quantity).toBe(10);
expect(p.variants[0]!.gtin).toBeUndefined(); // barcode was null
expect(p.images).toHaveLength(1);
expect(p.images[0]!.url).toBe('https://img.test/1.jpg');
expect(p.images[0]!.alt_text).toBeUndefined(); // altText was null
expect(p.images[0]!.width).toBe(800);
expect(p.images[0]!.height).toBe(600);
});
it('returns empty array when data is null', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
});
const products = await api.searchProducts('nothing');
expect(products).toEqual([]);
});
it('throws when response has GraphQL errors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
errors: [{ message: 'Invalid query' }],
});
await expect(api.searchProducts('bad')).rejects.toThrow(
'Storefront searchProducts failed: Invalid query',
);
});
it('passes query and first variables correctly', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { products: { edges: [] } },
});
await api.searchProducts('sneakers', 5);
expect(client.storefrontQuery).toHaveBeenCalledTimes(1);
const [_query, variables] = (client.storefrontQuery as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
expect(variables).toEqual({ query: 'sneakers', first: 5 });
});
it('uses default first=10 when not provided', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { products: { edges: [] } },
});
await api.searchProducts('shoes');
const [_query, variables] = (client.storefrontQuery as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
expect(variables).toEqual({ query: 'shoes', first: 10 });
});
});
// ─── getProduct ───
describe('StorefrontAPI.getProduct', () => {
let client: ShopifyClient;
let api: StorefrontAPI;
beforeEach(() => {
client = createMockClient();
api = new StorefrontAPI(client);
});
it('returns a CatalogProduct when found', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { product: mockProduct },
});
const product = await api.getProduct('gid://shopify/Product/1');
expect(product).not.toBeNull();
expect(product!.id).toBe('gid://shopify/Product/1');
expect(product!.title).toBe('Test Product');
expect(product!.variants).toHaveLength(1);
expect(product!.images).toHaveLength(1);
});
it('returns null when product is null', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { product: null },
});
const product = await api.getProduct('gid://shopify/Product/nonexistent');
expect(product).toBeNull();
});
it('returns null when data is null', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
});
const product = await api.getProduct('gid://shopify/Product/1');
expect(product).toBeNull();
});
it('throws when response has GraphQL errors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
errors: [{ message: 'Product error' }],
});
await expect(api.getProduct('gid://shopify/Product/1')).rejects.toThrow(
'Storefront getProduct failed: Product error',
);
});
});
// ─── createCart ───
describe('StorefrontAPI.createCart', () => {
let client: ShopifyClient;
let api: StorefrontAPI;
beforeEach(() => {
client = createMockClient();
api = new StorefrontAPI(client);
});
it('returns cart ID on success', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartCreate: {
cart: { id: 'gid://shopify/Cart/abc' },
userErrors: [],
},
},
});
const cartId = await api.createCart();
expect(cartId).toBe('gid://shopify/Cart/abc');
});
it('throws when cart is null with userErrors message', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartCreate: {
cart: null,
userErrors: [{ field: ['input'], message: 'Invalid cart creation' }],
},
},
});
await expect(api.createCart()).rejects.toThrow(
'Storefront createCart failed: Invalid cart creation',
);
});
it('throws when cart is null with unknown error if no userErrors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartCreate: {
cart: null,
userErrors: [],
},
},
});
await expect(api.createCart()).rejects.toThrow(
'Storefront createCart failed:',
);
});
it('throws when response has GraphQL errors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
errors: [{ message: 'GraphQL error 1' }, { message: 'GraphQL error 2' }],
});
await expect(api.createCart()).rejects.toThrow(
'Storefront createCart failed: GraphQL error 1, GraphQL error 2',
);
});
it('throws when data is null (no cartCreate result)', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
});
await expect(api.createCart()).rejects.toThrow(
'Storefront createCart failed: Unknown error',
);
});
it('calls storefrontQuery without variables', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartCreate: {
cart: { id: 'gid://shopify/Cart/x' },
userErrors: [],
},
},
});
await api.createCart();
expect(client.storefrontQuery).toHaveBeenCalledTimes(1);
const args = (client.storefrontQuery as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
// createCart mutation is called with just the query string, no variables
expect(args).toHaveLength(1);
});
});
// ─── addToCart ───
describe('StorefrontAPI.addToCart', () => {
let client: ShopifyClient;
let api: StorefrontAPI;
beforeEach(() => {
client = createMockClient();
api = new StorefrontAPI(client);
});
it('succeeds without throwing on valid response', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartLinesAdd: {
cart: { id: 'gid://shopify/Cart/1' },
userErrors: [],
},
},
});
await expect(
api.addToCart('gid://shopify/Cart/1', 'gid://shopify/ProductVariant/1', 3),
).resolves.toBeUndefined();
});
it('passes correct variables including merchandiseId and quantity', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartLinesAdd: {
cart: { id: 'gid://shopify/Cart/1' },
userErrors: [],
},
},
});
await api.addToCart('gid://shopify/Cart/1', 'variant-x', 5);
const [_query, variables] = (client.storefrontQuery as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
expect(variables).toEqual({
cartId: 'gid://shopify/Cart/1',
lines: [{ merchandiseId: 'variant-x', quantity: 5 }],
});
});
it('throws on GraphQL errors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
errors: [{ message: 'Cart not found' }],
});
await expect(
api.addToCart('gid://shopify/Cart/bad', 'v1', 1),
).rejects.toThrow('Storefront addToCart failed: Cart not found');
});
it('throws when cartLinesAdd has userErrors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartLinesAdd: {
cart: null,
userErrors: [
{ field: ['lines', '0', 'merchandiseId'], message: 'Variant not found' },
],
},
},
});
await expect(
api.addToCart('gid://shopify/Cart/1', 'bad-variant', 1),
).rejects.toThrow('Storefront addToCart user errors: Variant not found');
});
it('throws when cartLinesAdd has multiple userErrors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
cartLinesAdd: {
cart: null,
userErrors: [
{ field: ['lines'], message: 'Error A' },
{ field: ['lines'], message: 'Error B' },
],
},
},
});
await expect(
api.addToCart('gid://shopify/Cart/1', 'v1', 1),
).rejects.toThrow('Storefront addToCart user errors: Error A, Error B');
});
});
// ─── getCart ───
describe('StorefrontAPI.getCart', () => {
let client: ShopifyClient;
let api: StorefrontAPI;
beforeEach(() => {
client = createMockClient();
api = new StorefrontAPI(client);
});
it('returns converted lines and totals on success', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: mockCart },
});
const result = await api.getCart('gid://shopify/Cart/1');
expect(result.id).toBe('gid://shopify/Cart/1');
// Line items
expect(result.lines).toHaveLength(1);
const line = result.lines[0]!;
expect(line.id).toBe('line1');
expect(line.product_id).toBe('p1');
expect(line.variant_id).toBe('v1');
expect(line.title).toBe('Product - Default');
expect(line.quantity).toBe(2);
expect(line.unit_amount).toBe(2999); // $29.99 in cents
expect(line.total_amount).toBe(5998); // $59.98 in cents
expect(line.type).toBe('product');
expect(line.image_url).toBeUndefined(); // image was null
expect(line.sku).toBe('SKU1');
// Totals
expect(result.totals.currency).toBe('USD');
expect(result.totals.shipping).toBe(0);
expect(result.totals.discount).toBe(0);
expect(result.totals.fee).toBe(0);
});
it('converts money amounts to minor units (cents) correctly', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: mockCart },
});
const result = await api.getCart('gid://shopify/Cart/1');
// $59.98 -> 5998
expect(result.totals.subtotal).toBe(5998);
// $4.80 -> 480
expect(result.totals.tax).toBe(480);
// $64.78 -> 6478
expect(result.totals.total).toBe(6478);
});
it('handles null totalTaxAmount by setting tax to 0', async () => {
const cartNoTax: ShopifyCart = {
...mockCart,
cost: {
subtotalAmount: { amount: '59.98', currencyCode: 'USD' },
totalTaxAmount: null,
totalAmount: { amount: '59.98', currencyCode: 'USD' },
},
};
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: cartNoTax },
});
const result = await api.getCart('gid://shopify/Cart/1');
expect(result.totals.tax).toBe(0);
expect(result.totals.subtotal).toBe(5998);
expect(result.totals.total).toBe(5998);
});
it('throws when cart is not found (data.cart is null)', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: null },
});
await expect(api.getCart('gid://shopify/Cart/missing')).rejects.toThrow(
'Cart not found: gid://shopify/Cart/missing',
);
});
it('throws when data is null', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
});
await expect(api.getCart('gid://shopify/Cart/1')).rejects.toThrow(
'Cart not found: gid://shopify/Cart/1',
);
});
it('throws on GraphQL errors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
errors: [{ message: 'Internal server error' }],
});
await expect(api.getCart('gid://shopify/Cart/1')).rejects.toThrow(
'Storefront getCart failed: Internal server error',
);
});
it('handles cart with multiple line items', async () => {
const multiLineCart: ShopifyCart = {
...mockCart,
lines: {
edges: [
...mockCart.lines.edges,
{
node: {
id: 'line2',
quantity: 1,
merchandise: {
id: 'v2',
title: 'Large',
sku: 'SKU2',
product: { id: 'p2', title: 'Another Product' },
image: { url: 'https://img.test/2.jpg', altText: 'alt', width: 400, height: 400 },
price: { amount: '49.99', currencyCode: 'USD' },
},
cost: {
totalAmount: { amount: '49.99', currencyCode: 'USD' },
amountPerQuantity: { amount: '49.99', currencyCode: 'USD' },
},
},
},
],
},
};
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: multiLineCart },
});
const result = await api.getCart('gid://shopify/Cart/1');
expect(result.lines).toHaveLength(2);
expect(result.lines[0]!.id).toBe('line1');
expect(result.lines[1]!.id).toBe('line2');
expect(result.lines[1]!.title).toBe('Another Product - Large');
expect(result.lines[1]!.image_url).toBe('https://img.test/2.jpg');
expect(result.lines[1]!.sku).toBe('SKU2');
expect(result.lines[1]!.unit_amount).toBe(4999);
expect(result.lines[1]!.total_amount).toBe(4999);
});
it('passes cartId variable correctly', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: mockCart },
});
await api.getCart('gid://shopify/Cart/42');
const [_query, variables] = (client.storefrontQuery as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
expect(variables).toEqual({ cartId: 'gid://shopify/Cart/42' });
});
});
// ─── createCheckoutUrl ───
describe('StorefrontAPI.createCheckoutUrl', () => {
let client: ShopifyClient;
let api: StorefrontAPI;
beforeEach(() => {
client = createMockClient();
api = new StorefrontAPI(client);
});
it('returns checkout URL on success', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: mockCart },
});
const url = await api.createCheckoutUrl('gid://shopify/Cart/1');
expect(url).toBe('https://test-store.myshopify.com/cart/c/1');
});
it('throws when cart is not found (data.cart is null)', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: null },
});
await expect(
api.createCheckoutUrl('gid://shopify/Cart/missing'),
).rejects.toThrow('Cart not found: gid://shopify/Cart/missing');
});
it('throws when data is null', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
});
await expect(
api.createCheckoutUrl('gid://shopify/Cart/1'),
).rejects.toThrow('Cart not found: gid://shopify/Cart/1');
});
it('throws on GraphQL errors', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
errors: [{ message: 'Unauthorized' }],
});
await expect(
api.createCheckoutUrl('gid://shopify/Cart/1'),
).rejects.toThrow('Storefront createCheckoutUrl failed: Unauthorized');
});
it('passes cartId variable correctly', async () => {
(client.storefrontQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { cart: mockCart },
});
await api.createCheckoutUrl('gid://shopify/Cart/99');
const [_query, variables] = (client.storefrontQuery as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
expect(variables).toEqual({ cartId: 'gid://shopify/Cart/99' });
});
});