/**
* REST Handler — Catalog (isolated unit tests with mocks)
* Targets 95%+ coverage of src/rest/catalog.ts
*/
vi.mock('../../src/tools/scout-inventory.js', () => ({
scoutInventory: vi.fn(),
}));
import { handleCatalog } from '../../src/rest/catalog.js';
import type { RESTRequest } from '../../src/rest/types.js';
import { scoutInventory } from '../../src/tools/scout-inventory.js';
// ─── Helpers ───
function makeReq(overrides: Partial<RESTRequest> = {}): RESTRequest {
return {
method: 'GET',
path: '/ucp/v1/catalog',
segments: [],
query: {},
body: null,
headers: {},
...overrides,
};
}
// ─── Setup ───
beforeEach(() => {
vi.clearAllMocks();
});
// ─── Tests ───
describe('handleCatalog', () => {
// 1. GET with q parameter — returns 200
it('GET with q parameter returns 200 with scout results', async () => {
const mockResult = { products: [{ id: 'p-1', title: 'Blue Shirt' }] };
vi.mocked(scoutInventory).mockResolvedValue(mockResult as any);
const res = await handleCatalog(makeReq({ query: { q: 'shirt' } }));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(mockResult);
expect(scoutInventory).toHaveBeenCalledWith({ query: 'shirt' });
});
// 2. GET with category, price_min, price_max, limit — passes parsed params
it('GET with all optional params passes parsed values to scoutInventory', async () => {
const mockResult = { products: [{ id: 'p-2', title: 'Red Hat' }] };
vi.mocked(scoutInventory).mockResolvedValue(mockResult as any);
const res = await handleCatalog(makeReq({
query: {
q: 'hat',
category: 'accessories',
price_min: '1000',
price_max: '5000',
limit: '5',
},
}));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(mockResult);
expect(scoutInventory).toHaveBeenCalledWith({
query: 'hat',
category: 'accessories',
price_min: 1000,
price_max: 5000,
limit: 5,
});
});
// 3. GET without q — returns 400
it('GET without q parameter returns 400 bad request', async () => {
const res = await handleCatalog(makeReq());
expect(res.statusCode).toBe(400);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('bad_request');
expect(body.error.message).toContain('q is required');
expect(scoutInventory).not.toHaveBeenCalled();
});
// 4. GET with productId segment — returns 404
it('GET with productId segment returns 404 not found', async () => {
const res = await handleCatalog(makeReq({ segments: ['prod-123'] }));
expect(res.statusCode).toBe(404);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('not_found');
expect(body.error.message).toContain('prod-123');
expect(scoutInventory).not.toHaveBeenCalled();
});
// 5. POST — returns 405
it('POST returns 405 method not allowed', async () => {
const res = await handleCatalog(makeReq({ method: 'POST' }));
expect(res.statusCode).toBe(405);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('method_not_allowed');
expect(body.error.message).toContain('GET');
});
// 6. scoutInventory throws — returns 500
it('returns 500 when scoutInventory throws an Error', async () => {
vi.mocked(scoutInventory).mockRejectedValue(new Error('Shopify API timeout'));
const res = await handleCatalog(makeReq({ query: { q: 'shirt' } }));
expect(res.statusCode).toBe(500);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('internal_error');
expect(body.error.message).toContain('Shopify API timeout');
});
// Additional edge cases for thorough coverage
it('PUT returns 405 method not allowed', async () => {
const res = await handleCatalog(makeReq({ method: 'PUT' }));
expect(res.statusCode).toBe(405);
});
it('DELETE returns 405 method not allowed', async () => {
const res = await handleCatalog(makeReq({ method: 'DELETE' }));
expect(res.statusCode).toBe(405);
});
it('returns 500 when scoutInventory throws a non-Error value', async () => {
vi.mocked(scoutInventory).mockRejectedValue('raw string error');
const res = await handleCatalog(makeReq({ query: { q: 'test' } }));
expect(res.statusCode).toBe(500);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('internal_error');
expect(body.error.message).toBe('raw string error');
});
it('GET with only category (no q) returns 400', async () => {
const res = await handleCatalog(makeReq({ query: { category: 'shoes' } }));
expect(res.statusCode).toBe(400);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('bad_request');
});
it('GET with q and only price_min passes partial params', async () => {
const mockResult = { products: [] };
vi.mocked(scoutInventory).mockResolvedValue(mockResult as any);
const res = await handleCatalog(makeReq({
query: { q: 'jacket', price_min: '2000' },
}));
expect(res.statusCode).toBe(200);
expect(scoutInventory).toHaveBeenCalledWith({
query: 'jacket',
price_min: 2000,
});
});
it('GET with q and only price_max passes partial params', async () => {
const mockResult = { products: [] };
vi.mocked(scoutInventory).mockResolvedValue(mockResult as any);
const res = await handleCatalog(makeReq({
query: { q: 'jacket', price_max: '10000' },
}));
expect(res.statusCode).toBe(200);
expect(scoutInventory).toHaveBeenCalledWith({
query: 'jacket',
price_max: 10000,
});
});
it('GET with q and only limit passes partial params', async () => {
const mockResult = { products: [] };
vi.mocked(scoutInventory).mockResolvedValue(mockResult as any);
const res = await handleCatalog(makeReq({
query: { q: 'jacket', limit: '20' },
}));
expect(res.statusCode).toBe(200);
expect(scoutInventory).toHaveBeenCalledWith({
query: 'jacket',
limit: 20,
});
});
it('GET with nested segment returns 404 with correct product ID', async () => {
const res = await handleCatalog(makeReq({ segments: ['prod-456', 'variants'] }));
expect(res.statusCode).toBe(404);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.message).toContain('prod-456');
});
});