/**
* Tests for scout_inventory tool
* Covers: search, category filter, price filters, limit/pagination, error handling
*/
import type { CatalogProduct } from '../../src/types.js';
// ─── Mocks ───
vi.mock('../../src/types.js', () => ({
loadConfig: vi.fn(() => ({
shopify: {
storeDomain: 'test.myshopify.com',
accessToken: 'token',
storefrontToken: 'sf-token',
},
ap2: {},
gateway: {},
dynamodb: {},
})),
}));
const mockSearchProducts = vi.fn();
vi.mock('../../src/shopify/storefront.js', () => ({
StorefrontAPI: vi.fn(() => ({ searchProducts: mockSearchProducts })),
}));
vi.mock('../../src/shopify/client.js', () => ({
ShopifyClient: vi.fn(),
}));
vi.mock('../../src/utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { scoutInventory } from '../../src/tools/scout-inventory.js';
// ─── Helpers ───
function makeProduct(
id: string,
variantPrices: number[],
): CatalogProduct {
return {
id,
title: `Product ${id}`,
description: `Description for ${id}`,
vendor: 'TestVendor',
product_type: 'TestType',
tags: [],
variants: variantPrices.map((price, i) => ({
id: `variant-${id}-${i}`,
title: `Variant ${i}`,
sku: `SKU-${id}-${i}`,
price,
currency: 'USD',
available: true,
inventory_quantity: 10,
})),
images: [],
available: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};
}
// ─── Test Suites ───
describe('scoutInventory', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// 1. Returns products from search
it('returns products from search', async () => {
const products = [makeProduct('p1', [1000]), makeProduct('p2', [2000])];
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({ query: 'shoes' });
expect(result.products).toHaveLength(2);
expect(result.total_count).toBe(2);
expect(result.has_more).toBe(false);
expect(mockSearchProducts).toHaveBeenCalledWith('shoes', 10);
});
// 2. Applies category filter to search query
it('applies category filter to search query', async () => {
mockSearchProducts.mockResolvedValue([]);
await scoutInventory({ query: 'boots', category: 'footwear' });
expect(mockSearchProducts).toHaveBeenCalledWith(
'boots product_type:footwear',
10,
);
});
// 3. Applies price_min filter (filters out cheap variants)
it('filters out products below price_min', async () => {
const products = [
makeProduct('cheap', [500]), // only variant at 500 — should be excluded
makeProduct('ok', [1500]), // variant at 1500 — should pass
];
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({ query: 'test', price_min: 1000 });
expect(result.products).toHaveLength(1);
expect(result.products[0]!.id).toBe('ok');
});
// 4. Applies price_max filter (filters out expensive variants)
it('filters out products above price_max', async () => {
const products = [
makeProduct('affordable', [1000]),
makeProduct('expensive', [5000]),
];
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({ query: 'test', price_max: 2000 });
expect(result.products).toHaveLength(1);
expect(result.products[0]!.id).toBe('affordable');
});
// 5. Applies both price_min and price_max
it('applies both price_min and price_max', async () => {
const products = [
makeProduct('too-cheap', [200]),
makeProduct('in-range', [1500]),
makeProduct('too-expensive', [5000]),
];
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({
query: 'test',
price_min: 1000,
price_max: 3000,
});
expect(result.products).toHaveLength(1);
expect(result.products[0]!.id).toBe('in-range');
});
// 6. Uses fetchLimit = limit * 3 when price filters present
it('uses fetchLimit = limit * 3 when price filters are present', async () => {
mockSearchProducts.mockResolvedValue([]);
await scoutInventory({ query: 'test', limit: 5, price_min: 100 });
expect(mockSearchProducts).toHaveBeenCalledWith('test', 15); // 5 * 3
});
// 7. Caps fetchLimit at 250
it('caps fetchLimit at 250', async () => {
mockSearchProducts.mockResolvedValue([]);
await scoutInventory({ query: 'test', limit: 100, price_max: 5000 });
// 100 * 3 = 300, capped at 250
expect(mockSearchProducts).toHaveBeenCalledWith('test', 250);
});
// 8. Default limit is 10
it('uses default limit of 10 when not specified', async () => {
mockSearchProducts.mockResolvedValue([]);
await scoutInventory({ query: 'test' });
expect(mockSearchProducts).toHaveBeenCalledWith('test', 10);
});
// 9. Trims results to requested limit
it('trims results to requested limit', async () => {
const products = Array.from({ length: 10 }, (_, i) =>
makeProduct(`p${i}`, [1000]),
);
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({ query: 'test', limit: 3 });
expect(result.products).toHaveLength(3);
expect(result.total_count).toBe(3);
});
// 10. Sets has_more=true when more results available after trim
it('sets has_more=true when more results exist after trimming', async () => {
const products = Array.from({ length: 5 }, (_, i) =>
makeProduct(`p${i}`, [1000]),
);
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({ query: 'test', limit: 3 });
expect(result.has_more).toBe(true);
expect(result.products).toHaveLength(3);
});
// 11. Returns empty result on error (catch block)
it('returns empty result on error', async () => {
mockSearchProducts.mockRejectedValue(new Error('API down'));
const result = await scoutInventory({ query: 'test' });
expect(result.products).toEqual([]);
expect(result.total_count).toBe(0);
expect(result.has_more).toBe(false);
});
// 12. Product passes filter if ANY variant is in range
it('product passes price filter if any variant is in range', async () => {
// Product has two variants: one too cheap, one in range
const product = makeProduct('multi-variant', [200, 1500, 8000]);
mockSearchProducts.mockResolvedValue([product]);
const result = await scoutInventory({
query: 'test',
price_min: 1000,
price_max: 5000,
});
expect(result.products).toHaveLength(1);
expect(result.products[0]!.id).toBe('multi-variant');
});
// Edge: product excluded when NO variant is in range
it('excludes product when no variant matches range', async () => {
const product = makeProduct('all-out-of-range', [200, 8000]);
mockSearchProducts.mockResolvedValue([product]);
const result = await scoutInventory({
query: 'test',
price_min: 1000,
price_max: 5000,
});
expect(result.products).toHaveLength(0);
expect(result.total_count).toBe(0);
});
// Edge: has_more is false when filtered count equals limit
it('has_more is false when filtered count equals limit exactly', async () => {
const products = [makeProduct('p1', [1000]), makeProduct('p2', [2000])];
mockSearchProducts.mockResolvedValue(products);
const result = await scoutInventory({ query: 'test', limit: 2 });
expect(result.has_more).toBe(false);
expect(result.products).toHaveLength(2);
});
// Edge: fetchLimit without price filters equals effectiveLimit
it('fetchLimit equals effectiveLimit when no price filters', async () => {
mockSearchProducts.mockResolvedValue([]);
await scoutInventory({ query: 'test', limit: 20 });
expect(mockSearchProducts).toHaveBeenCalledWith('test', 20);
});
// Edge: price_max only triggers 3x multiplier
it('uses 3x multiplier when only price_max is set', async () => {
mockSearchProducts.mockResolvedValue([]);
await scoutInventory({ query: 'test', limit: 10, price_max: 3000 });
expect(mockSearchProducts).toHaveBeenCalledWith('test', 30);
});
// Edge: error that is not an Error instance
it('handles non-Error exceptions gracefully', async () => {
mockSearchProducts.mockRejectedValue('string error');
const result = await scoutInventory({ query: 'test' });
expect(result.products).toEqual([]);
expect(result.total_count).toBe(0);
expect(result.has_more).toBe(false);
});
});