import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn(), mockPatch: vi.fn(), mockDelete: vi.fn(), })); vi.mock('../../src/services/api-client', () => ({ apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete }, })); import { listProducts, getProduct, lookupBarcode, createProduct, updateProduct, deleteProduct, smartAddProduct, importProducts, } from '../../src/services/products'; beforeEach(() => vi.clearAllMocks()); describe('products service', () => { it('listProducts with no query', async () => { mockGet.mockResolvedValue({ data: [] }); await listProducts('hh1'); expect(mockGet).toHaveBeenCalledWith('/households/hh1/products'); }); it('listProducts builds query string', async () => { mockGet.mockResolvedValue({ data: [] }); await listProducts('hh1', { q: 'chicken', category: 'meat', tags: 'organic', barcode: '1234', cursor: 'cur1', limit: 10, }); const url = mockGet.mock.calls[0][0] as string; expect(url).toContain('q=chicken'); expect(url).toContain('category=meat'); expect(url).toContain('tags=organic'); expect(url).toContain('barcode=1234'); expect(url).toContain('cursor=cur1'); expect(url).toContain('limit=10'); }); it('getProduct calls GET with correct path', async () => { mockGet.mockResolvedValue({ _id: 'p1' }); await getProduct('hh1', 'p1'); expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/p1'); }); it('lookupBarcode calls GET barcode endpoint', async () => { mockGet.mockResolvedValue({ _id: 'p1' }); await lookupBarcode('hh1', '1234567890'); expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/barcode/1234567890'); }); it('createProduct calls POST', async () => { mockPost.mockResolvedValue({ _id: 'p1' }); const data = { name: 'Chicken', category: 'meat' as never, servingSize: 100, servingUnit: 'g' as never, nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 }, tags: [], source: 'manual' as never, }; await createProduct('hh1', data); expect(mockPost).toHaveBeenCalledWith('/households/hh1/products', data); }); it('updateProduct calls PATCH', async () => { mockPatch.mockResolvedValue({ _id: 'p1' }); await updateProduct('hh1', 'p1', { name: 'Updated' }); expect(mockPatch).toHaveBeenCalledWith('/households/hh1/products/p1', { name: 'Updated' }); }); it('deleteProduct calls DELETE', async () => { mockDelete.mockResolvedValue(undefined); await deleteProduct('hh1', 'p1'); expect(mockDelete).toHaveBeenCalledWith('/households/hh1/products/p1'); }); it('smartAddProduct calls POST smart-add', async () => { mockPost.mockResolvedValue({ available: false, message: 'LLM not configured' }); const result = await smartAddProduct('hh1', 'chicken breast'); expect(mockPost).toHaveBeenCalledWith('/households/hh1/products/smart-add', { text: 'chicken breast', }); expect(result.available).toBe(false); }); describe('importProducts', () => { const mockFetch = vi.fn(); beforeEach(() => { vi.stubGlobal('fetch', mockFetch); }); it('uploads file via fetch and returns result', async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ imported: 3, skippedDuplicates: 1, errors: [] }), }); const file = new File(['data'], 'products.csv', { type: 'text/csv' }); const result = await importProducts('hh1', file); expect(result.imported).toBe(3); expect(result.skippedDuplicates).toBe(1); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/households/hh1/products/import'), expect.objectContaining({ method: 'POST' }), ); }); it('throws on non-ok response with JSON body', async () => { mockFetch.mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request', json: () => Promise.resolve({ message: 'File too large' }), }); const file = new File(['data'], 'products.csv', { type: 'text/csv' }); await expect(importProducts('hh1', file)).rejects.toThrow('File too large'); }); it('throws with status on non-ok response without JSON', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', json: () => Promise.reject(new Error('not json')), }); const file = new File(['data'], 'products.csv', { type: 'text/csv' }); await expect(importProducts('hh1', file)).rejects.toThrow( 'Import failed: 500 Internal Server Error', ); }); it('throws with status when JSON body has no message', async () => { mockFetch.mockResolvedValue({ ok: false, status: 422, statusText: 'Unprocessable Entity', json: () => Promise.resolve({}), }); const file = new File(['data'], 'products.csv', { type: 'text/csv' }); await expect(importProducts('hh1', file)).rejects.toThrow('Import failed: 422'); }); }); });