Tests refactor

This commit is contained in:
Aerilyn Weber 2026-05-19 11:06:03 +09:00
parent 245520fb50
commit 99134d8556
165 changed files with 911 additions and 531 deletions

View file

@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { apiClient as ApiClientType } from '../../src/services/api-client';
// Create a fresh ApiClient for each test by re-importing
let apiClient: typeof ApiClientType;
beforeEach(async () => {
vi.restoreAllMocks();
// Reset module to get a fresh singleton
vi.resetModules();
const mod = await import('../../src/services/api-client');
apiClient = mod.apiClient;
});
function mockFetch(body: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
} as Response);
}
describe('ApiClient', () => {
describe('token management', () => {
it('hasToken returns false initially', () => {
expect(apiClient.hasToken).toBe(false);
});
it('hasToken returns true after setting token', () => {
apiClient.accessToken = 'test-token';
expect(apiClient.hasToken).toBe(true);
});
it('includes Authorization header when token is set', async () => {
const spy = mockFetch({ data: [] });
apiClient.accessToken = 'my-jwt';
await apiClient.get('/test');
const headers = spy.mock.calls[0][1]?.headers as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer my-jwt');
});
it('omits Authorization header when no token', async () => {
const spy = mockFetch({ data: [] });
await apiClient.get('/test');
const headers = spy.mock.calls[0][1]?.headers as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
});
});
describe('get', () => {
it('makes GET request to correct URL', async () => {
const spy = mockFetch({ id: '1' });
await apiClient.get('/households/hh1/stores');
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('/households/hh1/stores'),
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('returns parsed JSON body', async () => {
mockFetch({ id: '1', name: 'CVS' });
const result = await apiClient.get('/test');
expect(result).toEqual({ id: '1', name: 'CVS' });
});
});
describe('post', () => {
it('makes POST request with JSON body', async () => {
const spy = mockFetch({ id: '1' });
const body = { name: 'Walgreens' };
await apiClient.post('/stores', body);
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('/stores'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify(body),
}),
);
});
it('handles post without body', async () => {
const spy = mockFetch({ id: '1' });
await apiClient.post('/test');
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ body: undefined }),
);
});
});
describe('patch', () => {
it('makes PATCH request with JSON body', async () => {
const spy = mockFetch({ id: '1' });
const body = { name: 'Updated' };
await apiClient.patch('/stores/1', body);
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('/stores/1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify(body),
}),
);
});
});
describe('delete', () => {
it('makes DELETE request', async () => {
const spy = mockFetch(undefined, 204);
await apiClient.delete('/stores/1');
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('/stores/1'),
expect.objectContaining({ method: 'DELETE' }),
);
});
});
describe('error handling', () => {
it('throws with message from error response body', async () => {
mockFetch({ message: 'Store not found' }, 404);
await expect(apiClient.get('/test')).rejects.toThrow('Store not found');
});
it('throws with status code when body has no message', async () => {
mockFetch({}, 500);
await expect(apiClient.get('/test')).rejects.toThrow('Request failed: 500');
});
it('throws with status text when JSON parsing fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 502,
statusText: 'Bad Gateway',
json: () => Promise.reject(new Error('parse error')),
} as Response);
await expect(apiClient.get('/test')).rejects.toThrow('Request failed: 502 Bad Gateway');
});
it('returns undefined for 204 No Content', async () => {
mockFetch(null, 204);
const result = await apiClient.get('/test');
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet } = vi.hoisted(() => ({
mockGet: vi.fn(),
}));
vi.mock('../../src/services/api-client', () => ({
apiClient: { get: mockGet },
}));
import { listCabinetEvents, getEventsByItem, getSpendingSummary } from '../../src/services/cabinet-events';
beforeEach(() => vi.clearAllMocks());
describe('cabinet-events service', () => {
it('listCabinetEvents with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listCabinetEvents('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet-events');
});
it('listCabinetEvents builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listCabinetEvents('hh1', {
medicineId: 'med-1',
eventType: 'dispense',
startDate: '2026-01-01',
endDate: '2026-02-01',
});
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('eventType=dispense'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('startDate=2026-01-01'));
});
it('getEventsByItem builds URL with cabinetItemId', async () => {
mockGet.mockResolvedValue({ data: [] });
await getEventsByItem('hh1', 'ci-1', { limit: 5 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/cabinet-events/by-item/ci-1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
});
it('listCabinetEvents with cursor and limit', async () => {
mockGet.mockResolvedValue({ data: [] });
await listCabinetEvents('hh1', { cursor: 'cur1', limit: 10 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=10'));
});
it('getEventsByItem with cursor', async () => {
mockGet.mockResolvedValue({ data: [] });
await getEventsByItem('hh1', 'ci-1', { cursor: 'cur2' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur2'));
});
it('getSpendingSummary with no query', async () => {
mockGet.mockResolvedValue({});
await getSpendingSummary('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet-events/spending-summary');
});
it('getSpendingSummary builds query string', async () => {
mockGet.mockResolvedValue({});
await getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('period=month'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
});
});

View file

@ -0,0 +1,100 @@
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 {
listCabinetItems,
getCabinetItem,
getCabinetSummary,
getExpiringSoon,
createCabinetItem,
updateCabinetItem,
adjustCabinetItemQuantity,
deleteCabinetItem,
} from '../../src/services/cabinet';
beforeEach(() => vi.clearAllMocks());
describe('cabinet service', () => {
it('listCabinetItems with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listCabinetItems('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet');
});
it('listCabinetItems builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listCabinetItems('hh1', { medicineId: 'med-1', status: 'active', expiringWithin: 30 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=active'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('expiringWithin=30'));
});
it('listCabinetItems with cursor and limit', async () => {
mockGet.mockResolvedValue({ data: [] });
await listCabinetItems('hh1', { cursor: 'cur1', limit: 20 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=20'));
});
it('getCabinetItem calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'ci-1' });
await getCabinetItem('hh1', 'ci-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1');
});
it('getCabinetSummary calls GET', async () => {
mockGet.mockResolvedValue({});
await getCabinetSummary('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/summary');
});
it('getExpiringSoon uses default days', async () => {
mockGet.mockResolvedValue({ data: [] });
await getExpiringSoon('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/expiring-soon?days=30');
});
it('getExpiringSoon uses custom days', async () => {
mockGet.mockResolvedValue({ data: [] });
await getExpiringSoon('hh1', 7);
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/expiring-soon?days=7');
});
it('createCabinetItem calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'ci-1' });
const data = { medicineId: 'med-1' } as never;
await createCabinetItem('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/cabinet', data);
});
it('updateCabinetItem calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'ci-1' });
await updateCabinetItem('hh1', 'ci-1', { notes: 'x' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1', { notes: 'x' });
});
it('adjustCabinetItemQuantity calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'ci-1' });
await adjustCabinetItemQuantity('hh1', 'ci-1', { adjustment: -5, reason: 'used' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1/adjust', {
adjustment: -5,
reason: 'used',
});
});
it('deleteCabinetItem calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteCabinetItem('hh1', 'ci-1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1');
});
});

View file

@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost, mockPatch } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPatch: vi.fn(),
}));
vi.mock('../../src/services/api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
}));
import {
createHousehold,
getHousehold,
updateHousehold,
generateInviteCode,
joinHousehold,
} from '../../src/services/households';
beforeEach(() => vi.clearAllMocks());
describe('households service', () => {
it('createHousehold calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'hh1' });
await createHousehold('My Home');
expect(mockPost).toHaveBeenCalledWith('/households', { name: 'My Home' });
});
it('getHousehold calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'hh1' });
await getHousehold('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1');
});
it('updateHousehold calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'hh1' });
await updateHousehold('hh1', { name: 'Updated' });
expect(mockPatch).toHaveBeenCalledWith('/households/hh1', { name: 'Updated' });
});
it('generateInviteCode calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'hh1' });
await generateInviteCode('hh1');
expect(mockPost).toHaveBeenCalledWith('/households/hh1/invite');
});
it('joinHousehold calls POST with invite code', async () => {
mockPost.mockResolvedValue({ _id: 'hh1' });
await joinHousehold('ABC123');
expect(mockPost).toHaveBeenCalledWith('/households/join', { inviteCode: 'ABC123' });
});
});

View file

@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from '../../src/services/api-client';
import * as MealPlansService from '../../src/services/meal-plans';
vi.mock('../../src/services/api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
describe('meal-plans service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('listMealPlans with and without query', async () => {
await MealPlansService.listMealPlans('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans');
await MealPlansService.listMealPlans('hh1', { cursor: 'cur', limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans?cursor=cur&limit=10');
});
it('getMealPlanByWeek', async () => {
await MealPlansService.getMealPlanByWeek('hh1', '2026-05-10');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/week/2026-05-10');
});
it('getMealPlan', async () => {
await MealPlansService.getMealPlan('hh1', 'mp1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
});
it('createMealPlan', async () => {
const data = { weekStartDate: '2026-05-10' } as any;
await MealPlansService.createMealPlan('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/meal-plans', data);
});
it('updateMealPlan', async () => {
const data = { days: [] } as any;
await MealPlansService.updateMealPlan('hh1', 'mp1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1', data);
});
it('updateMealPlanStatus', async () => {
await MealPlansService.updateMealPlanStatus('hh1', 'mp1', 'active' as any);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/status', { status: 'active' });
});
it('deleteMealPlan', async () => {
await MealPlansService.deleteMealPlan('hh1', 'mp1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
});
it('getSuggestions', async () => {
await MealPlansService.getSuggestions('hh1', 3);
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/suggestions?limit=3');
});
it('getShoppingGap', async () => {
await MealPlansService.getShoppingGap('hh1', 'mp1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/gap');
});
});

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
}));
vi.mock('../../src/services/api-client', () => ({
apiClient: { get: mockGet, post: mockPost },
}));
import { recordPrice, getPriceHistory, compareStores, getPriceAnalytics } from '../../src/services/medicine-prices';
beforeEach(() => vi.clearAllMocks());
describe('medicine-prices service', () => {
it('recordPrice calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'pr-1' });
const data = { medicineProductId: 'mp-1', storeId: 'st-1', price: 10 } as never;
await recordPrice('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicine-prices', data);
});
it('getPriceHistory with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await getPriceHistory('hh1', 'med-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/history/med-1');
});
it('getPriceHistory builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await getPriceHistory('hh1', 'med-1', { storeId: 'st-1', startDate: '2026-01-01' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('storeId=st-1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('startDate=2026-01-01'));
});
it('getPriceHistory with endDate, cursor and limit', async () => {
mockGet.mockResolvedValue({ data: [] });
await getPriceHistory('hh1', 'med-1', { endDate: '2026-12-31', cursor: 'cur1', limit: 10 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('endDate=2026-12-31'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=10'));
});
it('compareStores calls GET with medicineId', async () => {
mockGet.mockResolvedValue({ data: [] });
await compareStores('hh1', 'med-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/compare/med-1');
});
it('getPriceAnalytics uses default period', async () => {
mockGet.mockResolvedValue({});
await getPriceAnalytics('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/analytics?period=month');
});
it('getPriceAnalytics uses custom period', async () => {
mockGet.mockResolvedValue({});
await getPriceAnalytics('hh1', 'year');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/analytics?period=year');
});
});

View file

@ -0,0 +1,105 @@
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 {
listMedicines,
getMedicine,
createMedicine,
updateMedicine,
deleteMedicine,
listMedicineProducts,
createMedicineProduct,
updateMedicineProduct,
deleteMedicineProduct,
} from '../../src/services/medicines';
beforeEach(() => vi.clearAllMocks());
describe('medicines service', () => {
it('listMedicines with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listMedicines('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicines');
});
it('listMedicines builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listMedicines('hh1', { q: 'aspirin', category: 'pain', form: 'tablet', limit: 10 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('q=aspirin'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('category=pain'));
});
it('listMedicines with cursor', async () => {
mockGet.mockResolvedValue({ data: [] });
await listMedicines('hh1', { cursor: 'cur1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
});
it('getMedicine calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'med-1' });
await getMedicine('hh1', 'med-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicines/med-1');
});
it('createMedicine calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'med-1' });
await createMedicine('hh1', { name: 'Aspirin' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicines', { name: 'Aspirin' });
});
it('updateMedicine calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'med-1' });
await updateMedicine('hh1', 'med-1', { name: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/medicines/med-1', { name: 'Updated' });
});
it('deleteMedicine calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteMedicine('hh1', 'med-1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/medicines/med-1');
});
it('listMedicineProducts builds URL with medicineId', async () => {
mockGet.mockResolvedValue({ data: [] });
await listMedicineProducts('hh1', 'med-1', { limit: 5 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/medicines/med-1/products'));
});
it('listMedicineProducts with cursor', async () => {
mockGet.mockResolvedValue({ data: [] });
await listMedicineProducts('hh1', 'med-1', { cursor: 'cur1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
});
it('createMedicineProduct calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'mp-1' });
await createMedicineProduct('hh1', 'med-1', { brand: 'Bayer' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicines/med-1/products', {
brand: 'Bayer',
});
});
it('updateMedicineProduct uses medicine-products path', async () => {
mockPatch.mockResolvedValue({ _id: 'mp-1' });
await updateMedicineProduct('hh1', 'mp-1', { brand: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/medicine-products/mp-1', {
brand: 'Updated',
});
});
it('deleteMedicineProduct calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteMedicineProduct('hh1', 'mp-1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/medicine-products/mp-1');
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from '../../src/services/api-client';
import * as NutritionTargetsService from '../../src/services/nutrition-targets';
vi.mock('../../src/services/api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('nutrition-targets service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('getActiveNutritionTarget', async () => {
await NutritionTargetsService.getActiveNutritionTarget('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets');
});
it('getNutritionTargetHistory', async () => {
await NutritionTargetsService.getNutritionTargetHistory('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets/history');
});
it('setNutritionTarget', async () => {
const data = { calories: 2000 } as any;
await NutritionTargetsService.setNutritionTarget('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets', data);
});
it('calculateTargetPreset', async () => {
await NutritionTargetsService.calculateTargetPreset('hh1', 2500, 'gain');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets/presets', { calories: 2500, strategy: 'gain' });
});
});

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
}));
vi.mock('../../src/services/api-client', () => ({
apiClient: { get: mockGet, post: mockPost },
}));
import { listFills, getFill, previewFill, executeFill, undoFill } from '../../src/services/organizer';
beforeEach(() => vi.clearAllMocks());
describe('organizer service', () => {
it('listFills with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listFills('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/organizer/fills');
});
it('listFills builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listFills('hh1', { regimenId: 'reg-1', status: 'active' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('regimenId=reg-1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=active'));
});
it('listFills with cursor and limit', async () => {
mockGet.mockResolvedValue({ data: [] });
await listFills('hh1', { cursor: 'cur1', limit: 5 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
});
it('getFill calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'f-1' });
await getFill('hh1', 'f-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/organizer/fills/f-1');
});
it('previewFill calls POST', async () => {
mockPost.mockResolvedValue({});
const data = { regimenId: 'reg-1' } as never;
await previewFill('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/organizer/preview', data);
});
it('executeFill calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'f-1' });
const data = { regimenId: 'reg-1' } as never;
await executeFill('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/organizer/fill', data);
});
it('undoFill calls POST with empty body', async () => {
mockPost.mockResolvedValue({ _id: 'f-1' });
await undoFill('hh1', 'f-1');
expect(mockPost).toHaveBeenCalledWith('/households/hh1/organizer/fills/f-1/undo', {});
});
});

View file

@ -0,0 +1,124 @@
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 {
listPantryItems,
getPantryItem,
createPantryItem,
updatePantryItem,
transitionPantryItem,
batchTransitionPantryItems,
getExpiringSoon,
getWasteStats,
deletePantryItem,
} from '../../src/services/pantry';
beforeEach(() => vi.clearAllMocks());
describe('pantry service', () => {
it('listPantryItems with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPantryItems('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry');
});
it('listPantryItems builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPantryItems('hh1', {
storageLocation: 'fridge',
status: 'sealed',
urgency: 'urgent',
productId: 'p1',
limit: 10,
});
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('storageLocation=fridge');
expect(url).toContain('status=sealed');
expect(url).toContain('urgency=urgent');
expect(url).toContain('productId=p1');
expect(url).toContain('limit=10');
});
it('listPantryItems with cursor', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPantryItems('hh1', { cursor: 'cur1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
});
it('getPantryItem calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'item1' });
await getPantryItem('hh1', 'item1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/item1');
});
it('createPantryItem calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'item1' });
const data = { productId: 'p1', storageLocation: 'fridge', quantity: 1, unit: 'piece' };
await createPantryItem('hh1', data as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry', data);
});
it('updatePantryItem calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'item1' });
await updatePantryItem('hh1', 'item1', { quantity: 2 });
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/pantry/item1', { quantity: 2 });
});
it('transitionPantryItem calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'item1' });
await transitionPantryItem('hh1', 'item1', { status: 'opened' as never });
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/item1/transition', {
status: 'opened',
});
});
it('batchTransitionPantryItems calls POST', async () => {
mockPost.mockResolvedValue({ transitioned: 2, failed: 0 });
const data = { itemIds: ['a', 'b'], status: 'consumed' as const };
await batchTransitionPantryItems('hh1', data as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/batch-transition', data);
});
it('getExpiringSoon with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await getExpiringSoon('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/expiring-soon');
});
it('getExpiringSoon builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await getExpiringSoon('hh1', { days: 3, cursor: 'c1', limit: 5 });
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('days=3');
expect(url).toContain('cursor=c1');
expect(url).toContain('limit=5');
});
it('getWasteStats with no period', async () => {
mockGet.mockResolvedValue({ wastePercentage: 10 });
await getWasteStats('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats');
});
it('getWasteStats with period', async () => {
mockGet.mockResolvedValue({ wastePercentage: 10 });
await getWasteStats('hh1', 'week');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats?period=week');
});
it('deletePantryItem calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deletePantryItem('hh1', 'item1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/pantry/item1');
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from '../../src/services/api-client';
import * as PricesService from '../../src/services/prices';
vi.mock('../../src/services/api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('prices service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('recordPrice', async () => {
const data = { productId: 'p1', price: 10 } as any;
await PricesService.recordPrice('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices', data);
});
it('recordBulkPrices', async () => {
const data = { storeId: 's1', items: [] } as any;
await PricesService.recordBulkPrices('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices/bulk', data);
});
it('getPriceHistory with and without query', async () => {
await PricesService.getPriceHistory('hh1', 'p1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/history/p1');
await PricesService.getPriceHistory('hh1', 'p1', {
storeId: 's1',
startDate: '2026-05-01',
endDate: '2026-05-10',
cursor: 'cur',
limit: 10,
});
expect(apiClient.get).toHaveBeenCalledWith(
'/households/hh1/prices/history/p1?storeId=s1&startDate=2026-05-01&endDate=2026-05-10&cursor=cur&limit=10'
);
});
it('compareStores', async () => {
await PricesService.compareStores('hh1', 'p1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/compare/p1');
});
it('getPriceAnalytics', async () => {
await PricesService.getPriceAnalytics('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/analytics');
});
});

View file

@ -0,0 +1,163 @@
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');
});
});
});

View file

@ -0,0 +1,69 @@
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 {
listPurchases,
getPurchase,
createPurchase,
updatePurchase,
receivePurchase,
deletePurchase,
} from '../../src/services/purchases';
beforeEach(() => vi.clearAllMocks());
describe('purchases service', () => {
it('listPurchases with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPurchases('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/purchases');
});
it('listPurchases builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPurchases('hh1', { status: 'ordered', storeId: 'st-1', limit: 10 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=ordered'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('storeId=st-1'));
});
it('getPurchase calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'p-1' });
await getPurchase('hh1', 'p-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/purchases/p-1');
});
it('createPurchase calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'p-1' });
const data = { storeId: 'st-1', items: [] } as never;
await createPurchase('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/purchases', data);
});
it('updatePurchase calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'p-1' });
await updatePurchase('hh1', 'p-1', { notes: 'updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/purchases/p-1', { notes: 'updated' });
});
it('receivePurchase calls POST with empty body', async () => {
mockPost.mockResolvedValue({ addedCount: 1, priceRecordsCreated: 0 });
await receivePurchase('hh1', 'p-1');
expect(mockPost).toHaveBeenCalledWith('/households/hh1/purchases/p-1/receive', {});
});
it('deletePurchase calls DELETE', async () => {
mockDelete.mockResolvedValue({ _id: 'p-1' });
await deletePurchase('hh1', 'p-1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/purchases/p-1');
});
});

View file

@ -0,0 +1,118 @@
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 {
listRecipes,
getRecipe,
createRecipe,
updateRecipe,
deleteRecipe,
scaleRecipe,
importRecipeFromText,
importRecipeFromUrl,
listRecipesByProduct,
} from '../../src/services/recipes';
beforeEach(() => vi.clearAllMocks());
describe('recipes service', () => {
it('listRecipes with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipes('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes');
});
it('listRecipes builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipes('hh1', {
q: 'pasta',
tags: 'italian',
cuisine: 'Italian',
maxCalories: 500,
isFavorite: true,
cursor: 'cur1',
limit: 10,
});
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('q=pasta');
expect(url).toContain('tags=italian');
expect(url).toContain('cuisine=Italian');
expect(url).toContain('maxCalories=500');
expect(url).toContain('isFavorite=true');
expect(url).toContain('cursor=cur1');
expect(url).toContain('limit=10');
});
it('getRecipe calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'r1' });
await getRecipe('hh1', 'r1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/r1');
});
it('createRecipe calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'r1' });
const data = { name: 'Pasta' };
await createRecipe('hh1', data as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes', data);
});
it('updateRecipe calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'r1' });
await updateRecipe('hh1', 'r1', { name: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/recipes/r1', { name: 'Updated' });
});
it('deleteRecipe calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteRecipe('hh1', 'r1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/recipes/r1');
});
it('scaleRecipe calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'r1' });
await scaleRecipe('hh1', 'r1', { targetServings: 8 });
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/r1/scale', {
targetServings: 8,
});
});
it('importRecipeFromText calls POST', async () => {
mockPost.mockResolvedValue({ available: false });
await importRecipeFromText('hh1', { text: 'recipe text' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-text', {
text: 'recipe text',
});
});
it('importRecipeFromUrl calls POST', async () => {
mockPost.mockResolvedValue({ available: false });
await importRecipeFromUrl('hh1', { url: 'http://example.com' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-url', {
url: 'http://example.com',
});
});
it('listRecipesByProduct with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipesByProduct('hh1', 'p1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/by-product/p1');
});
it('listRecipesByProduct with query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipesByProduct('hh1', 'p1', { cursor: 'c1', limit: 5 });
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('cursor=c1');
expect(url).toContain('limit=5');
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost, mockPatch } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPatch: vi.fn(),
}));
vi.mock('../../src/services/api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
}));
import {
getRefillAlerts,
listRefillLists,
createRefillList,
getRefillList,
updateRefillList,
updateRefillListItem,
addToCabinet,
} from '../../src/services/refills';
beforeEach(() => vi.clearAllMocks());
describe('refills service', () => {
it('getRefillAlerts with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await getRefillAlerts('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/refills/alerts');
});
it('getRefillAlerts builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await getRefillAlerts('hh1', { thresholdDays: 14, userId: 'u-1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('thresholdDays=14'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('userId=u-1'));
});
it('listRefillLists with query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRefillLists('hh1', { status: 'active' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=active'));
});
it('listRefillLists with cursor and limit', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRefillLists('hh1', { cursor: 'cur1', limit: 25 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=25'));
});
it('createRefillList calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'rl-1' });
const data = { name: 'Weekly' } as never;
await createRefillList('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/refills/lists', data);
});
it('getRefillList calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'rl-1' });
await getRefillList('hh1', 'rl-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1');
});
it('updateRefillList calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'rl-1' });
await updateRefillList('hh1', 'rl-1', { name: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1', {
name: 'Updated',
});
});
it('updateRefillListItem calls PATCH with nested path', async () => {
mockPatch.mockResolvedValue({ _id: 'rl-1' });
await updateRefillListItem('hh1', 'rl-1', 'item-1', { purchased: true } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1/items/item-1', {
purchased: true,
});
});
it('addToCabinet calls POST with empty body', async () => {
mockPost.mockResolvedValue({});
await addToCabinet('hh1', 'rl-1');
expect(mockPost).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1/add-to-cabinet', {});
});
});

View file

@ -0,0 +1,75 @@
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 {
listRegimens,
getRegimen,
getBurnRates,
createRegimen,
updateRegimen,
deleteRegimen,
} from '../../src/services/regimens';
beforeEach(() => vi.clearAllMocks());
describe('regimens service', () => {
it('listRegimens with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRegimens('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/regimens');
});
it('listRegimens builds query string with isActive', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRegimens('hh1', { isActive: true, limit: 10 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('isActive=true'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=10'));
});
it('listRegimens with cursor', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRegimens('hh1', { cursor: 'cur1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
});
it('getRegimen calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'reg-1' });
await getRegimen('hh1', 'reg-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/regimens/reg-1');
});
it('getBurnRates calls GET', async () => {
mockGet.mockResolvedValue({});
await getBurnRates('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/regimens/burn-rate');
});
it('createRegimen calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'reg-1' });
const data = { name: 'Morning' } as never;
await createRegimen('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/regimens', data);
});
it('updateRegimen calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'reg-1' });
await updateRegimen('hh1', 'reg-1', { name: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/regimens/reg-1', { name: 'Updated' });
});
it('deleteRegimen calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteRegimen('hh1', 'reg-1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/regimens/reg-1');
});
});

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from '../../src/services/api-client';
import * as ShoppingListsService from '../../src/services/shopping-lists';
vi.mock('../../src/services/api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
baseUrl: 'http://localhost:3001',
},
}));
describe('shopping-lists service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('getShoppingLists', async () => {
await ShoppingListsService.getShoppingLists('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists');
});
it('getShoppingList', async () => {
await ShoppingListsService.getShoppingList('hh1', 'list1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
});
it('createShoppingList', async () => {
const data = { name: 'Test' } as any;
await ShoppingListsService.createShoppingList('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists', data);
});
it('updateShoppingList', async () => {
const data = { name: 'Updated' } as any;
await ShoppingListsService.updateShoppingList('hh1', 'list1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1', data);
});
it('deleteShoppingList', async () => {
await ShoppingListsService.deleteShoppingList('hh1', 'list1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
});
it('addShoppingItem', async () => {
const data = { productId: 'p1' } as any;
await ShoppingListsService.addShoppingItem('hh1', 'list1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items', data);
});
it('updateShoppingItem', async () => {
const data = { checked: true } as any;
await ShoppingListsService.updateShoppingItem('hh1', 'list1', 'item1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1', data);
});
it('removeShoppingItem', async () => {
await ShoppingListsService.removeShoppingItem('hh1', 'list1', 'item1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1');
});
it('generateFromMealPlan', async () => {
await ShoppingListsService.generateFromMealPlan('hh1', 'mp1');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/from-meal-plan/mp1');
});
it('syncToPantry', async () => {
await ShoppingListsService.syncToPantry('hh1', 'list1');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/sync-to-pantry');
});
it('getBasketStoreComparison', async () => {
await ShoppingListsService.getBasketStoreComparison('hh1', 'list1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/stores');
});
it('getShoppingListSyncSocketUrl handles insecure and secure contexts', () => {
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
apiClient.baseUrl = 'https://api.meshitrack.com';
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');
});
});

View file

@ -0,0 +1,56 @@
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 { listStores, getStore, createStore, updateStore, deactivateStore } from '../../src/services/stores';
beforeEach(() => vi.clearAllMocks());
describe('stores service', () => {
it('listStores calls GET with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listStores('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/stores');
});
it('listStores builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listStores('hh1', { tags: 'pharmacy', search: 'cvs', cursor: 'c1', limit: 5 });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('tags=pharmacy'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('search=cvs'));
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
});
it('getStore calls GET with id', async () => {
mockGet.mockResolvedValue({ _id: 'st-1' });
await getStore('hh1', 'st-1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/stores/st-1');
});
it('createStore calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'st-1' });
await createStore('hh1', { name: 'CVS' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/stores', { name: 'CVS' });
});
it('updateStore calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'st-1' });
await updateStore('hh1', 'st-1', { name: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/stores/st-1', { name: 'Updated' });
});
it('deactivateStore calls DELETE', async () => {
mockDelete.mockResolvedValue({ _id: 'st-1' });
await deactivateStore('hh1', 'st-1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/stores/st-1');
});
});