Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Create a fresh ApiClient for each test by re-importing
let apiClient: typeof import('../api-client').apiClient;
beforeEach(async () => {
vi.restoreAllMocks();
// Reset module to get a fresh singleton
vi.resetModules();
const mod = await import('../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,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet } = vi.hoisted(() => ({
mockGet: vi.fn(),
}));
vi.mock('../api-client', () => ({
apiClient: { get: mockGet },
}));
import { listCabinetEvents, getEventsByItem, getSpendingSummary } from '../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,91 @@
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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import {
listCabinetItems, getCabinetItem, getCabinetSummary, getExpiringSoon,
createCabinetItem, updateCabinetItem, adjustCabinetItemQuantity, deleteCabinetItem,
} from '../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,47 @@
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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
}));
import { createHousehold, getHousehold, updateHousehold, generateInviteCode, joinHousehold } from '../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,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
}));
vi.mock('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost },
}));
import { recordPrice, getPriceHistory, compareStores, getPriceAnalytics } from '../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,94 @@
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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import {
listMedicines, getMedicine, createMedicine, updateMedicine, deleteMedicine,
listMedicineProducts, createMedicineProduct, updateMedicineProduct, deleteMedicineProduct,
} from '../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,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
}));
vi.mock('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost },
}));
import { listFills, getFill, previewFill, executeFill, undoFill } from '../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,62 @@
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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import { listPurchases, getPurchase, createPurchase, updatePurchase, receivePurchase, deletePurchase } from '../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,80 @@
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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
}));
import {
getRefillAlerts, listRefillLists, createRefillList, getRefillList,
updateRefillList, updateRefillListItem, addToCabinet,
} from '../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,68 @@
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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import { listRegimens, getRegimen, getBurnRates, createRegimen, updateRegimen, deleteRegimen } from '../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,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('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import { listStores, getStore, createStore, updateStore, deactivateStore } from '../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');
});
});

View file

@ -0,0 +1,60 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
MedicinePriceRecordResponseSchema,
MedicinePriceHistoryResponseSchema,
StoreComparisonResponseSchema,
MedicineSpendingAnalyticsResponseSchema,
CreateMedicinePriceRecordSchema,
} from '@meshitrack/shared';
type MedicinePriceRecordResponse = z.infer<typeof MedicinePriceRecordResponseSchema>;
type MedicinePriceHistoryResponse = z.infer<typeof MedicinePriceHistoryResponseSchema>;
type StoreComparisonResponse = z.infer<typeof StoreComparisonResponseSchema>;
type MedicineSpendingAnalyticsResponse = z.infer<typeof MedicineSpendingAnalyticsResponseSchema>;
type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;
export async function recordPrice(
householdId: string,
data: CreateMedicinePriceRecordInput,
): Promise<MedicinePriceRecordResponse> {
return apiClient.post<MedicinePriceRecordResponse>(
`/households/${householdId}/medicine-prices`,
data,
);
}
export async function getPriceHistory(
householdId: string,
medicineId: string,
query?: { storeId?: string; startDate?: string; endDate?: string; cursor?: string; limit?: number },
): Promise<MedicinePriceHistoryResponse> {
const params = new URLSearchParams();
if (query?.storeId) params.set('storeId', query.storeId);
if (query?.startDate) params.set('startDate', query.startDate);
if (query?.endDate) params.set('endDate', query.endDate);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<MedicinePriceHistoryResponse>(
`/households/${householdId}/medicine-prices/history/${medicineId}${qs ? `?${qs}` : ''}`,
);
}
export async function compareStores(
householdId: string,
medicineId: string,
): Promise<StoreComparisonResponse> {
return apiClient.get<StoreComparisonResponse>(
`/households/${householdId}/medicine-prices/compare/${medicineId}`,
);
}
export async function getPriceAnalytics(
householdId: string,
period: 'month' | 'quarter' | 'year' = 'month',
): Promise<MedicineSpendingAnalyticsResponse> {
return apiClient.get<MedicineSpendingAnalyticsResponse>(
`/households/${householdId}/medicine-prices/analytics?period=${period}`,
);
}

View file

@ -0,0 +1,69 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
PurchaseResponseSchema,
PurchaseListResponseSchema,
CreatePurchaseSchema,
UpdatePurchaseSchema,
PurchaseQuerySchema,
} from '@meshitrack/shared';
type PurchaseResponse = z.infer<typeof PurchaseResponseSchema>;
type PurchaseListResponse = z.infer<typeof PurchaseListResponseSchema>;
type CreatePurchaseInput = z.infer<typeof CreatePurchaseSchema>;
type UpdatePurchaseInput = z.infer<typeof UpdatePurchaseSchema>;
type PurchaseQueryInput = z.infer<typeof PurchaseQuerySchema>;
export async function listPurchases(
householdId: string,
query?: Partial<PurchaseQueryInput>,
): Promise<PurchaseListResponse> {
const params = new URLSearchParams();
if (query?.status) params.set('status', query.status);
if (query?.storeId) params.set('storeId', query.storeId);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<PurchaseListResponse>(
`/households/${householdId}/purchases${qs ? `?${qs}` : ''}`,
);
}
export async function getPurchase(
householdId: string,
id: string,
): Promise<PurchaseResponse> {
return apiClient.get<PurchaseResponse>(`/households/${householdId}/purchases/${id}`);
}
export async function createPurchase(
householdId: string,
data: CreatePurchaseInput,
): Promise<PurchaseResponse> {
return apiClient.post<PurchaseResponse>(`/households/${householdId}/purchases`, data);
}
export async function updatePurchase(
householdId: string,
id: string,
data: UpdatePurchaseInput,
): Promise<PurchaseResponse> {
return apiClient.patch<PurchaseResponse>(
`/households/${householdId}/purchases/${id}`,
data,
);
}
export async function receivePurchase(
householdId: string,
id: string,
): Promise<{ addedCount: number; priceRecordsCreated: number }> {
return apiClient.post<{ addedCount: number; priceRecordsCreated: number }>(
`/households/${householdId}/purchases/${id}/receive`,
{},
);
}
export async function deletePurchase(householdId: string, id: string): Promise<PurchaseResponse> {
return apiClient.delete<PurchaseResponse>(`/households/${householdId}/purchases/${id}`);
}

View file

@ -0,0 +1,96 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
RefillAlertResponseSchema,
RefillListResponseSchema,
RefillListListResponseSchema,
AddToCabinetResponseSchema,
CreateRefillListSchema,
UpdateRefillListSchema,
UpdateRefillListItemSchema,
} from '@meshitrack/shared';
type RefillAlertResponse = z.infer<typeof RefillAlertResponseSchema>;
type RefillListResponse = z.infer<typeof RefillListResponseSchema>;
type RefillListListResponse = z.infer<typeof RefillListListResponseSchema>;
type AddToCabinetResponse = z.infer<typeof AddToCabinetResponseSchema>;
type CreateRefillListInput = z.infer<typeof CreateRefillListSchema>;
type UpdateRefillListInput = z.infer<typeof UpdateRefillListSchema>;
type UpdateRefillListItemInput = z.infer<typeof UpdateRefillListItemSchema>;
export async function getRefillAlerts(
householdId: string,
query?: { thresholdDays?: number; userId?: string },
): Promise<{ data: RefillAlertResponse[] }> {
const params = new URLSearchParams();
if (query?.thresholdDays) params.set('thresholdDays', String(query.thresholdDays));
if (query?.userId) params.set('userId', query.userId);
const qs = params.toString();
return apiClient.get<{ data: RefillAlertResponse[] }>(
`/households/${householdId}/refills/alerts${qs ? `?${qs}` : ''}`,
);
}
export async function listRefillLists(
householdId: string,
query?: { status?: string; cursor?: string; limit?: number },
): Promise<RefillListListResponse> {
const params = new URLSearchParams();
if (query?.status) params.set('status', query.status);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<RefillListListResponse>(
`/households/${householdId}/refills/lists${qs ? `?${qs}` : ''}`,
);
}
export async function createRefillList(
householdId: string,
data: CreateRefillListInput,
): Promise<RefillListResponse> {
return apiClient.post<RefillListResponse>(
`/households/${householdId}/refills/lists`,
data,
);
}
export async function getRefillList(
householdId: string,
id: string,
): Promise<RefillListResponse> {
return apiClient.get<RefillListResponse>(`/households/${householdId}/refills/lists/${id}`);
}
export async function updateRefillList(
householdId: string,
id: string,
data: UpdateRefillListInput,
): Promise<RefillListResponse> {
return apiClient.patch<RefillListResponse>(
`/households/${householdId}/refills/lists/${id}`,
data,
);
}
export async function updateRefillListItem(
householdId: string,
listId: string,
itemId: string,
data: UpdateRefillListItemInput,
): Promise<RefillListResponse> {
return apiClient.patch<RefillListResponse>(
`/households/${householdId}/refills/lists/${listId}/items/${itemId}`,
data,
);
}
export async function addToCabinet(
householdId: string,
listId: string,
): Promise<AddToCabinetResponse> {
return apiClient.post<AddToCabinetResponse>(
`/households/${householdId}/refills/lists/${listId}/add-to-cabinet`,
{},
);
}

View file

@ -0,0 +1,51 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
StoreResponseSchema,
StoreListResponseSchema,
CreateStoreSchema,
UpdateStoreSchema,
} from '@meshitrack/shared';
type StoreResponse = z.infer<typeof StoreResponseSchema>;
type StoreListResponse = z.infer<typeof StoreListResponseSchema>;
type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
export async function listStores(
householdId: string,
query?: { tags?: string; search?: string; cursor?: string; limit?: number },
): Promise<StoreListResponse> {
const params = new URLSearchParams();
if (query?.tags) params.set('tags', query.tags);
if (query?.search) params.set('search', query.search);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<StoreListResponse>(
`/households/${householdId}/stores${qs ? `?${qs}` : ''}`,
);
}
export async function getStore(householdId: string, id: string): Promise<StoreResponse> {
return apiClient.get<StoreResponse>(`/households/${householdId}/stores/${id}`);
}
export async function createStore(
householdId: string,
data: CreateStoreInput,
): Promise<StoreResponse> {
return apiClient.post<StoreResponse>(`/households/${householdId}/stores`, data);
}
export async function updateStore(
householdId: string,
id: string,
data: UpdateStoreInput,
): Promise<StoreResponse> {
return apiClient.patch<StoreResponse>(`/households/${householdId}/stores/${id}`, data);
}
export async function deactivateStore(householdId: string, id: string): Promise<StoreResponse> {
return apiClient.delete<StoreResponse>(`/households/${householdId}/stores/${id}`);
}