/**
* Comprehensive unit tests for AdminAPI (src/shopify/admin.ts).
* Mocks ShopifyClient directly — no network layer involved.
*/
import { AdminAPI } from '../../src/shopify/admin.js';
import type { ShopifyClient } from '../../src/shopify/client.js';
// ─── Mock Client Factory ───
function createMockClient() {
return {
storefrontQuery: vi.fn(),
adminQuery: vi.fn(),
} as unknown as ShopifyClient;
}
// ─── Shared Mock Data ───
const mockShopifyOrder = {
id: 'gid://shopify/Order/1',
name: '#1001',
displayFinancialStatus: 'PAID',
displayFulfillmentStatus: 'UNFULFILLED',
currencyCode: 'USD',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-02T00:00:00Z',
totalPriceSet: { shopMoney: { amount: '100.00', currencyCode: 'USD' } },
subtotalPriceSet: { shopMoney: { amount: '85.00', currencyCode: 'USD' } },
totalTaxSet: { shopMoney: { amount: '8.50', currencyCode: 'USD' } },
totalShippingPriceSet: { shopMoney: { amount: '6.50', currencyCode: 'USD' } },
totalDiscountsSet: { shopMoney: { amount: '0.00', currencyCode: 'USD' } },
lineItems: {
edges: [
{
node: {
id: 'li1',
title: 'Test',
quantity: 1,
sku: 'SKU1',
variant: {
id: 'v1',
product: { id: 'p1' },
image: {
url: 'https://img.test/1.jpg',
altText: null,
width: 800,
height: 600,
},
price: '85.00',
},
originalTotalSet: {
shopMoney: { amount: '85.00', currencyCode: 'USD' },
},
discountedTotalSet: {
shopMoney: { amount: '85.00', currencyCode: 'USD' },
},
},
},
],
},
fulfillments: [],
};
const percentageDiscountNode = {
id: 'gid://shopify/DiscountCodeNode/1',
codeDiscount: {
title: '20% Off',
summary: '20% off your order',
codes: { edges: [{ node: { code: 'SAVE20' } }] },
customerGets: { value: { percentage: 0.2 } },
minimumRequirement: {
greaterThanOrEqualToSubtotal: {
amount: '50.00',
currencyCode: 'USD',
},
},
},
};
const fixedDiscountNode = {
id: 'gid://shopify/DiscountCodeNode/2',
codeDiscount: {
title: '$10 Off',
summary: '$10 off orders over $50',
codes: { edges: [{ node: { code: 'SAVE10' } }] },
customerGets: {
value: { amount: { amount: '10.00', currencyCode: 'USD' } },
},
minimumRequirement: null,
},
};
// ─── Tests ───
describe('AdminAPI.getOrder', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('returns UCP Order with all field mappings when order is found', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: mockShopifyOrder },
});
const order = await admin.getOrder('gid://shopify/Order/1');
// Verify adminQuery was called with correct arguments
expect(client.adminQuery).toHaveBeenCalledOnce();
expect(client.adminQuery).toHaveBeenCalledWith(
expect.stringContaining('GetOrder'),
{ id: 'gid://shopify/Order/1' },
);
// Top-level fields
expect(order).not.toBeNull();
expect(order!.id).toBe('gid://shopify/Order/1');
expect(order!.checkout_id).toBe('#1001');
expect(order!.status).toBe('processing'); // UNFULFILLED => processing
expect(order!.created_at).toBe('2025-01-01T00:00:00Z');
expect(order!.updated_at).toBe('2025-01-02T00:00:00Z');
// Totals (all in minor units / cents)
expect(order!.totals).toEqual({
subtotal: 8500,
tax: 850,
shipping: 650,
discount: 0,
fee: 0,
total: 10000,
currency: 'USD',
});
// Line items
expect(order!.line_items).toHaveLength(1);
const li = order!.line_items[0]!;
expect(li.id).toBe('li1');
expect(li.product_id).toBe('p1');
expect(li.variant_id).toBe('v1');
expect(li.title).toBe('Test');
expect(li.quantity).toBe(1);
expect(li.unit_amount).toBe(8500);
expect(li.total_amount).toBe(8500);
expect(li.type).toBe('product');
expect(li.image_url).toBe('https://img.test/1.jpg');
expect(li.sku).toBe('SKU1');
// No fulfillments => fulfillment should be undefined
expect(order!.fulfillment).toBeUndefined();
});
it('returns null when order is null', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: null },
});
const order = await admin.getOrder('gid://shopify/Order/nonexistent');
expect(order).toBeNull();
});
it('throws when response has errors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
errors: [{ message: 'Access denied' }],
});
await expect(
admin.getOrder('gid://shopify/Order/1'),
).rejects.toThrow('Admin getOrder failed: Access denied');
});
});
describe('AdminAPI.getOrders', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('returns array of UCP Orders', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
orders: {
edges: [{ node: mockShopifyOrder }],
},
},
});
const orders = await admin.getOrders(5);
expect(client.adminQuery).toHaveBeenCalledWith(
expect.stringContaining('GetOrders'),
{ first: 5 },
);
expect(orders).toHaveLength(1);
expect(orders[0]!.id).toBe('gid://shopify/Order/1');
expect(orders[0]!.checkout_id).toBe('#1001');
expect(orders[0]!.status).toBe('processing');
});
it('uses default first=10 when no argument provided', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
orders: { edges: [] },
},
});
await admin.getOrders();
expect(client.adminQuery).toHaveBeenCalledWith(
expect.stringContaining('GetOrders'),
{ first: 10 },
);
});
it('returns empty array when data is null', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
});
const orders = await admin.getOrders();
expect(orders).toEqual([]);
});
it('throws when response has errors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
errors: [
{ message: 'Internal error' },
{ message: 'Query too complex' },
],
});
await expect(admin.getOrders()).rejects.toThrow(
'Admin getOrders failed: Internal error, Query too complex',
);
});
});
describe('AdminAPI.getInventoryLevel', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('returns inventory quantity', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
productVariant: {
inventoryQuantity: 42,
inventoryItem: { id: 'gid://shopify/InventoryItem/1' },
},
},
});
const qty = await admin.getInventoryLevel('gid://shopify/ProductVariant/1');
expect(client.adminQuery).toHaveBeenCalledWith(
expect.stringContaining('GetInventoryLevel'),
{ id: 'gid://shopify/ProductVariant/1' },
);
expect(qty).toBe(42);
});
it('throws when variant is not found', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { productVariant: null },
});
await expect(
admin.getInventoryLevel('gid://shopify/ProductVariant/missing'),
).rejects.toThrow(
'Variant not found: gid://shopify/ProductVariant/missing',
);
});
it('throws when response has errors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
errors: [{ message: 'Unauthorized' }],
});
await expect(
admin.getInventoryLevel('gid://shopify/ProductVariant/1'),
).rejects.toThrow('Admin getInventoryLevel failed: Unauthorized');
});
});
describe('AdminAPI.getDiscountCodes', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('returns percentage discount correctly (percentage * 100)', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
codeDiscountNodes: {
edges: [{ node: percentageDiscountNode }],
},
},
});
const discounts = await admin.getDiscountCodes();
expect(client.adminQuery).toHaveBeenCalledWith(
expect.stringContaining('GetDiscountCodes'),
{ first: 50 },
);
expect(discounts).toHaveLength(1);
const d = discounts[0]!;
expect(d.code).toBe('SAVE20');
expect(d.type).toBe('percentage');
expect(d.value).toBe(20); // 0.2 * 100
expect(d.description).toBe('20% off your order');
expect(d.min_purchase).toBe(5000); // $50.00 in cents
});
it('returns fixed_amount discount correctly (amount in minor units)', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
codeDiscountNodes: {
edges: [{ node: fixedDiscountNode }],
},
},
});
const discounts = await admin.getDiscountCodes();
expect(discounts).toHaveLength(1);
const d = discounts[0]!;
expect(d.code).toBe('SAVE10');
expect(d.type).toBe('fixed_amount');
expect(d.value).toBe(1000); // $10.00 in cents
expect(d.description).toBe('$10 off orders over $50');
expect(d.min_purchase).toBeUndefined();
});
it('returns empty array when data is null', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
});
const discounts = await admin.getDiscountCodes();
expect(discounts).toEqual([]);
});
it('handles discount with no minimumRequirement', async () => {
const nodeNoMin = {
id: 'gid://shopify/DiscountCodeNode/3',
codeDiscount: {
title: 'Free Ship',
summary: 'No minimum required',
codes: { edges: [{ node: { code: 'FREESHIP' } }] },
customerGets: { value: { percentage: 0.15 } },
minimumRequirement: null,
},
};
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
codeDiscountNodes: {
edges: [{ node: nodeNoMin }],
},
},
});
const discounts = await admin.getDiscountCodes();
expect(discounts).toHaveLength(1);
const d = discounts[0]!;
expect(d.code).toBe('FREESHIP');
expect(d.type).toBe('percentage');
expect(d.value).toBe(15); // 0.15 * 100
expect(d.min_purchase).toBeUndefined();
});
it('returns multiple discounts from mixed types', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
codeDiscountNodes: {
edges: [
{ node: percentageDiscountNode },
{ node: fixedDiscountNode },
],
},
},
});
const discounts = await admin.getDiscountCodes();
expect(discounts).toHaveLength(2);
expect(discounts[0]!.type).toBe('percentage');
expect(discounts[1]!.type).toBe('fixed_amount');
});
it('throws when response has errors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
errors: [{ message: 'Scope missing' }],
});
await expect(admin.getDiscountCodes()).rejects.toThrow(
'Admin getDiscountCodes failed: Scope missing',
);
});
});
describe('AdminAPI.applyDiscount', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('returns true when discount is successfully applied', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
checkoutDiscountCodeApplyV2: {
checkout: { id: 'gid://shopify/Checkout/1' },
checkoutUserErrors: [],
},
},
});
const result = await admin.applyDiscount(
'gid://shopify/Checkout/1',
'SAVE20',
);
expect(client.adminQuery).toHaveBeenCalledWith(
expect.stringContaining('ApplyDiscount'),
{
checkoutId: 'gid://shopify/Checkout/1',
discountCode: 'SAVE20',
},
);
expect(result).toBe(true);
});
it('returns false when result is null', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { checkoutDiscountCodeApplyV2: null },
});
const result = await admin.applyDiscount(
'gid://shopify/Checkout/1',
'INVALID',
);
expect(result).toBe(false);
});
it('returns false when data itself is null', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
});
const result = await admin.applyDiscount(
'gid://shopify/Checkout/1',
'NODATA',
);
expect(result).toBe(false);
});
it('returns false when has checkoutUserErrors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
checkoutDiscountCodeApplyV2: {
checkout: null,
checkoutUserErrors: [
{
field: ['discountCode'],
message: 'Discount code is not valid',
code: 'DISCOUNT_NOT_FOUND',
},
],
},
},
});
const result = await admin.applyDiscount(
'gid://shopify/Checkout/1',
'EXPIRED',
);
expect(result).toBe(false);
});
it('returns false when checkout is null and no user errors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: {
checkoutDiscountCodeApplyV2: {
checkout: null,
checkoutUserErrors: [],
},
},
});
const result = await admin.applyDiscount(
'gid://shopify/Checkout/1',
'WEIRD',
);
expect(result).toBe(false);
});
it('throws when response has errors', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: null,
errors: [{ message: 'Mutation failed' }],
});
await expect(
admin.applyDiscount('gid://shopify/Checkout/1', 'SAVE20'),
).rejects.toThrow('Admin applyDiscount failed: Mutation failed');
});
});
describe('AdminAPI order status mappings', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
function orderWithStatuses(
financialStatus: string | null,
fulfillmentStatus: string,
) {
return {
...mockShopifyOrder,
displayFinancialStatus: financialStatus,
displayFulfillmentStatus: fulfillmentStatus,
};
}
it('maps FULFILLED to "delivered"', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('PAID', 'FULFILLED') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('delivered');
});
it('maps IN_PROGRESS to "shipped"', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('PAID', 'IN_PROGRESS') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('shipped');
});
it('maps PARTIALLY_FULFILLED to "shipped"', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('PAID', 'PARTIALLY_FULFILLED') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('shipped');
});
it('maps UNFULFILLED to "processing"', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('PAID', 'UNFULFILLED') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('processing');
});
it('maps unknown fulfillment status to "confirmed"', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('PAID', 'SOME_UNKNOWN_STATUS') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('confirmed');
});
it('maps REFUNDED financial status to "cancelled" regardless of fulfillment', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('REFUNDED', 'FULFILLED') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('cancelled');
});
it('maps VOIDED financial status to "cancelled" regardless of fulfillment', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses('VOIDED', 'IN_PROGRESS') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('cancelled');
});
it('handles null financial status gracefully', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithStatuses(null, 'UNFULFILLED') },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.status).toBe('processing');
});
});
describe('AdminAPI order fulfillment info', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('populates fulfillment info when fulfillments exist', async () => {
const orderWithFulfillment = {
...mockShopifyOrder,
displayFulfillmentStatus: 'FULFILLED',
fulfillments: [
{
status: 'SUCCESS',
trackingInfo: [
{
number: 'TRACK123',
url: 'https://track.test/TRACK123',
company: 'FedEx',
},
],
estimatedDeliveryAt: '2025-02-01T00:00:00Z',
},
],
};
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderWithFulfillment },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.fulfillment).toBeDefined();
expect(order!.fulfillment!.status).toBe('fulfilled');
expect(order!.fulfillment!.tracking_number).toBe('TRACK123');
expect(order!.fulfillment!.tracking_url).toBe(
'https://track.test/TRACK123',
);
expect(order!.fulfillment!.carrier).toBe('FedEx');
expect(order!.fulfillment!.estimated_delivery).toBe(
'2025-02-01T00:00:00Z',
);
});
it('returns undefined fulfillment when fulfillments array is empty', async () => {
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: mockShopifyOrder },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.fulfillment).toBeUndefined();
});
it('handles fulfillment with empty trackingInfo', async () => {
const orderNoTracking = {
...mockShopifyOrder,
displayFulfillmentStatus: 'IN_PROGRESS',
fulfillments: [
{
status: 'IN_PROGRESS',
trackingInfo: [],
estimatedDeliveryAt: null,
},
],
};
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderNoTracking },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.fulfillment).toBeDefined();
expect(order!.fulfillment!.status).toBe('partial');
expect(order!.fulfillment!.tracking_number).toBeUndefined();
expect(order!.fulfillment!.tracking_url).toBeUndefined();
expect(order!.fulfillment!.carrier).toBeUndefined();
expect(order!.fulfillment!.estimated_delivery).toBeUndefined();
});
it('maps PARTIALLY_FULFILLED fulfillment status to "partial"', async () => {
const orderPartial = {
...mockShopifyOrder,
displayFulfillmentStatus: 'PARTIALLY_FULFILLED',
fulfillments: [
{
status: 'PARTIAL',
trackingInfo: [],
estimatedDeliveryAt: null,
},
],
};
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderPartial },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.fulfillment!.status).toBe('partial');
});
});
describe('AdminAPI line item edge cases', () => {
let client: ShopifyClient;
let admin: AdminAPI;
beforeEach(() => {
client = createMockClient();
admin = new AdminAPI(client);
});
it('handles line item with null variant', async () => {
const orderNullVariant = {
...mockShopifyOrder,
lineItems: {
edges: [
{
node: {
id: 'li-no-var',
title: 'Deleted Product',
quantity: 2,
sku: null,
variant: null,
originalTotalSet: {
shopMoney: { amount: '0.00', currencyCode: 'USD' },
},
discountedTotalSet: {
shopMoney: { amount: '0.00', currencyCode: 'USD' },
},
},
},
],
},
};
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderNullVariant },
});
const order = await admin.getOrder('gid://shopify/Order/1');
const li = order!.line_items[0]!;
expect(li.product_id).toBe('');
expect(li.variant_id).toBe('');
expect(li.unit_amount).toBe(0);
expect(li.image_url).toBeUndefined();
expect(li.sku).toBeUndefined(); // null sku => undefined
});
it('handles line item with null sku', async () => {
const orderNullSku = {
...mockShopifyOrder,
lineItems: {
edges: [
{
node: {
...mockShopifyOrder.lineItems.edges[0]!.node,
sku: null,
},
},
],
},
};
vi.mocked(client.adminQuery).mockResolvedValueOnce({
data: { order: orderNullSku },
});
const order = await admin.getOrder('gid://shopify/Order/1');
expect(order!.line_items[0]!.sku).toBeUndefined();
});
});