2026-05-14 14:47:23 +09:00
|
|
|
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(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
vi.mock('../../src/services/api-client', () => ({
|
2026-05-14 14:47:23 +09:00
|
|
|
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
listProducts,
|
|
|
|
|
getProduct,
|
|
|
|
|
lookupBarcode,
|
|
|
|
|
createProduct,
|
|
|
|
|
updateProduct,
|
|
|
|
|
deleteProduct,
|
|
|
|
|
smartAddProduct,
|
|
|
|
|
importProducts,
|
2026-05-19 11:06:03 +09:00
|
|
|
} from '../../src/services/products';
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|