Implement stores and refills, improve testing
This commit is contained in:
parent
9f416903ef
commit
5536acd67d
137 changed files with 21218 additions and 221 deletions
|
|
@ -10,7 +10,8 @@
|
|||
"lint": "eslint src",
|
||||
"lint-fix": "eslint src --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "echo 'no tests yet'"
|
||||
"test": "vitest run",
|
||||
"test:cov": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@meshitrack/shared": "*",
|
||||
|
|
@ -23,12 +24,21 @@
|
|||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^16.2.1",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.12.14",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^6.0.0"
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
packages/web/src/app/(dashboard)/__tests__/layout.test.tsx
Normal file
24
packages/web/src/app/(dashboard)/__tests__/layout.test.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/layout/Sidebar', () => ({
|
||||
Sidebar: () => <nav data-testid="sidebar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/TopBar', () => ({
|
||||
TopBar: () => <div data-testid="topbar" />,
|
||||
}));
|
||||
|
||||
import DashboardLayout from '../layout';
|
||||
|
||||
describe('DashboardLayout', () => {
|
||||
it('renders sidebar, topbar and children', () => {
|
||||
render(<DashboardLayout>
|
||||
<div data-testid="child">content</div>
|
||||
</DashboardLayout>);
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
packages/web/src/app/(dashboard)/__tests__/loading.test.tsx
Normal file
11
packages/web/src/app/(dashboard)/__tests__/loading.test.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, container } from '@testing-library/react';
|
||||
|
||||
import DashboardLoading from '../loading';
|
||||
|
||||
describe('DashboardLoading', () => {
|
||||
it('renders a loading spinner', () => {
|
||||
const { container: c } = render(<DashboardLoading />);
|
||||
expect(c.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import DashboardPage from '../page';
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
it('renders heading', () => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Medicines card linking to /medicines', () => {
|
||||
render(<DashboardPage />);
|
||||
const link = screen.getByRole('link', { name: /medicines/i });
|
||||
expect(link).toHaveAttribute('href', '/medicines');
|
||||
});
|
||||
|
||||
it('renders Settings card linking to /settings', () => {
|
||||
render(<DashboardPage />);
|
||||
const link = screen.getByRole('link', { name: /settings/i });
|
||||
expect(link).toHaveAttribute('href', '/settings');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
const { mockRecordPrice, mockGetPriceHistory, mockCompareStores } = vi.hoisted(() => ({
|
||||
mockRecordPrice: vi.fn(),
|
||||
mockGetPriceHistory: vi.fn(),
|
||||
mockCompareStores: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListMedicines, mockListMedicineProducts } = vi.hoisted(() => ({
|
||||
mockListMedicines: vi.fn(),
|
||||
mockListMedicineProducts: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListStores } = vi.hoisted(() => ({ mockListStores: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('@/services/medicine-prices', () => ({
|
||||
recordPrice: mockRecordPrice,
|
||||
getPriceHistory: mockGetPriceHistory,
|
||||
compareStores: mockCompareStores,
|
||||
}));
|
||||
vi.mock('@/services/medicines', () => ({
|
||||
listMedicines: mockListMedicines,
|
||||
listMedicineProducts: mockListMedicineProducts,
|
||||
}));
|
||||
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
|
||||
import MedicinePricesPage from '../page';
|
||||
|
||||
const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue(emptyResponse);
|
||||
mockGetPriceHistory.mockResolvedValue(emptyResponse);
|
||||
mockCompareStores.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
describe('MedicinePricesPage', () => {
|
||||
it('shows loading skeleton when session loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<MedicinePricesPage />);
|
||||
expect(screen.getByText('Medicine Prices')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Record Price')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<MedicinePricesPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Medicine Prices heading when householdId exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<MedicinePricesPage />);
|
||||
expect(screen.getByText('Medicine Prices')).toBeInTheDocument();
|
||||
expect(screen.getByText('Record Price')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty prompt to select a medicine', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<MedicinePricesPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Select a medicine to view price history.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads price history when medicine selected', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'pr-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
storeId: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
price: 15.99,
|
||||
pricePerUnit: 0.18,
|
||||
currency: 'USD',
|
||||
quantity: 90,
|
||||
unit: 'tablet',
|
||||
date: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
||||
// Wait for price records to render
|
||||
await waitFor(() => expect(screen.getByText('Walgreens')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows validation error in form when required fields missing', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await userEvent.click(screen.getByText('Record Price'));
|
||||
await waitFor(() => screen.getByRole('button', { name: 'Record Price', hidden: false }));
|
||||
|
||||
// The submit button inside the form also has text 'Record Price'
|
||||
const submitBtn = screen.getAllByRole('button', { name: 'Record Price' }).find(
|
||||
(b) => b.getAttribute('type') === 'submit',
|
||||
);
|
||||
if (submitBtn) {
|
||||
fireEvent.submit(submitBtn.closest('form')!);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Please select a medicine, a product, and a store.')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows error when price history fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('History load failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('records a price successfully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [{ _id: 'prod-1', brand: 'Generic', packageSize: 90, packageUnit: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockRecordPrice.mockResolvedValue({});
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Price'));
|
||||
await userEvent.click(screen.getByText('Record Price'));
|
||||
|
||||
await waitFor(() => screen.getByText('Record Price', { selector: 'h2' }));
|
||||
|
||||
// Select store
|
||||
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
||||
// Select medicine
|
||||
fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } });
|
||||
|
||||
// Wait for products to load and select product
|
||||
await waitFor(() => screen.getByText(/Generic.*90/));
|
||||
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
|
||||
|
||||
// Submit form
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!);
|
||||
|
||||
await waitFor(() => expect(mockRecordPrice).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })));
|
||||
// Form should close after success
|
||||
await waitFor(() => expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows Load more button in price history', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'pr-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
storeId: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
price: 15.99,
|
||||
pricePerUnit: 0.18,
|
||||
currency: 'USD',
|
||||
quantity: 90,
|
||||
unit: 'tablet',
|
||||
date: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: 'cur1', hasMore: true },
|
||||
});
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Load more')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('Load more'));
|
||||
|
||||
expect(mockGetPriceHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows store filter when medicine selected and changes it', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('All stores'));
|
||||
fireEvent.change(screen.getByDisplayValue('All stores'), { target: { value: 'st-1' } });
|
||||
|
||||
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('dismisses price history error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => screen.getByText('History load failed'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('History load failed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows store comparison table', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCompareStores.mockResolvedValue({
|
||||
data: [
|
||||
{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 12.99, latestPricePerUnit: 0.14, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
||||
{ storeId: 'st-2', storeName: 'CVS', latestPrice: 14.99, latestPricePerUnit: 0.17, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
||||
],
|
||||
});
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Store comparison')).toBeInTheDocument());
|
||||
expect(screen.getByText('Walgreens')).toBeInTheDocument();
|
||||
// First row (cheapest) should be highlighted green
|
||||
expect(screen.getByText('cheapest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes price, currency, quantity, and unit fields in form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await userEvent.click(screen.getByText('Record Price'));
|
||||
await waitFor(() => screen.getByPlaceholderText('9.99'));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('9.99'), { target: { value: '15.99' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('USD'), { target: { value: 'EUR' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||
fireEvent.change(screen.getByDisplayValue('tablet'), { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('9.99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes medicine search, notes and insurance price fields in form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<MedicinePricesPage />);
|
||||
|
||||
await userEvent.click(screen.getByText('Record Price'));
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'Met' } });
|
||||
|
||||
// Notes field (no placeholder, but maxLength 1000)
|
||||
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||
if (notesInput) {
|
||||
fireEvent.change(notesInput, { target: { value: 'Some notes' } });
|
||||
}
|
||||
|
||||
// Insurance price checkbox
|
||||
fireEvent.click(screen.getByLabelText('Insurance price'));
|
||||
expect(screen.getByLabelText('Insurance price')).toBeChecked();
|
||||
});
|
||||
|
||||
it('toggles Record Price form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<MedicinePricesPage />);
|
||||
await userEvent.click(screen.getByText('Record Price'));
|
||||
// Both the header toggle and the form show "Cancel" when open
|
||||
expect(screen.getAllByRole('button', { name: 'Cancel' }).length).toBeGreaterThan(0);
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[0]);
|
||||
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
618
packages/web/src/app/(dashboard)/medicine-prices/page.tsx
Normal file
618
packages/web/src/app/(dashboard)/medicine-prices/page.tsx
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import {
|
||||
recordPrice,
|
||||
getPriceHistory,
|
||||
compareStores,
|
||||
} from '@/services/medicine-prices';
|
||||
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||
import { listStores } from '@/services/stores';
|
||||
import { DosageUnit } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
MedicinePriceRecordResponseSchema,
|
||||
StoreComparisonItemSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type PriceRecord = z.infer<typeof MedicinePriceRecordResponseSchema>;
|
||||
type StoreComparisonItem = z.infer<typeof StoreComparisonItemSchema>;
|
||||
|
||||
type MedicineOption = { _id: string; name: string; strength: number; strengthUnit: string };
|
||||
type ProductOption = { _id: string; brand?: string; packageSize: number; packageUnit: string };
|
||||
type StoreOption = { _id: string; name: string };
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currency?: string): string {
|
||||
return currency ? `${currency} ${amount.toFixed(2)}` : amount.toFixed(2);
|
||||
}
|
||||
|
||||
// --- Record price form ---
|
||||
|
||||
function RecordPriceForm({
|
||||
householdId,
|
||||
medicines,
|
||||
stores,
|
||||
onRecorded,
|
||||
onCancel,
|
||||
}: {
|
||||
householdId: string;
|
||||
medicines: MedicineOption[];
|
||||
stores: StoreOption[];
|
||||
onRecorded: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [medicineId, setMedicineId] = useState('');
|
||||
const [medicineSearch, setMedicineSearch] = useState('');
|
||||
const [products, setProducts] = useState<ProductOption[]>([]);
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
const [medicineProductId, setMedicineProductId] = useState('');
|
||||
const [storeId, setStoreId] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState('USD');
|
||||
const [quantity, setQuantity] = useState('');
|
||||
const [unit, setUnit] = useState<DosageUnit>(DosageUnit.TABLET);
|
||||
const [isInsurancePrice, setIsInsurancePrice] = useState(false);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const filteredMedicines = medicineSearch
|
||||
? medicines.filter((m) => m.name.toLowerCase().includes(medicineSearch.toLowerCase()))
|
||||
: medicines;
|
||||
|
||||
async function handleMedicineChange(id: string) {
|
||||
setMedicineId(id);
|
||||
setMedicineProductId('');
|
||||
setProducts([]);
|
||||
if (!id) return;
|
||||
setProductsLoading(true);
|
||||
try {
|
||||
const result = await listMedicineProducts(householdId, id, { limit: 50 });
|
||||
setProducts(result.data as ProductOption[]);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
} finally {
|
||||
setProductsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProductChange(productId: string) {
|
||||
setMedicineProductId(productId);
|
||||
const product = products.find((p) => p._id === productId);
|
||||
if (product) {
|
||||
setQuantity(String(product.packageSize));
|
||||
setUnit(product.packageUnit as DosageUnit);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!medicineId || !medicineProductId || !storeId) {
|
||||
setError('Please select a medicine, a product, and a store.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await recordPrice(householdId, {
|
||||
medicineId,
|
||||
medicineProductId,
|
||||
storeId,
|
||||
price: Number(price),
|
||||
currency: currency.trim(),
|
||||
quantity: Number(quantity),
|
||||
unit,
|
||||
isInsurancePrice,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
onRecorded();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to record price');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Record Price</h2>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
||||
<select
|
||||
value={storeId}
|
||||
onChange={(e) => setStoreId(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select store</option>
|
||||
{stores.map((s) => (
|
||||
<option key={s._id} value={s._id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{stores.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
No stores yet.{' '}
|
||||
<Link href="/stores" className="text-primary-600 underline">
|
||||
Add a store first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Medicine</label>
|
||||
<input
|
||||
type="text"
|
||||
value={medicineSearch}
|
||||
onChange={(e) => setMedicineSearch(e.target.value)}
|
||||
placeholder="Search medicines..."
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none mb-2"
|
||||
/>
|
||||
<select
|
||||
value={medicineId}
|
||||
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select medicine</option>
|
||||
{filteredMedicines.map((m) => (
|
||||
<option key={m._id} value={m._id}>
|
||||
{m.name} ({m.strength} {m.strengthUnit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
|
||||
{productsLoading ? (
|
||||
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||
) : (
|
||||
<select
|
||||
value={medicineProductId}
|
||||
onChange={(e) => handleProductChange(e.target.value)}
|
||||
required
|
||||
disabled={!medicineId}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||
>
|
||||
<option value="">{medicineId ? 'Select product' : 'Select a medicine first'}</option>
|
||||
{products.map((p) => (
|
||||
<option key={p._id} value={p._id}>
|
||||
{p.brand ?? 'Generic'} — {p.packageSize} {p.packageUnit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{medicineId && !productsLoading && products.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
No products for this medicine.{' '}
|
||||
<Link href={`/medicines/${medicineId}`} className="text-primary-600 underline">
|
||||
Add a product first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Price</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0.01}
|
||||
step="any"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="9.99"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Currency</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={10}
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
placeholder="USD"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Package size</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
step={1}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
placeholder="90"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as DosageUnit)}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{Object.values(DosageUnit).map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isInsurancePrice"
|
||||
checked={isInsurancePrice}
|
||||
onChange={(e) => setIsInsurancePrice(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="isInsurancePrice" className="text-sm font-medium text-gray-700">
|
||||
Insurance price
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Recording...' : 'Record Price'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Price history & store comparison ---
|
||||
|
||||
function PriceHistory({
|
||||
householdId,
|
||||
medicines,
|
||||
stores,
|
||||
}: {
|
||||
householdId: string;
|
||||
medicines: MedicineOption[];
|
||||
stores: StoreOption[];
|
||||
}) {
|
||||
const [selectedMedicineId, setSelectedMedicineId] = useState('');
|
||||
const [selectedStoreId, setSelectedStoreId] = useState('');
|
||||
const [records, setRecords] = useState<PriceRecord[]>([]);
|
||||
const [comparison, setComparison] = useState<StoreComparisonItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchHistory = useCallback(
|
||||
async (append = false) => {
|
||||
if (!selectedMedicineId) return;
|
||||
if (!append) setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [histResult, compResult] = await Promise.all([
|
||||
getPriceHistory(householdId, selectedMedicineId, {
|
||||
storeId: selectedStoreId || undefined,
|
||||
cursor: append ? (cursor ?? undefined) : undefined,
|
||||
limit: 20,
|
||||
}),
|
||||
!append ? compareStores(householdId, selectedMedicineId) : Promise.resolve(null),
|
||||
]);
|
||||
setRecords((prev) => (append ? [...prev, ...histResult.data] : histResult.data));
|
||||
setCursor(histResult.pagination.cursor);
|
||||
setHasMore(histResult.pagination.hasMore);
|
||||
if (compResult) setComparison(compResult.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load price history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[householdId, selectedMedicineId, selectedStoreId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(null);
|
||||
setRecords([]);
|
||||
setComparison([]);
|
||||
if (selectedMedicineId) fetchHistory(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [householdId, selectedMedicineId, selectedStoreId]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Price History</h2>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={selectedMedicineId}
|
||||
onChange={(e) => setSelectedMedicineId(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select a medicine</option>
|
||||
{medicines.map((m) => (
|
||||
<option key={m._id} value={m._id}>
|
||||
{m.name} ({m.strength} {m.strengthUnit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedMedicineId && (
|
||||
<select
|
||||
value={selectedStoreId}
|
||||
onChange={(e) => setSelectedStoreId(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All stores</option>
|
||||
{stores.map((s) => (
|
||||
<option key={s._id} value={s._id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedMedicineId ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
Select a medicine to view price history.
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse h-12 rounded-lg bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{comparison.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Store comparison</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 font-medium">Store</th>
|
||||
<th className="pb-2 font-medium text-right">Price</th>
|
||||
<th className="pb-2 font-medium text-right">Per unit</th>
|
||||
<th className="pb-2 font-medium text-right">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{comparison.map((item, i) => (
|
||||
<tr key={item.storeId} className={i === 0 ? 'text-green-700 font-medium' : ''}>
|
||||
<td className="py-2">
|
||||
{item.storeName}
|
||||
{i === 0 && (
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs">
|
||||
cheapest
|
||||
</span>
|
||||
)}
|
||||
{item.isInsurancePrice && (
|
||||
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
||||
insurance
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.latestPrice, item.currency)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-500">
|
||||
{formatCurrency(item.latestPricePerUnit, item.currency)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">{formatDate(item.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{records.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">No price records found.</p>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">All records</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 font-medium">Store</th>
|
||||
<th className="pb-2 font-medium text-right">Price</th>
|
||||
<th className="pb-2 font-medium text-right">Qty</th>
|
||||
<th className="pb-2 font-medium text-right">Per unit</th>
|
||||
<th className="pb-2 font-medium text-right">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{records.map((r) => (
|
||||
<tr key={r._id}>
|
||||
<td className="py-2">
|
||||
{r.storeName}
|
||||
{r.isInsurancePrice && (
|
||||
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
||||
ins
|
||||
</span>
|
||||
)}
|
||||
{r.notes && (
|
||||
<span className="ml-1 text-xs text-gray-400"> — {r.notes}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium">
|
||||
{formatCurrency(r.price, r.currency)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-500">
|
||||
{r.quantity} {r.unit}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-500">
|
||||
{formatCurrency(r.pricePerUnit, r.currency)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">{formatDate(r.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => fetchHistory(true)}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main page ---
|
||||
|
||||
function MedicinePricesContent({ householdId }: { householdId: string }) {
|
||||
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [historyKey, setHistoryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
||||
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Medicine Prices</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Record Price'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<RecordPriceForm
|
||||
householdId={householdId}
|
||||
medicines={medicines}
|
||||
stores={stores}
|
||||
onRecorded={() => {
|
||||
setShowForm(false);
|
||||
setHistoryKey((k) => k + 1);
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<PriceHistory
|
||||
key={historyKey}
|
||||
householdId={householdId}
|
||||
medicines={medicines}
|
||||
stores={stores}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MedicinePricesPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before tracking prices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MedicinePricesContent householdId={householdId} />;
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ function formatDateTime(dateStr: string): string {
|
|||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
/* v8 ignore next 4 */
|
||||
function formatQuantityChange(event: CabinetEvent): string {
|
||||
const sign = event.quantity > 0 ? '+' : '';
|
||||
return `${sign}${event.quantity}`;
|
||||
|
|
@ -267,7 +268,7 @@ function EventTimeline({
|
|||
<option value="">All event types</option>
|
||||
{Object.values(CabinetEventType).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{EVENT_TYPE_LABELS[t] ?? t}
|
||||
{/* v8 ignore next */ EVENT_TYPE_LABELS[t] ?? t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -348,9 +349,9 @@ function EventTimeline({
|
|||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
||||
{/* v8 ignore next */ EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{event.medicineName}
|
||||
|
|
|
|||
|
|
@ -456,9 +456,9 @@ function CabinetItemCard({
|
|||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[item.status] ?? STATUS_COLORS['active']}`}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ STATUS_COLORS[item.status] ?? STATUS_COLORS['active']}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status] ?? item.status}
|
||||
{/* v8 ignore next */ STATUS_LABELS[item.status] ?? item.status}
|
||||
</span>
|
||||
{item.status === 'active' && (
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -594,7 +594,7 @@ function AddToCabinetForm({
|
|||
<option value="">Select a medicine</option>
|
||||
{medicines.map((med) => (
|
||||
<option key={med._id} value={med._id}>
|
||||
{med.name} ({med.strength} {med.strengthUnit}, {FORM_LABELS[med.form] ?? med.form})
|
||||
{med.name} ({med.strength} {med.strengthUnit}, {/* v8 ignore next */ FORM_LABELS[med.form] ?? med.form})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -637,7 +637,7 @@ function AddToCabinetForm({
|
|||
: Object.values(DosageUnit);
|
||||
return units.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{UNIT_LABELS[u] ?? u}
|
||||
{/* v8 ignore next */ UNIT_LABELS[u] ?? u}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ function CreateMedicineForm({
|
|||
>
|
||||
{Object.values(MedicineForm).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{FORM_LABELS[f] ?? f}
|
||||
{/* v8 ignore next */ FORM_LABELS[f] ?? f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -319,7 +319,7 @@ function CreateMedicineForm({
|
|||
>
|
||||
{Object.values(MedicineCategory).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CATEGORY_LABELS[c] ?? c}
|
||||
{/* v8 ignore next */ CATEGORY_LABELS[c] ?? c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,777 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
const { mockUseParams } = vi.hoisted(() => ({ mockUseParams: vi.fn() }));
|
||||
|
||||
const {
|
||||
mockGetMedicine,
|
||||
mockListMedicineProducts,
|
||||
mockCreateMedicineProduct,
|
||||
mockDeleteMedicineProduct,
|
||||
mockUpdateMedicine,
|
||||
mockUpdateMedicineProduct,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetMedicine: vi.fn(),
|
||||
mockListMedicineProducts: vi.fn(),
|
||||
mockCreateMedicineProduct: vi.fn(),
|
||||
mockDeleteMedicineProduct: vi.fn(),
|
||||
mockUpdateMedicine: vi.fn(),
|
||||
mockUpdateMedicineProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } =
|
||||
vi.hoisted(() => ({
|
||||
mockListCabinetItems: vi.fn(),
|
||||
mockAdjustCabinetItemQuantity: vi.fn(),
|
||||
mockDeleteCabinetItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||
|
||||
vi.mock('@/services/medicines', () => ({
|
||||
getMedicine: mockGetMedicine,
|
||||
listMedicineProducts: mockListMedicineProducts,
|
||||
createMedicineProduct: mockCreateMedicineProduct,
|
||||
deleteMedicineProduct: mockDeleteMedicineProduct,
|
||||
updateMedicine: mockUpdateMedicine,
|
||||
updateMedicineProduct: mockUpdateMedicineProduct,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/cabinet', () => ({
|
||||
listCabinetItems: mockListCabinetItems,
|
||||
adjustCabinetItemQuantity: mockAdjustCabinetItemQuantity,
|
||||
deleteCabinetItem: mockDeleteCabinetItem,
|
||||
}));
|
||||
|
||||
import MedicineDetailPage from '../page';
|
||||
|
||||
const medicine = {
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
form: 'tablet',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
category: 'prescription',
|
||||
notes: '',
|
||||
tags: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const emptyProducts = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptyItems = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseParams.mockReturnValue({ id: 'med-1' });
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetMedicine.mockResolvedValue(medicine);
|
||||
mockListMedicineProducts.mockResolvedValue(emptyProducts);
|
||||
mockListCabinetItems.mockResolvedValue(emptyItems);
|
||||
});
|
||||
|
||||
describe('MedicineDetailPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
expect(screen.queryByText('Metformin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders medicine name and sections after load', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
expect(screen.getByText('Inventory')).toBeInTheDocument();
|
||||
expect(screen.getByText('Products (Brands/Packages)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when medicine fails to load', async () => {
|
||||
mockGetMedicine.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Not found')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty inventory state', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/No inventory items/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty products state', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No products yet/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles Add Product form', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
expect(screen.getByText('Add Product', { selector: 'h3' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||
expect(screen.queryByText('Add Product', { selector: 'h3' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles edit medicine form', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByText('Edit Medicine')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves medicine edit', async () => {
|
||||
mockUpdateMedicine.mockResolvedValue({ ...medicine, name: 'Metformin XR' });
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
||||
});
|
||||
|
||||
it('deletes a product after confirmation', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Glucophage',
|
||||
packageSize: 60,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteMedicineProduct.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Glucophage'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteMedicineProduct).toHaveBeenCalledWith('hh1', 'prod-1');
|
||||
});
|
||||
|
||||
it('submits CreateProductForm', async () => {
|
||||
mockCreateMedicineProduct.mockResolvedValue({
|
||||
_id: 'prod-new',
|
||||
brand: 'NewBrand',
|
||||
packageSize: 30,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
||||
target: { value: 'NewBrand' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('90'), {
|
||||
target: { value: '30' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateMedicineProduct).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'med-1',
|
||||
expect.objectContaining({ brand: 'NewBrand' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows create product error on failure', async () => {
|
||||
mockCreateMedicineProduct.mockRejectedValue(new Error('Duplicate brand'));
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
||||
target: { value: 'Brand' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Duplicate brand')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('opens and saves product edit form', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Glucophage',
|
||||
packageSize: 60,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateMedicineProduct.mockResolvedValue({
|
||||
_id: 'prod-1',
|
||||
brand: 'Glucophage XR',
|
||||
packageSize: 60,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Glucophage'));
|
||||
// First Edit title is the medicine edit, second is the product edit
|
||||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||
fireEvent.change(screen.getByDisplayValue('Glucophage'), { target: { value: 'Glucophage XR' } });
|
||||
fireEvent.submit(screen.getByDisplayValue('Glucophage XR').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateMedicineProduct).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'prod-1',
|
||||
expect.objectContaining({ brand: 'Glucophage XR' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows concentration fields when injection medicine with ml unit', async () => {
|
||||
mockGetMedicine.mockResolvedValue({
|
||||
...medicine,
|
||||
form: 'injection',
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||
// Change package unit to ml to show concentration fields
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'vial',
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('deletes a product after confirmation', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Glucophage',
|
||||
packageSize: 60,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteMedicineProduct.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Glucophage'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteMedicineProduct).toHaveBeenCalledWith('hh1', 'prod-1');
|
||||
});
|
||||
|
||||
it('edits injection product and changes concentration fields', async () => {
|
||||
mockGetMedicine.mockResolvedValue({
|
||||
...medicine,
|
||||
form: 'injection',
|
||||
});
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Insulin',
|
||||
packageSize: 10,
|
||||
packageUnit: 'ml',
|
||||
source: 'manual',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Insulin'));
|
||||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Insulin'));
|
||||
|
||||
// concentration field should be visible since form=injection, unit=ml
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
||||
|
||||
// Change concentration unit
|
||||
const concUnitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === '--',
|
||||
) as HTMLSelectElement;
|
||||
if (concUnitSelect) {
|
||||
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||
}
|
||||
|
||||
expect(screen.getByDisplayValue('Insulin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes fields in the product edit inline form', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Glucophage',
|
||||
packageSize: 60,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Glucophage'));
|
||||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||
|
||||
// Change package size
|
||||
fireEvent.change(screen.getByDisplayValue('60'), { target: { value: '90' } });
|
||||
|
||||
// Change package unit
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'tablet',
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes manufacturer, concentration and notes fields in product form', async () => {
|
||||
mockGetMedicine.mockResolvedValue({
|
||||
...medicine,
|
||||
form: 'injection',
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Pfizer'));
|
||||
|
||||
// Change manufacturer (truthy) then clear (falsy → undefined)
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: 'Pfizer' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: '' } });
|
||||
|
||||
// Change notes (truthy) then clear (falsy → undefined)
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'Store in fridge' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
||||
|
||||
// Change unit to ml to show concentration fields
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'vial',
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||
// Set concentration (truthy) then clear (falsy → undefined)
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
||||
|
||||
// Change concentration unit
|
||||
const concUnitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === '',
|
||||
) as HTMLSelectElement;
|
||||
if (concUnitSelect) {
|
||||
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||
}
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Pfizer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes fields in the product edit inline form (manufacturer, notes)', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Glucophage',
|
||||
packageSize: 60,
|
||||
packageUnit: 'tablet',
|
||||
source: 'manual',
|
||||
manufacturer: '',
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Glucophage'));
|
||||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||
|
||||
// Find the manufacturer input (empty string value, maxLength 200)
|
||||
const allInputs = document.querySelectorAll('input[maxLength="200"]');
|
||||
// First is brand (Glucophage), second is manufacturer
|
||||
if (allInputs.length > 1) {
|
||||
fireEvent.change(allInputs[1]!, { target: { value: 'Pfizer' } });
|
||||
// Clear manufacturer to cover the || undefined false branch
|
||||
fireEvent.change(allInputs[1]!, { target: { value: '' } });
|
||||
}
|
||||
|
||||
// Change notes (maxLength 1000 input)
|
||||
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||
if (notesInput) {
|
||||
fireEvent.change(notesInput, { target: { value: 'Keep refrigerated' } });
|
||||
}
|
||||
|
||||
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adjusts cabinet item quantity', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'ci-1',
|
||||
quantity: 10,
|
||||
unit: 'tablet',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAdjustCabinetItemQuantity.mockResolvedValue({});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Add 1'));
|
||||
await userEvent.click(screen.getByTitle('Add 1'));
|
||||
|
||||
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: 1 });
|
||||
});
|
||||
|
||||
it('takes 1 from cabinet item', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAdjustCabinetItemQuantity.mockResolvedValue({});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Take 1'));
|
||||
await userEvent.click(screen.getByTitle('Take 1'));
|
||||
|
||||
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: -1 });
|
||||
});
|
||||
|
||||
it('deletes cabinet item after confirmation', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteCabinetItem.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Delete'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1');
|
||||
});
|
||||
|
||||
it('cancels CreateProductForm with internal Cancel button', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product', { selector: 'h3' }));
|
||||
// Click the Cancel button inside the form (last Cancel button in DOM)
|
||||
const cancelButtons = screen.getAllByText('Cancel');
|
||||
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||
|
||||
expect(screen.queryByText('Add Product', { selector: 'h3' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes all fields in edit medicine form', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. daily, morning'));
|
||||
|
||||
// Change name
|
||||
const nameInput = document.querySelector('input[maxLength="200"]') as HTMLElement;
|
||||
if (nameInput) fireEvent.change(nameInput, { target: { value: 'Metformin XR' } });
|
||||
|
||||
// Change form select
|
||||
const formSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'tablet',
|
||||
) as HTMLSelectElement;
|
||||
if (formSelect) fireEvent.change(formSelect, { target: { value: 'capsule' } });
|
||||
|
||||
// Change strength
|
||||
const strengthInput = document.querySelector('input[min="0.01"]') as HTMLElement;
|
||||
if (strengthInput) fireEvent.change(strengthInput, { target: { value: '250' } });
|
||||
|
||||
// Change category select
|
||||
const catSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
||||
) as HTMLSelectElement;
|
||||
if (catSelect) fireEvent.change(catSelect, { target: { value: 'otc' } });
|
||||
|
||||
// Change notes (truthy value)
|
||||
const notesInputs = document.querySelectorAll('input[maxLength="1000"]');
|
||||
if (notesInputs[0]) fireEvent.change(notesInputs[0]!, { target: { value: 'Take with food' } });
|
||||
// Also clear notes (covers the || undefined false branch)
|
||||
if (notesInputs[0]) fireEvent.change(notesInputs[0]!, { target: { value: '' } });
|
||||
|
||||
// Change tags
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. daily, morning'), {
|
||||
target: { value: 'morning, daily' },
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. daily, morning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when update medicine fails', async () => {
|
||||
mockUpdateMedicine.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Update failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses error in detail page', async () => {
|
||||
mockUpdateMedicine.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => screen.getByText('Update failed'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('Update failed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes strength unit in edit medicine form', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Edit'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByText('Edit Medicine'));
|
||||
// Change strength unit select (the one with 'mg' options)
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'mg',
|
||||
) as HTMLSelectElement;
|
||||
if (unitSelect) fireEvent.change(unitSelect, { target: { value: 'mcg' } });
|
||||
|
||||
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels product edit form', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [{ _id: 'mp-1', brand: 'Bayer', packageSize: 100, unit: 'tablet', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Bayer'));
|
||||
const editButtons = screen.getAllByTitle('Edit');
|
||||
await userEvent.click(editButtons[editButtons.length - 1]!);
|
||||
await waitFor(() => screen.getByDisplayValue('Bayer'));
|
||||
|
||||
// Click Cancel to close product edit form
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByDisplayValue('Bayer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on create product', async () => {
|
||||
mockCreateMedicineProduct.mockRejectedValue('create failed');
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Product'));
|
||||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), { target: { value: 'Brand X' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to create product')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on update product', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [{ _id: 'mp-1', brand: 'Bayer', packageSize: 100, unit: 'tablet', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateMedicineProduct.mockRejectedValue('update failed');
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Bayer'));
|
||||
// Click the product Edit button (last Edit button, not the medicine one)
|
||||
const editButtons = screen.getAllByTitle('Edit');
|
||||
await userEvent.click(editButtons[editButtons.length - 1]!);
|
||||
await waitFor(() => screen.getByDisplayValue('Bayer'));
|
||||
fireEvent.submit(screen.getByDisplayValue('Bayer').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to update product')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on delete product', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [{ _id: 'mp-1', brand: 'Bayer', packageSize: 100, unit: 'tablet', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteMedicineProduct.mockRejectedValue('delete failed');
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Bayer'));
|
||||
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to delete product')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on adjust cabinet', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAdjustCabinetItemQuantity.mockRejectedValue('adjust failed');
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Take 1'));
|
||||
await userEvent.click(screen.getByTitle('Take 1'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to adjust quantity')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows product concentration and manufacturer in display', async () => {
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'mp-1',
|
||||
brand: 'Lantus',
|
||||
packageSize: 10,
|
||||
packageUnit: 'ml',
|
||||
concentration: 100,
|
||||
concentrationUnit: 'IU/ml',
|
||||
manufacturer: 'Sanofi',
|
||||
notes: 'Refrigerate after opening',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Lantus'));
|
||||
expect(screen.getByText(/100 IU\/ml/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Sanofi/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Refrigerate after opening')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears concentration and notes in edit inline form for injection', async () => {
|
||||
mockGetMedicine.mockResolvedValue({ ...medicine, form: 'injection' });
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'prod-1',
|
||||
brand: 'Lantus',
|
||||
packageSize: 10,
|
||||
packageUnit: 'ml',
|
||||
source: 'manual',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Lantus'));
|
||||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Lantus'));
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||
|
||||
// Set concentration then clear (covers undefined branch at line 675)
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
||||
|
||||
// Set notes then clear (covers undefined branch at line 720)
|
||||
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||
if (notesInput) fireEvent.change(notesInput, { target: { value: 'Refrigerate' } });
|
||||
if (notesInput) fireEvent.change(notesInput, { target: { value: '' } });
|
||||
|
||||
expect(screen.getByDisplayValue('Lantus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on delete cabinet', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteCabinetItem.mockRejectedValue('delete failed');
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() => screen.getAllByTitle('Delete'));
|
||||
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
@ -161,11 +161,17 @@ export default function MedicineDetailPage() {
|
|||
|
||||
function startEditProduct(product: MedicineProduct) {
|
||||
setEditingProductId(product._id);
|
||||
const validUnits = Object.values(DosageUnit) as string[];
|
||||
const allowedUnits = allowedUnitsForForm((medicine?.form as MedicineForm) ?? MedicineForm.OTHER);
|
||||
const storedUnit = product.packageUnit;
|
||||
const packageUnit = validUnits.includes(storedUnit)
|
||||
? (storedUnit as DosageUnit)
|
||||
: allowedUnits[0];
|
||||
setProductForm({
|
||||
brand: product.brand,
|
||||
manufacturer: product.manufacturer ?? undefined,
|
||||
packageSize: product.packageSize,
|
||||
packageUnit: product.packageUnit as DosageUnit,
|
||||
packageUnit,
|
||||
concentration: product.concentration ?? undefined,
|
||||
concentrationUnit: product.concentrationUnit as ConcentrationUnit | undefined,
|
||||
notes: product.notes ?? undefined,
|
||||
|
|
@ -561,6 +567,7 @@ export default function MedicineDetailPage() {
|
|||
<CreateProductForm
|
||||
householdId={householdId}
|
||||
medicineId={params.id}
|
||||
/* v8 ignore next */
|
||||
medicineForm={(medicine?.form as MedicineForm) ?? MedicineForm.OTHER}
|
||||
onCreated={() => {
|
||||
setShowProductForm(false);
|
||||
|
|
@ -587,6 +594,7 @@ export default function MedicineDetailPage() {
|
|||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
/* v8 ignore next */
|
||||
value={productForm.brand ?? ''}
|
||||
onChange={(e) => setProductForm({ ...productForm, brand: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
|
|
@ -619,7 +627,7 @@ export default function MedicineDetailPage() {
|
|||
required
|
||||
min={1}
|
||||
step="any"
|
||||
value={productForm.packageSize ?? ''}
|
||||
value={/* v8 ignore next */ productForm.packageSize ?? ''}
|
||||
onChange={(e) =>
|
||||
setProductForm({
|
||||
...productForm,
|
||||
|
|
@ -632,7 +640,7 @@ export default function MedicineDetailPage() {
|
|||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
||||
<select
|
||||
value={productForm.packageUnit ?? ''}
|
||||
value={/* v8 ignore next */ productForm.packageUnit ?? ''}
|
||||
onChange={(e) =>
|
||||
setProductForm({
|
||||
...productForm,
|
||||
|
|
@ -642,7 +650,7 @@ export default function MedicineDetailPage() {
|
|||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{allowedUnitsForForm(
|
||||
(medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
||||
/* v8 ignore next */ (medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
||||
).map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockListCabinetEvents, mockGetSpendingSummary } = vi.hoisted(() => ({
|
||||
mockListCabinetEvents: vi.fn(),
|
||||
mockGetSpendingSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||
|
||||
vi.mock('@/services/cabinet-events', () => ({
|
||||
listCabinetEvents: mockListCabinetEvents,
|
||||
getSpendingSummary: mockGetSpendingSummary,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||
|
||||
import { ActivityTab } from '../ActivityTab';
|
||||
|
||||
const emptyMeds = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptyEvents = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptySummary = {
|
||||
totalSpent: 0,
|
||||
currency: null,
|
||||
byMedicine: [],
|
||||
byPeriod: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListMedicines.mockResolvedValue(emptyMeds);
|
||||
mockListCabinetEvents.mockResolvedValue(emptyEvents);
|
||||
mockGetSpendingSummary.mockResolvedValue(emptySummary);
|
||||
});
|
||||
|
||||
describe('ActivityTab', () => {
|
||||
it('renders Spending Summary and Cabinet Activity sections', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Spending Summary')).toBeInTheDocument());
|
||||
expect(screen.getByText('Cabinet Activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches spending summary on mount', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
||||
});
|
||||
|
||||
it('fetches events on mount', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
||||
});
|
||||
|
||||
it('shows empty state when no events', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No events found for the selected filters.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error when events fail to load', async () => {
|
||||
mockListCabinetEvents.mockRejectedValue(new Error('Events error'));
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Events error')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when spending summary fails', async () => {
|
||||
mockGetSpendingSummary.mockRejectedValue(new Error('Spending error'));
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Spending error')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders event entries', async () => {
|
||||
mockListCabinetEvents.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'ev-1',
|
||||
eventType: 'purchased',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantity: 10,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 10,
|
||||
unit: 'tablet',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows spending summary with data', async () => {
|
||||
mockGetSpendingSummary.mockResolvedValue({
|
||||
totalSpent: 125.50,
|
||||
currency: 'USD',
|
||||
byMedicine: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
totalSpent: 125.50,
|
||||
purchaseCount: 2,
|
||||
avgUnitPrice: 0.69,
|
||||
},
|
||||
],
|
||||
byPeriod: [
|
||||
{ period: '2026-01', totalSpent: 125.50 },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText(/125.50/).length).toBeGreaterThan(0));
|
||||
expect(screen.getByText('Metformin')).toBeInTheDocument();
|
||||
expect(screen.getByText('By medicine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no purchase data message when total is zero', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No purchase data found for this period.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters events by event type', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
||||
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('filters events by medicine', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1));
|
||||
const allMedSelects = screen.getAllByDisplayValue('All medicines');
|
||||
// The last select is the cabinet events medicine filter
|
||||
fireEvent.change(allMedSelects[allMedSelects.length - 1]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('shows clear filters button when filter applied and clears them', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalled());
|
||||
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
||||
|
||||
await waitFor(() => screen.getByText('Clear filters'));
|
||||
await userEvent.click(screen.getByText('Clear filters'));
|
||||
|
||||
expect(screen.queryByText('Clear filters')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses cabinet activity error', async () => {
|
||||
mockListCabinetEvents.mockRejectedValue(new Error('Events error'));
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Events error'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('Events error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes spending summary period and medicine filters', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Change period
|
||||
fireEvent.change(screen.getByDisplayValue('This month'), { target: { value: 'quarter' } });
|
||||
|
||||
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Change medicine filter in spending summary (first "All medicines" select)
|
||||
const medSelects = screen.getAllByDisplayValue('All medicines');
|
||||
fireEvent.change(medSelects[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('changes start and end date filters', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
||||
|
||||
const dateInputs = document.querySelectorAll('input[type="date"]');
|
||||
fireEvent.change(dateInputs[0]!, { target: { value: '2026-01-01' } });
|
||||
fireEvent.change(dateInputs[1]!, { target: { value: '2026-03-31' } });
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('loads more events when Load more is clicked', async () => {
|
||||
mockListCabinetEvents.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'ev-1',
|
||||
eventType: 'purchased',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantity: 10,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 10,
|
||||
unit: 'tablet',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: 'cursor-1', hasMore: true },
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Load more'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
|
||||
|
||||
expect(mockListCabinetEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders event with negative quantity (red dot)', async () => {
|
||||
mockListCabinetEvents.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'ev-1',
|
||||
eventType: 'taken',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantity: -1,
|
||||
quantityBefore: 10,
|
||||
quantityAfter: 9,
|
||||
unit: 'tablet',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders event with reason, storeName, totalPrice, and notes', async () => {
|
||||
mockListCabinetEvents.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'ev-1',
|
||||
eventType: 'purchased',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantity: 30,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 30,
|
||||
unit: 'tablet',
|
||||
reason: 'Monthly refill',
|
||||
storeName: 'Pharmacy',
|
||||
totalPrice: 25.5,
|
||||
currency: 'USD',
|
||||
notes: 'On sale',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Monthly refill/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Pharmacy/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/25.50/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/On sale/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,667 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const {
|
||||
mockListCabinetItems,
|
||||
mockGetCabinetSummary,
|
||||
mockCreateCabinetItem,
|
||||
mockAdjustCabinetItemQuantity,
|
||||
mockDeleteCabinetItem,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListCabinetItems: vi.fn(),
|
||||
mockGetCabinetSummary: vi.fn(),
|
||||
mockCreateCabinetItem: vi.fn(),
|
||||
mockAdjustCabinetItemQuantity: vi.fn(),
|
||||
mockDeleteCabinetItem: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||
|
||||
vi.mock('@/services/cabinet', () => ({
|
||||
listCabinetItems: mockListCabinetItems,
|
||||
getCabinetSummary: mockGetCabinetSummary,
|
||||
createCabinetItem: mockCreateCabinetItem,
|
||||
adjustCabinetItemQuantity: mockAdjustCabinetItemQuantity,
|
||||
deleteCabinetItem: mockDeleteCabinetItem,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||
|
||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||
|
||||
import { CabinetTab } from '../CabinetTab';
|
||||
|
||||
const emptySummary = { data: [] };
|
||||
const emptyItems = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptyMeds = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
const cabinetItem = {
|
||||
_id: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantity: 10,
|
||||
unit: 'tablet',
|
||||
status: 'active',
|
||||
expirationDate: null,
|
||||
unitPrice: null,
|
||||
totalPrice: null,
|
||||
storeId: null,
|
||||
notes: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||
mockListCabinetItems.mockResolvedValue(emptyItems);
|
||||
mockListMedicines.mockResolvedValue(emptyMeds);
|
||||
});
|
||||
|
||||
describe('CabinetTab', () => {
|
||||
it('shows empty state when no items', async () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/cabinet is empty/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when list fails', async () => {
|
||||
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses error on Dismiss click', async () => {
|
||||
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Server error'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('Server error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles Add to Cabinet form', async () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||
expect(screen.queryByPlaceholderText('Search medicines...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches between Summary and All Items views', async () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
expect(mockListCabinetItems).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders cabinet items in detail view', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('adjusts item quantity', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 11 });
|
||||
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByTitle('Add 1'));
|
||||
await userEvent.click(screen.getByTitle('Add 1'));
|
||||
|
||||
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: 1 });
|
||||
});
|
||||
|
||||
it('shows summary view with medicine data', async () => {
|
||||
mockGetCabinetSummary.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
totalQuantity: 30,
|
||||
unit: 'tablet',
|
||||
itemCount: 2,
|
||||
earliestExpiry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
expect(screen.getByText(/2 items/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands medicine in summary view', async () => {
|
||||
mockGetCabinetSummary.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
totalQuantity: 30,
|
||||
unit: 'tablet',
|
||||
itemCount: 1,
|
||||
earliestExpiry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockListCabinetItems).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ medicineId: 'med-1' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits AddToCabinetForm with medicine selection validation', async () => {
|
||||
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
|
||||
// Submit without selecting a medicine - should show error
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Please select a medicine')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits AddToCabinetForm successfully', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
form: 'tablet',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
|
||||
// Select a medicine (first combobox is the medicine select)
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, {
|
||||
target: { value: 'med-1' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateCabinetItem).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits AddToCabinetForm with expiration date and notes', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
form: 'tablet',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
|
||||
// Select a medicine
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, {
|
||||
target: { value: 'med-1' },
|
||||
});
|
||||
|
||||
// Set expiration date (type="date" input, no label association)
|
||||
const dateInput = document.querySelector('input[type="date"]') as HTMLElement;
|
||||
if (dateInput) fireEvent.change(dateInput, { target: { value: '2027-12-31' } });
|
||||
|
||||
// Set notes
|
||||
fireEvent.change(screen.getByPlaceholderText('Any notes about this item'), {
|
||||
target: { value: 'Store in fridge' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateCabinetItem).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({
|
||||
medicineId: 'med-1',
|
||||
notes: 'Store in fridge',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('changes quantity and unit in AddToCabinetForm', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
|
||||
// Search for medicine
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
||||
target: { value: 'Met' },
|
||||
});
|
||||
|
||||
// Change quantity
|
||||
fireEvent.change(screen.getByPlaceholderText('30'), { target: { value: '60' } });
|
||||
|
||||
// Change unit
|
||||
fireEvent.change(screen.getAllByRole('combobox')[1]!, { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches from detail view back to summary view', async () => {
|
||||
mockGetCabinetSummary.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
totalQuantity: 10,
|
||||
unit: 'tablet',
|
||||
itemCount: 1,
|
||||
earliestExpiry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
// Now in detail view
|
||||
await waitFor(() => screen.getByText('Summary'));
|
||||
await userEvent.click(screen.getByText('Summary'));
|
||||
|
||||
// Back in summary view — Metformin should show
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows expiry date for cabinet items', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
...cabinetItem,
|
||||
expirationDate: '2099-12-31T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
expect(screen.getByText(/days\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters cabinet items by status in detail view', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('All Statuses'));
|
||||
fireEvent.change(screen.getByDisplayValue('All Statuses'), { target: { value: 'active' } });
|
||||
|
||||
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('takes 1 from cabinet item', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 9 });
|
||||
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByTitle('Take 1'));
|
||||
await userEvent.click(screen.getByTitle('Take 1'));
|
||||
|
||||
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: -1 });
|
||||
});
|
||||
|
||||
it('collapses expanded medicine when it returns 0 items', async () => {
|
||||
mockGetCabinetSummary.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
totalQuantity: 0,
|
||||
unit: 'tablet',
|
||||
itemCount: 0,
|
||||
earliestExpiry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockListCabinetItems).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ medicineId: 'med-1' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error when createCabinetItem fails', async () => {
|
||||
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
// Should show 'Please select a medicine' validation first (since no medicine selected)
|
||||
await waitFor(() => expect(screen.getByText('Please select a medicine')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('deletes cabinet item after confirmation', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteCabinetItem.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByTitle('Delete'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1');
|
||||
});
|
||||
|
||||
it('shows error when adjust fails', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAdjustCabinetItemQuantity.mockRejectedValue(new Error('Adjust failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByTitle('Add 1'));
|
||||
await userEvent.click(screen.getByTitle('Add 1'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Adjust failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when delete fails', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteCabinetItem.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => screen.getByTitle('Delete'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Delete failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('cancels AddToCabinetForm with internal Cancel button', async () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
// Click the Cancel button inside the form (not the toggle button)
|
||||
const cancelButtons = screen.getAllByText('Cancel');
|
||||
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search medicines...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses expanded medicine when clicked again', async () => {
|
||||
const summary = {
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
totalQuantity: 10,
|
||||
unit: 'tablet',
|
||||
itemCount: 1,
|
||||
earliestExpiry: null,
|
||||
};
|
||||
mockGetCabinetSummary.mockResolvedValue({ data: [summary] });
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [cabinetItem],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
// First click expands — handleExpand + useEffect both call listCabinetItems
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||
const callsAfterExpand = mockListCabinetItems.mock.calls.length;
|
||||
|
||||
// Second click collapses — no additional listCabinetItems calls
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
await waitFor(() => expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument());
|
||||
|
||||
expect(mockListCabinetItems.mock.calls.length).toBe(callsAfterExpand);
|
||||
});
|
||||
|
||||
it('shows empty items when expand fails', async () => {
|
||||
mockGetCabinetSummary.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
totalQuantity: 10,
|
||||
unit: 'tablet',
|
||||
itemCount: 1,
|
||||
earliestExpiry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockListCabinetItems.mockRejectedValue(new Error('Expand failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||
// No items should be shown (empty after error)
|
||||
expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows create error when medicine is selected and create fails', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Create failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows item notes in detail view', async () => {
|
||||
mockListCabinetItems.mockResolvedValue({
|
||||
data: [{ ...cabinetItem, notes: 'Store in fridge' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Store in fridge')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('waits for medicines to load then selects medicine in form', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
// Wait for medicine options to load (covers medicines.map callback)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getAllByRole('combobox')[0] as HTMLSelectElement).options.length,
|
||||
).toBeGreaterThan(1),
|
||||
);
|
||||
|
||||
// Select a valid medicine (true branch: medForm is set → defaultUnitForForm called)
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
// Select empty value (false branch: selectedMed undefined → DosageUnit.TABLET fallback)
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: '' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error is thrown during create', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateCabinetItem.mockRejectedValue('unexpected');
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to add item')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockListMedicines, mockCreateMedicine, mockDeleteMedicine } = vi.hoisted(() => ({
|
||||
mockListMedicines: vi.fn(),
|
||||
mockCreateMedicine: vi.fn(),
|
||||
mockDeleteMedicine: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/medicines', () => ({
|
||||
listMedicines: mockListMedicines,
|
||||
createMedicine: mockCreateMedicine,
|
||||
deleteMedicine: mockDeleteMedicine,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||
|
||||
import { LibraryTab } from '../LibraryTab';
|
||||
|
||||
const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
const medicine = {
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
form: 'tablet',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
category: 'prescription',
|
||||
tags: [],
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('LibraryTab', () => {
|
||||
it('shows loading skeleton initially', () => {
|
||||
mockListMedicines.mockReturnValue(new Promise(() => {}));
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
expect(screen.queryByText('Metformin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders medicine list after load', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty state when no medicines', async () => {
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/No medicines yet/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when list fails', async () => {
|
||||
mockListMedicines.mockRejectedValue(new Error('Failed to load'));
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to load')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses error on Dismiss click', async () => {
|
||||
mockListMedicines.mockRejectedValue(new Error('Failed to load'));
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Failed to load'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('Failed to load')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles Add Medicine form', async () => {
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Medicine'));
|
||||
await userEvent.click(screen.getByText('Add Medicine'));
|
||||
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||
expect(screen.queryByPlaceholderText('e.g. Metformin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates medicine and refreshes list', async () => {
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
mockCreateMedicine.mockResolvedValue({ _id: 'med-2', name: 'Aspirin' });
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await userEvent.click(screen.getByText('Add Medicine'));
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('e.g. Metformin'), 'Aspirin');
|
||||
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
||||
|
||||
await waitFor(() => expect(mockCreateMedicine).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Aspirin' })));
|
||||
});
|
||||
|
||||
it('deletes medicine after confirmation', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
mockDeleteMedicine.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteMedicine).toHaveBeenCalledWith('hh1', 'med-1');
|
||||
});
|
||||
|
||||
it('changes form fields in add medicine form', async () => {
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Medicine'));
|
||||
await userEvent.click(screen.getByText('Add Medicine'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Metformin'));
|
||||
|
||||
// Change form type
|
||||
fireEvent.change(screen.getByDisplayValue('Tablet'), { target: { value: 'capsule' } });
|
||||
// Change strength unit
|
||||
fireEvent.change(screen.getByDisplayValue('mg'), { target: { value: 'mcg' } });
|
||||
// Change category
|
||||
fireEvent.change(screen.getByDisplayValue('OTC'), { target: { value: 'prescription' } });
|
||||
// Change notes (covers truthy branch)
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'test notes' } });
|
||||
// Clear notes (covers falsy branch → undefined)
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
||||
|
||||
// Verify form is still visible
|
||||
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters medicines by search, category, and form', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [medicine],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
|
||||
// Search filter
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'met' } });
|
||||
|
||||
// Category filter
|
||||
fireEvent.change(screen.getByDisplayValue('All Categories'), { target: { value: 'prescription' } });
|
||||
|
||||
// Form filter
|
||||
fireEvent.change(screen.getByDisplayValue('All Forms'), { target: { value: 'tablet' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error is thrown on delete', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
mockDeleteMedicine.mockRejectedValue('oops');
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to delete')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('cancels CreateMedicineForm with internal Cancel button', async () => {
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Medicine'));
|
||||
await userEvent.click(screen.getByText('Add Medicine'));
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Metformin'));
|
||||
|
||||
const cancelButtons = screen.getAllByText('Cancel');
|
||||
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||
|
||||
expect(screen.queryByPlaceholderText('e.g. Metformin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error is thrown on create medicine', async () => {
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
mockCreateMedicine.mockRejectedValue('create failed');
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Medicine'));
|
||||
await userEvent.click(screen.getByText('Add Medicine'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Metformin'));
|
||||
await userEvent.type(screen.getByPlaceholderText('e.g. Metformin'), 'Aspirin');
|
||||
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to create medicine')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not delete medicine if confirmation cancelled', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteMedicine).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockListFills, mockPreviewFill, mockExecuteFill, mockUndoFill } = vi.hoisted(() => ({
|
||||
mockListFills: vi.fn(),
|
||||
mockPreviewFill: vi.fn(),
|
||||
mockExecuteFill: vi.fn(),
|
||||
mockUndoFill: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListRegimens } = vi.hoisted(() => ({ mockListRegimens: vi.fn() }));
|
||||
|
||||
vi.mock('@/services/organizer', () => ({
|
||||
listFills: mockListFills,
|
||||
previewFill: mockPreviewFill,
|
||||
executeFill: mockExecuteFill,
|
||||
undoFill: mockUndoFill,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/regimens', () => ({ listRegimens: mockListRegimens }));
|
||||
|
||||
import { OrganizerTab } from '../OrganizerTab';
|
||||
|
||||
const emptyRegimens = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptyFills = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
const activeRegimen = {
|
||||
_id: 'reg-1',
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
mockListFills.mockResolvedValue(emptyFills);
|
||||
});
|
||||
|
||||
describe('OrganizerTab', () => {
|
||||
it('shows fill organizer and fill history sections', async () => {
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Fill Pill Organizer')).toBeInTheDocument());
|
||||
expect(screen.getByText('Fill History')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no active regimens message when none exist', async () => {
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No active regimens found/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows fill form when active regimens exist', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Preview fill')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty fill history', async () => {
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders fill history entries', async () => {
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'fill-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
status: 'completed',
|
||||
numberOfDays: 7,
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Morning Routine')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('previews fill and shows preview result', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
numberOfDays: 7,
|
||||
hasShortages: false,
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.change(screen.getByDisplayValue('7'), { target: { value: '14' } });
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockPreviewFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows preview result and executes fill', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: false,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityAvailable: 30,
|
||||
isShort: false,
|
||||
shortage: 0,
|
||||
cabinetBreakdown: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockExecuteFill.mockResolvedValue({ _id: 'fill-new', status: 'completed' });
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => screen.getByText('Confirm fill'));
|
||||
expect(screen.getByText('Metformin')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Confirm fill'));
|
||||
|
||||
await waitFor(() => expect(mockExecuteFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })));
|
||||
});
|
||||
|
||||
it('shows shortage warning in preview', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: true,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityAvailable: 3,
|
||||
isShort: true,
|
||||
shortage: 4,
|
||||
cabinetBreakdown: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => screen.getByText('Shortages detected'));
|
||||
expect(screen.getByText('Allow partial fill (fill what is available)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows undo error when undo fails', async () => {
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'fill-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
status: 'completed',
|
||||
numberOfDays: 7,
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUndoFill.mockRejectedValue(new Error('Undo failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Undo'));
|
||||
await userEvent.click(screen.getByText('Undo'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Undo failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('filters fill history by status', async () => {
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'fill-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
status: 'completed',
|
||||
numberOfDays: 7,
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('All statuses'));
|
||||
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'completed' } });
|
||||
|
||||
await waitFor(() => expect(mockListFills).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('shows Ready to fill when no shortages', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: false,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityAvailable: 30,
|
||||
isShort: false,
|
||||
shortage: 0,
|
||||
cabinetBreakdown: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Ready to fill')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders fill items with short status', async () => {
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'fill-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
status: 'partial',
|
||||
numberOfDays: 7,
|
||||
fillDate: '2026-01-01T00:00:00.000Z',
|
||||
items: [{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityTaken: 3,
|
||||
wasShort: true,
|
||||
shortage: 4,
|
||||
deductions: [],
|
||||
}],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Metformin.*short/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses fill history error', async () => {
|
||||
mockListFills.mockRejectedValue(new Error('History error'));
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('History error'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('History error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('undoes a fill after confirmation', async () => {
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'fill-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
status: 'completed',
|
||||
numberOfDays: 7,
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUndoFill.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Undo'));
|
||||
await userEvent.click(screen.getByText('Undo'));
|
||||
|
||||
expect(mockUndoFill).toHaveBeenCalledWith('hh1', 'fill-1');
|
||||
});
|
||||
|
||||
it('cancels from preview screen', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: false,
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => screen.getByText('Confirm fill'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
// Should return to the form
|
||||
await waitFor(() => expect(screen.getByText('Preview fill')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows cabinet breakdown in preview items', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: false,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityAvailable: 30,
|
||||
isShort: false,
|
||||
shortage: 0,
|
||||
cabinetBreakdown: [
|
||||
{ cabinetItemId: 'ci-1', quantityToTake: 7, expirationDate: '2027-06-01T00:00:00.000Z' },
|
||||
{ cabinetItemId: 'ci-2', quantityToTake: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/7 units/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown during preview', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockRejectedValue('preview failed');
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to generate preview')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown during fill', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: false,
|
||||
items: [],
|
||||
});
|
||||
mockExecuteFill.mockRejectedValue('fill failed');
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => screen.getByText('Confirm fill'));
|
||||
await userEvent.click(screen.getByText('Confirm fill'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Fill failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('toggles allow partial checkbox in shortage preview', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockPreviewFill.mockResolvedValue({
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
numberOfDays: 7,
|
||||
hasShortages: true,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityAvailable: 3,
|
||||
isShort: true,
|
||||
shortage: 4,
|
||||
cabinetBreakdown: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Preview fill'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select regimen...').closest('select')!, {
|
||||
target: { value: 'reg-1' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() => screen.getByText('Allow partial fill (fill what is available)'));
|
||||
fireEvent.click(screen.getByLabelText('Allow partial fill (fill what is available)'));
|
||||
|
||||
expect(screen.getByLabelText('Allow partial fill (fill what is available)')).toBeChecked();
|
||||
});
|
||||
|
||||
it('changes notes field in the organizer form', async () => {
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [activeRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Any notes for this fill'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Any notes for this fill'), {
|
||||
target: { value: 'Fill for next week' },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Fill for next week')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,530 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockListRegimens, mockCreateRegimen, mockUpdateRegimen, mockDeleteRegimen, mockGetBurnRates } =
|
||||
vi.hoisted(() => ({
|
||||
mockListRegimens: vi.fn(),
|
||||
mockCreateRegimen: vi.fn(),
|
||||
mockUpdateRegimen: vi.fn(),
|
||||
mockDeleteRegimen: vi.fn(),
|
||||
mockGetBurnRates: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||
|
||||
vi.mock('@/services/regimens', () => ({
|
||||
listRegimens: mockListRegimens,
|
||||
createRegimen: mockCreateRegimen,
|
||||
updateRegimen: mockUpdateRegimen,
|
||||
deleteRegimen: mockDeleteRegimen,
|
||||
getBurnRates: mockGetBurnRates,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||
|
||||
import { RegimensTab } from '../RegimensTab';
|
||||
|
||||
const emptyMeds = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptyRegimens = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
const regimen = {
|
||||
_id: 'reg-1',
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'once_daily',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
mockListMedicines.mockResolvedValue(emptyMeds);
|
||||
mockGetBurnRates.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
describe('RegimensTab', () => {
|
||||
it('shows empty state when no regimens', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/No regimens yet/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders regimen list', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Morning Routine')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when list fails', async () => {
|
||||
mockListRegimens.mockRejectedValue(new Error('Load failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Load failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses error', async () => {
|
||||
mockListRegimens.mockRejectedValue(new Error('Load failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Load failed'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('Load failed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles new regimen form', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getByText('New Regimen'));
|
||||
expect(screen.getByText(/New Regimen/)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||
expect(screen.queryByPlaceholderText('e.g. Morning routine')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a regimen when form is submitted', async () => {
|
||||
mockCreateRegimen.mockResolvedValue(regimen);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Morning routine'), {
|
||||
target: { value: 'Evening Routine' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Morning routine').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateRegimen).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'Evening Routine' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes regimen after confirmation', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockDeleteRegimen.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
expect(mockDeleteRegimen).toHaveBeenCalledWith('hh1', 'reg-1');
|
||||
});
|
||||
|
||||
it('shows burn rate section when toggled', async () => {
|
||||
mockGetBurnRates.mockResolvedValue({ data: [] });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument());
|
||||
expect(mockGetBurnRates).toHaveBeenCalledWith('hh1');
|
||||
});
|
||||
|
||||
it('opens edit form for regimen', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
expect(screen.getByDisplayValue('Morning Routine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves edited regimen', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
|
||||
fireEvent.change(screen.getByDisplayValue('Morning Routine'), { target: { value: 'Evening Routine' } });
|
||||
fireEvent.submit(screen.getByDisplayValue('Evening Routine').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateRegimen).toHaveBeenCalledWith('hh1', 'reg-1', expect.objectContaining({ name: 'Evening Routine' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows validation error when submitting regimen form with no medications', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Morning routine'), {
|
||||
target: { value: 'My Regimen' },
|
||||
});
|
||||
// Remove the default medication
|
||||
await userEvent.click(screen.getByTitle('Remove medication'));
|
||||
// Submit with no medications
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Morning routine').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('At least one medication is required.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error when delete fails', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Delete failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows burn rate table with data', async () => {
|
||||
mockGetBurnRates.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dailyConsumption: 2.0,
|
||||
totalInCabinet: 60,
|
||||
daysUntilEmpty: 30,
|
||||
earliestExpiry: null,
|
||||
avgUnitPrice: null,
|
||||
projectedDailyCost: null,
|
||||
projectedMonthlyCost: null,
|
||||
projectedYearlyCost: null,
|
||||
currency: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||
expect(screen.getByText('2.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels edit form and hides it', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByDisplayValue('Morning Routine')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters regimens by active status', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('All regimens'));
|
||||
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
|
||||
|
||||
await waitFor(() => expect(mockListRegimens).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('adds a medication in the regimen form', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByText('+ Add medication'));
|
||||
await userEvent.click(screen.getByText('+ Add medication'));
|
||||
|
||||
// Now 2 medications — verify the button still shows
|
||||
expect(screen.getByText('+ Add medication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles isActive checkbox in regimen form', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByLabelText('Active'));
|
||||
expect(screen.getByLabelText('Active')).toBeChecked();
|
||||
fireEvent.click(screen.getByLabelText('Active'));
|
||||
expect(screen.getByLabelText('Active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('changes medicine, dosage, and unit in medication row', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByText('Select medicine...'));
|
||||
|
||||
// Select a medicine in the medication row
|
||||
const medicineSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
|
||||
) as HTMLSelectElement;
|
||||
expect(medicineSelect).toBeDefined();
|
||||
fireEvent.change(medicineSelect!, { target: { value: 'med-1' } });
|
||||
|
||||
// Change dosage
|
||||
const dosageInput = document.querySelector('input[min="0.01"]') as HTMLElement;
|
||||
if (dosageInput) fireEvent.change(dosageInput, { target: { value: '2' } });
|
||||
|
||||
// Change dosage unit
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'tablet',
|
||||
) as HTMLSelectElement;
|
||||
if (unitSelect) fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes instructions field in medication row', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Take with food'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Take with food'), {
|
||||
target: { value: 'With meals' },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('With meals')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes frequency to custom and sets times per day', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
|
||||
// The frequency select has 'daily' as its first option value
|
||||
const frequencySelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'daily',
|
||||
) as HTMLSelectElement;
|
||||
expect(frequencySelect).toBeDefined();
|
||||
fireEvent.change(frequencySelect!, { target: { value: 'custom' } });
|
||||
|
||||
// customFrequencyPerDay input should appear
|
||||
await waitFor(() => expect(document.querySelector('input[min="1"][step="1"]')).not.toBeNull());
|
||||
const timesPerDayInput = document.querySelector('input[min="1"][step="1"]') as HTMLElement;
|
||||
fireEvent.change(timesPerDayInput, { target: { value: '3' } });
|
||||
|
||||
// Change back to non-custom (covers the undefined branch)
|
||||
fireEvent.change(frequencySelect!, { target: { value: 'twice_daily' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes time of day in medication row', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
|
||||
// The time-of-day select has 'Any time' as its first option text
|
||||
const timeOfDaySelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Any time',
|
||||
) as HTMLSelectElement;
|
||||
expect(timeOfDaySelect).toBeDefined();
|
||||
fireEvent.change(timeOfDaySelect!, { target: { value: 'morning' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when burn rate fetch fails', async () => {
|
||||
mockGetBurnRates.mockRejectedValue(new Error('Burn rate failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Burn rate failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when update fails', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Deactivate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Update failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('toggles active/inactive status', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Deactivate'));
|
||||
|
||||
expect(mockUpdateRegimen).toHaveBeenCalledWith('hh1', 'reg-1', { isActive: false });
|
||||
});
|
||||
|
||||
it('cancels new regimen form using internal Cancel button', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
// The Cancel button inside the RegimenForm (not the toggle button)
|
||||
const cancelButtons = screen.getAllByRole('button', { name: 'Cancel' });
|
||||
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||
|
||||
expect(screen.queryByPlaceholderText('e.g. Morning routine')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters regimens by inactive status', async () => {
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListRegimens).toHaveBeenCalledTimes(1));
|
||||
|
||||
const filterSelect = screen.getByDisplayValue('All regimens');
|
||||
fireEvent.change(filterSelect, { target: { value: 'inactive' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockListRegimens).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ isActive: false }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows filtered empty state when filter is active and no results', async () => {
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('All regimens'));
|
||||
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No active regimens found.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows null when form is open and regimens list is empty', async () => {
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
// Form is open with no regimens — empty state shows null (nothing visible in that spot)
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
expect(screen.queryByText('No regimens yet.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows plural medications label when regimen has multiple meds', async () => {
|
||||
const multiMedRegimen = {
|
||||
...regimen,
|
||||
medications: [
|
||||
{ ...regimen.medications[0] },
|
||||
{ ...regimen.medications[0], medicineId: 'med-2', medicineName: 'Aspirin' },
|
||||
],
|
||||
};
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [multiMedRegimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/2 medications/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on burn rates', async () => {
|
||||
mockGetBurnRates.mockRejectedValue('burn failed');
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to load burn rates')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on create regimen', async () => {
|
||||
mockCreateRegimen.mockRejectedValue('save failed');
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
await userEvent.type(screen.getByPlaceholderText('e.g. Morning routine'), 'Test');
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Morning routine').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to save regimen')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('initializes edit form with existing medications', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
|
||||
// The medication row should be pre-filled — Remove button should be present
|
||||
expect(screen.getByTitle('Remove medication')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (props: any) => props.children };
|
||||
});
|
||||
|
||||
import MedicinesPage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('MedicinesPage', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<MedicinesPage />);
|
||||
|
||||
expect(screen.getByText('Medicines')).toBeInTheDocument();
|
||||
// Should show skeleton, not section cards
|
||||
expect(screen.queryByText('Library')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<MedicinesPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders section cards when household exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<MedicinesPage />);
|
||||
|
||||
expect(screen.getByText('Library')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cabinet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regimens')).toBeInTheDocument();
|
||||
expect(screen.getByText('Organizer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Activity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('@/app/(dashboard)/medicines/ActivityTab', () => ({
|
||||
ActivityTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="activity-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import ActivityPage from '../page';
|
||||
|
||||
describe('ActivityPage', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<ActivityPage />);
|
||||
expect(screen.getByText('Cabinet Activity')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('activity-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<ActivityPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ActivityTab when householdId exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<ActivityPage />);
|
||||
expect(screen.getByTestId('activity-tab')).toHaveTextContent('hh1');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('@/app/(dashboard)/medicines/CabinetTab', () => ({
|
||||
CabinetTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="cabinet-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import CabinetPage from '../page';
|
||||
|
||||
describe('CabinetPage', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<CabinetPage />);
|
||||
expect(screen.getByText('Medicine Cabinet')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cabinet-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<CabinetPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CabinetTab when householdId exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<CabinetPage />);
|
||||
expect(screen.getByTestId('cabinet-tab')).toHaveTextContent('hh1');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('@/app/(dashboard)/medicines/LibraryTab', () => ({
|
||||
LibraryTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="library-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import LibraryPage from '../page';
|
||||
|
||||
describe('LibraryPage', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<LibraryPage />);
|
||||
expect(screen.getByText('Medicine Library')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('library-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<LibraryPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders LibraryTab when householdId exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<LibraryPage />);
|
||||
expect(screen.getByTestId('library-tab')).toHaveTextContent('hh1');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('@/app/(dashboard)/medicines/OrganizerTab', () => ({
|
||||
OrganizerTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="organizer-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import OrganizerPage from '../page';
|
||||
|
||||
describe('OrganizerPage', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<OrganizerPage />);
|
||||
expect(screen.getByText('Pill Organizer')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('organizer-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<OrganizerPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders OrganizerTab when householdId exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<OrganizerPage />);
|
||||
expect(screen.getByTestId('organizer-tab')).toHaveTextContent('hh1');
|
||||
});
|
||||
});
|
||||
|
|
@ -56,6 +56,26 @@ export default function MedicinesPage() {
|
|||
description="View cabinet event history and spending summaries"
|
||||
href="/medicines/activity"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Stores"
|
||||
description="Manage pharmacies and stores for price tracking"
|
||||
href="/stores"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Prices"
|
||||
description="Track and compare medicine prices across stores"
|
||||
href="/medicine-prices"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Refills"
|
||||
description="Get refill alerts and manage shopping lists"
|
||||
href="/refills"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Purchases"
|
||||
description="Record medicine purchases and track online orders"
|
||||
href="/purchases"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -86,11 +106,9 @@ function PageSkeleton() {
|
|||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
|
||||
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('@/app/(dashboard)/medicines/RegimensTab', () => ({
|
||||
RegimensTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="regimens-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import RegimensPage from '../page';
|
||||
|
||||
describe('RegimensPage', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<RegimensPage />);
|
||||
expect(screen.getByText('Regimens')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('regimens-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<RegimensPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders RegimensTab when householdId exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RegimensPage />);
|
||||
expect(screen.getByTestId('regimens-tab')).toHaveTextContent('hh1');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
const { mockListPurchases, mockCreatePurchase, mockReceivePurchase, mockDeletePurchase } =
|
||||
vi.hoisted(() => ({
|
||||
mockListPurchases: vi.fn(),
|
||||
mockCreatePurchase: vi.fn(),
|
||||
mockReceivePurchase: vi.fn(),
|
||||
mockDeletePurchase: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListStores } = vi.hoisted(() => ({ mockListStores: vi.fn() }));
|
||||
const { mockListMedicines, mockListMedicineProducts } = vi.hoisted(() => ({
|
||||
mockListMedicines: vi.fn(),
|
||||
mockListMedicineProducts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('@/services/purchases', () => ({
|
||||
listPurchases: mockListPurchases,
|
||||
createPurchase: mockCreatePurchase,
|
||||
receivePurchase: mockReceivePurchase,
|
||||
deletePurchase: mockDeletePurchase,
|
||||
}));
|
||||
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
|
||||
vi.mock('@/services/medicines', () => ({
|
||||
listMedicines: mockListMedicines,
|
||||
listMedicineProducts: mockListMedicineProducts,
|
||||
}));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
|
||||
import PurchasesPage from '../page';
|
||||
|
||||
const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListStores.mockResolvedValue(emptyResponse);
|
||||
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||
});
|
||||
|
||||
describe('PurchasesPage', () => {
|
||||
it('shows loading skeleton when session loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<PurchasesPage />);
|
||||
expect(screen.getByText('Purchases')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Record Purchase')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<PurchasesPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no purchases', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Record Purchase button', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Record Purchase')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when purchases fail to load', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockRejectedValue(new Error('Network failure'));
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Network failure')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders ordered and received sections', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
status: 'ordered',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
_id: 'p2',
|
||||
status: 'in_cabinet',
|
||||
purchasedAt: '2026-01-02T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Pending arrival')).toBeInTheDocument());
|
||||
// "Received" appears as a section heading (h2) — use getAllByText since PurchaseCard may also render it
|
||||
expect(screen.getAllByText('Received').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('toggles the create form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
// After clicking, both the header toggle and the form show "Cancel"
|
||||
expect(screen.getAllByRole('button', { name: 'Cancel' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows Load more button when hasMore', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: 'cur1', hasMore: true },
|
||||
});
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Load more')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows validation error when no store selected', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
// Submit without selecting a store
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Please select a store.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows validation error when no items', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Add at least one item with a name and quantity.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('receives a purchase after confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
status: 'ordered',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockReceivePurchase.mockResolvedValue({});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Mark as received'));
|
||||
await userEvent.click(screen.getByText('Mark as received'));
|
||||
|
||||
expect(mockReceivePurchase).toHaveBeenCalledWith('hh1', 'p1');
|
||||
});
|
||||
|
||||
it('deletes a purchase after confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
status: 'ordered',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeletePurchase.mockResolvedValue({});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Cancel order'));
|
||||
await userEvent.click(screen.getByText('Cancel order'));
|
||||
|
||||
expect(mockDeletePurchase).toHaveBeenCalledWith('hh1', 'p1');
|
||||
});
|
||||
|
||||
it('shows error when receive fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
status: 'ordered',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockReceivePurchase.mockRejectedValue(new Error('Receive failed'));
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Mark as received'));
|
||||
await userEvent.click(screen.getByText('Mark as received'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Receive failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when delete fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
status: 'ordered',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeletePurchase.mockRejectedValue(new Error('Delete failed'));
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Cancel order'));
|
||||
await userEvent.click(screen.getByText('Cancel order'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Delete failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('creates a purchase successfully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListMedicineProducts.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreatePurchase.mockResolvedValue({
|
||||
_id: 'p-new',
|
||||
status: 'in_cabinet',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreatePurchase).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ storeId: 'st-1' }),
|
||||
),
|
||||
);
|
||||
// Form hides after success
|
||||
await waitFor(() => expect(screen.queryByText('Save Purchase')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('cancels the create form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getAllByRole('button', { name: 'Cancel' }));
|
||||
// Click the form's Cancel button (inside the form)
|
||||
const cancelBtns = screen.getAllByRole('button', { name: 'Cancel' });
|
||||
await userEvent.click(cancelBtns[cancelBtns.length - 1]!);
|
||||
|
||||
expect(screen.queryByText('Save Purchase')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders purchase items with price data', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
status: 'in_cabinet',
|
||||
storeName: 'Walgreens',
|
||||
purchasedAt: '2026-01-01T00:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
name: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 5.99,
|
||||
currency: 'USD',
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument());
|
||||
expect(screen.getByText(/5.99/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more purchases when Load more is clicked', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: 'cur1', hasMore: true },
|
||||
});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Load more'));
|
||||
await userEvent.click(screen.getByText('Load more'));
|
||||
|
||||
expect(mockListPurchases).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('toggles online order checkbox and changes notes', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByLabelText('Online order (pending arrival)'));
|
||||
fireEvent.click(screen.getByLabelText('Online order (pending arrival)'));
|
||||
fireEvent.change(document.querySelector('input[maxLength="1000"]') as HTMLElement, {
|
||||
target: { value: 'Some notes' },
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Online order (pending arrival)')).toBeChecked();
|
||||
});
|
||||
|
||||
it('adds and removes purchase items', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
const addItemBtn = screen.getByRole('button', { name: 'Add item' });
|
||||
await userEvent.click(addItemBtn);
|
||||
|
||||
// Now 2 items — remove button shows
|
||||
await waitFor(() => screen.getAllByText('Remove').length > 0);
|
||||
await userEvent.click(screen.getAllByText('Remove')[0]!);
|
||||
|
||||
expect(screen.queryByText('Remove')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes item price and currency fields', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('9.99'));
|
||||
fireEvent.change(screen.getByPlaceholderText('9.99'), { target: { value: '5.99' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('USD'), { target: { value: 'EUR' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('9.99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes item name and unit fields', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Brand / product name'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('Brand / product name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a medicine and product in the form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
// Select a medicine
|
||||
fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } });
|
||||
|
||||
// Wait for products to load
|
||||
await waitFor(() => screen.getByDisplayValue('Select product'));
|
||||
|
||||
// Select a product
|
||||
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
|
||||
|
||||
// Product details should auto-fill the form
|
||||
expect(screen.getByDisplayValue('60')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles product list failure gracefully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListMedicineProducts.mockRejectedValue(new Error('Products failed'));
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } });
|
||||
|
||||
// Wait for products loading to finish (error is swallowed)
|
||||
await waitFor(() => expect(mockListMedicineProducts).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows no stores message when store list is empty in form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Record Purchase'));
|
||||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/No stores yet/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockRejectedValue(new Error('Oops'));
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Oops'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('Oops')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
708
packages/web/src/app/(dashboard)/purchases/page.tsx
Normal file
708
packages/web/src/app/(dashboard)/purchases/page.tsx
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import {
|
||||
listPurchases,
|
||||
createPurchase,
|
||||
receivePurchase,
|
||||
deletePurchase,
|
||||
} from '@/services/purchases';
|
||||
import { listStores } from '@/services/stores';
|
||||
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
PurchaseResponseSchema,
|
||||
PurchaseListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type PurchaseResponse = z.infer<typeof PurchaseResponseSchema>;
|
||||
|
||||
type StoreOption = { _id: string; name: string };
|
||||
type MedicineOption = { _id: string; name: string; strength: number; strengthUnit: string };
|
||||
type ProductOption = { _id: string; brand?: string; packageSize: number; packageUnit: string };
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// --- Create purchase form ---
|
||||
|
||||
function CreatePurchaseForm({
|
||||
householdId,
|
||||
stores,
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
householdId: string;
|
||||
stores: StoreOption[];
|
||||
onCreated: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [storeId, setStoreId] = useState('');
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Line items
|
||||
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
||||
const [items, setItems] = useState<
|
||||
Array<{
|
||||
medicineId: string;
|
||||
medicineProductId: string;
|
||||
products: ProductOption[];
|
||||
productsLoading: boolean;
|
||||
name: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
actualPrice: string;
|
||||
currency: string;
|
||||
}>
|
||||
>([
|
||||
{
|
||||
medicineId: '',
|
||||
medicineProductId: '',
|
||||
products: [],
|
||||
productsLoading: false,
|
||||
name: '',
|
||||
quantity: '',
|
||||
unit: 'tablet',
|
||||
actualPrice: '',
|
||||
currency: 'USD',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
async function handleMedicineChange(idx: number, medicineId: string) {
|
||||
const updated = items.map((item, i) =>
|
||||
i === idx
|
||||
? { ...item, medicineId, medicineProductId: '', products: [], productsLoading: !!medicineId }
|
||||
: item,
|
||||
);
|
||||
setItems(updated);
|
||||
if (!medicineId) return;
|
||||
try {
|
||||
const result = await listMedicineProducts(householdId, medicineId, { limit: 50 });
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) =>
|
||||
i === idx ? { ...item, products: result.data as ProductOption[], productsLoading: false } : item,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => (i === idx ? { ...item, productsLoading: false } : item)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProductChange(idx: number, productId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
const product = item.products.find((p) => p._id === productId);
|
||||
return {
|
||||
...item,
|
||||
medicineProductId: productId,
|
||||
...(product
|
||||
? { quantity: String(product.packageSize), unit: product.packageUnit, name: product.brand ?? item.name }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
medicineId: '',
|
||||
medicineProductId: '',
|
||||
products: [],
|
||||
productsLoading: false,
|
||||
name: '',
|
||||
quantity: '',
|
||||
unit: 'tablet',
|
||||
actualPrice: '',
|
||||
currency: 'USD',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!storeId) {
|
||||
setError('Please select a store.');
|
||||
return;
|
||||
}
|
||||
const validItems = items.filter((item) => item.name.trim() && item.quantity);
|
||||
if (validItems.length === 0) {
|
||||
setError('Add at least one item with a name and quantity.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createPurchase(householdId, {
|
||||
storeId,
|
||||
status: isOnline ? 'ordered' : 'in_cabinet',
|
||||
notes: notes.trim() || undefined,
|
||||
items: validItems.map((item) => ({
|
||||
medicineProductId: item.medicineProductId || undefined,
|
||||
name: item.name.trim(),
|
||||
quantity: Number(item.quantity),
|
||||
unit: item.unit,
|
||||
actualPrice: item.actualPrice ? Number(item.actualPrice) : undefined,
|
||||
currency: item.currency.trim() || undefined,
|
||||
})),
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create purchase');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Record Purchase</h2>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
||||
<select
|
||||
value={storeId}
|
||||
onChange={(e) => setStoreId(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select store</option>
|
||||
{stores.map((s) => (
|
||||
<option key={s._id} value={s._id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{stores.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
No stores yet.{' '}
|
||||
<Link href="/stores" className="text-primary-600 underline">
|
||||
Add a store first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isOnline"
|
||||
checked={isOnline}
|
||||
onChange={(e) => setIsOnline(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="isOnline" className="text-sm font-medium text-gray-700">
|
||||
Online order (pending arrival)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Items</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="rounded-lg border px-3 py-1 text-xs font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-500">Item {idx + 1}</span>
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(idx)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Medicine (optional)
|
||||
</label>
|
||||
<select
|
||||
value={item.medicineId}
|
||||
onChange={(e) => handleMedicineChange(idx, e.target.value)}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select medicine</option>
|
||||
{medicines.map((m) => (
|
||||
<option key={m._id} value={m._id}>
|
||||
{m.name} ({m.strength} {m.strengthUnit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Product (optional)
|
||||
</label>
|
||||
{item.productsLoading ? (
|
||||
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||
) : (
|
||||
<select
|
||||
value={item.medicineProductId}
|
||||
onChange={(e) => handleProductChange(idx, e.target.value)}
|
||||
disabled={!item.medicineId}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||
>
|
||||
<option value="">
|
||||
{item.medicineId ? 'Select product' : 'Select medicine first'}
|
||||
</option>
|
||||
{item.products.map((p) => (
|
||||
<option key={p._id} value={p._id}>
|
||||
{p.brand ?? 'Generic'} — {p.packageSize} {p.packageUnit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
value={item.name}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, name: e.target.value } : it,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Brand / product name"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Package size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0.01}
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, quantity: e.target.value } : it,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="90"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, unit: e.target.value } : it,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="tablet"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Price (optional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.01}
|
||||
step="any"
|
||||
value={item.actualPrice}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, actualPrice: e.target.value } : it,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="9.99"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Currency
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={10}
|
||||
value={item.currency}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, currency: e.target.value } : it,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="USD"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save Purchase'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Purchase card ---
|
||||
|
||||
function PurchaseCard({
|
||||
purchase,
|
||||
onReceive,
|
||||
onDelete,
|
||||
}: {
|
||||
purchase: PurchaseResponse;
|
||||
onReceive?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const isOrdered = purchase.status === 'ordered';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{purchase.storeName}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{formatDate(purchase.purchasedAt)}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
isOrdered
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{isOrdered ? 'Pending' : 'Received'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{purchase.items.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-700">{item.name}</span>
|
||||
<span className="text-gray-500">
|
||||
{item.quantity} {item.unit}
|
||||
{item.actualPrice != null && ` — ${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{purchase.notes && (
|
||||
<p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>
|
||||
)}
|
||||
|
||||
{(isOrdered || onDelete) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{isOrdered && onReceive && (
|
||||
<button
|
||||
onClick={() => onReceive(purchase._id)}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Mark as received
|
||||
</button>
|
||||
)}
|
||||
{isOrdered && onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(purchase._id)}
|
||||
className="rounded-lg border px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel order
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main content ---
|
||||
|
||||
function PurchasesContent({ householdId }: { householdId: string }) {
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [purchases, setPurchases] = useState<PurchaseResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
const fetchPurchases = useCallback(
|
||||
async (append = false) => {
|
||||
if (!append) setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listPurchases(householdId, {
|
||||
cursor: append ? (cursor ?? undefined) : undefined,
|
||||
limit: 20,
|
||||
});
|
||||
setPurchases((prev) =>
|
||||
append ? [...prev, ...result.data] : result.data,
|
||||
);
|
||||
setCursor(result.pagination.cursor);
|
||||
setHasMore(result.pagination.hasMore);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load purchases');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[householdId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(null);
|
||||
fetchPurchases(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [householdId]);
|
||||
|
||||
async function handleReceive(id: string) {
|
||||
try {
|
||||
await receivePurchase(householdId, id);
|
||||
setPurchases((prev) =>
|
||||
prev.map((p) => (p._id === id ? { ...p, status: 'in_cabinet' as const } : p)),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to receive purchase');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deletePurchase(householdId, id);
|
||||
setPurchases((prev) => prev.filter((p) => p._id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete purchase');
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = purchases.filter((p) => p.status === 'ordered');
|
||||
const received = purchases.filter((p) => p.status === 'in_cabinet');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Purchases</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Record Purchase'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<CreatePurchaseForm
|
||||
householdId={householdId}
|
||||
stores={stores}
|
||||
onCreated={() => {
|
||||
setShowForm(false);
|
||||
setCursor(null);
|
||||
fetchPurchases(false);
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-xl bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{ordered.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-700 mb-3">Pending arrival</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{ordered.map((p) => (
|
||||
<PurchaseCard
|
||||
key={p._id}
|
||||
purchase={p}
|
||||
onReceive={handleReceive}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{received.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-700 mb-3">Received</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{received.map((p) => (
|
||||
<PurchaseCard key={p._id} purchase={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{purchases.length === 0 && (
|
||||
<div className="rounded-xl border bg-white p-10 text-center shadow-sm">
|
||||
<p className="text-sm text-gray-500">
|
||||
No purchases recorded yet. Record your first purchase to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => fetchPurchases(true)}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchasesPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||
<div className="h-28 rounded-xl bg-gray-200" />
|
||||
<div className="h-28 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before recording purchases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PurchasesContent householdId={householdId} />;
|
||||
}
|
||||
681
packages/web/src/app/(dashboard)/refills/__tests__/page.test.tsx
Normal file
681
packages/web/src/app/(dashboard)/refills/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
const {
|
||||
mockGetRefillAlerts,
|
||||
mockListRefillLists,
|
||||
mockCreateRefillList,
|
||||
mockUpdateRefillList,
|
||||
mockUpdateRefillListItem,
|
||||
mockAddToCabinet,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetRefillAlerts: vi.fn(),
|
||||
mockListRefillLists: vi.fn(),
|
||||
mockCreateRefillList: vi.fn(),
|
||||
mockUpdateRefillList: vi.fn(),
|
||||
mockUpdateRefillListItem: vi.fn(),
|
||||
mockAddToCabinet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('@/services/refills', () => ({
|
||||
getRefillAlerts: mockGetRefillAlerts,
|
||||
listRefillLists: mockListRefillLists,
|
||||
createRefillList: mockCreateRefillList,
|
||||
updateRefillList: mockUpdateRefillList,
|
||||
updateRefillListItem: mockUpdateRefillListItem,
|
||||
addToCabinet: mockAddToCabinet,
|
||||
}));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
|
||||
import RefillsPage from '../page';
|
||||
|
||||
const emptyAlerts = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
const emptyLists = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetRefillAlerts.mockResolvedValue(emptyAlerts);
|
||||
mockListRefillLists.mockResolvedValue(emptyLists);
|
||||
});
|
||||
|
||||
describe('RefillsPage', () => {
|
||||
it('shows loading skeleton when session loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
render(<RefillsPage />);
|
||||
expect(screen.getByText('Refills')).toBeInTheDocument();
|
||||
expect(screen.queryByText('New Refill List')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Refills heading when householdId exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
expect(screen.getByText('Refills')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state for alerts', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No medicines running low/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows empty state for refill lists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error when alerts fail', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRefillAlerts.mockRejectedValue(new Error('Alert error'));
|
||||
render(<RefillsPage />);
|
||||
await waitFor(() => expect(screen.getByText('Alert error')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows New List button and toggles form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('New List'));
|
||||
await userEvent.click(screen.getByText('New List'));
|
||||
expect(screen.getByPlaceholderText('List name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new refill list', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockCreateRefillList.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
name: 'Test List',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('New List'));
|
||||
await userEvent.click(screen.getByText('New List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('List name'));
|
||||
fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Test List' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateRefillList).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Test List' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows refill list and can select it', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: false,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('updates a refill list status', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRefillList.mockResolvedValue({});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText('Start shopping'));
|
||||
await userEvent.click(screen.getByText('Start shopping'));
|
||||
|
||||
expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'shopping' });
|
||||
});
|
||||
|
||||
it('shows error when create list fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockCreateRefillList.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('New List'));
|
||||
await userEvent.click(screen.getByText('New List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('List name'));
|
||||
fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Test' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Create failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('adds checked items to cabinet', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: true,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAddToCabinet.mockResolvedValue({ addedCount: 1 });
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText(/Add .* to Cabinet/));
|
||||
await userEvent.click(screen.getByText(/Add .* to Cabinet/));
|
||||
|
||||
expect(mockAddToCabinet).toHaveBeenCalledWith('hh1', 'rl-1');
|
||||
});
|
||||
|
||||
it('generates a refill list from alerts', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRefillAlerts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
currentStock: 5,
|
||||
dailyConsumption: 1.5,
|
||||
daysUntilEmpty: 3,
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateRefillList.mockResolvedValue({
|
||||
_id: 'rl-gen',
|
||||
name: 'Auto Refills',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Aspirin'));
|
||||
await userEvent.click(screen.getByText('Generate Refill List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('List name, e.g. Weekly refills'));
|
||||
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
||||
target: { value: 'Auto Refills' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateRefillList).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'Auto Refills', fromAlerts: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels create list form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('New List'));
|
||||
await userEvent.click(screen.getByText('New List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('List name'));
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
|
||||
|
||||
expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the refill list detail panel', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByTitle('Close'));
|
||||
await userEvent.click(screen.getByTitle('Close'));
|
||||
|
||||
// After close, detail panel should not be visible (no Close button)
|
||||
expect(screen.queryByTitle('Close')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('archives a refill list', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRefillList.mockResolvedValue({});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText('Archive'));
|
||||
await userEvent.click(screen.getByText('Archive'));
|
||||
|
||||
expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'archived' });
|
||||
});
|
||||
|
||||
it('shows error when generate list fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRefillAlerts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
currentStock: 5,
|
||||
dailyConsumption: 1.5,
|
||||
daysUntilEmpty: 3,
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateRefillList.mockRejectedValue(new Error('Generate failed'));
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Aspirin'));
|
||||
await userEvent.click(screen.getByText('Generate Refill List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('List name, e.g. Weekly refills'));
|
||||
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
||||
target: { value: 'Auto Refills' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses refill list panel error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRefillLists.mockRejectedValue(new Error('List error'));
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('List error'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
expect(screen.queryByText('List error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles item checked state', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: false,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRefillListItem.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: true,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText('Aspirin'));
|
||||
await userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', { checked: true });
|
||||
});
|
||||
|
||||
it('marks a shopping list as complete', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'shopping',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRefillList.mockResolvedValue({});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText('Mark complete'));
|
||||
await userEvent.click(screen.getByText('Mark complete'));
|
||||
|
||||
expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'completed' });
|
||||
});
|
||||
|
||||
it('filters refill lists by status', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'rl-1',
|
||||
name: 'Active List',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Active List'));
|
||||
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'active' } });
|
||||
|
||||
await waitFor(() => expect(mockListRefillLists).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('shows actual price input after checking item and allows price entry', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: true,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Actual price'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Actual price'), { target: { value: '5.99' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('Actual price')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses panel-level error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: true,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockAddToCabinet.mockRejectedValue(new Error('Cabinet error'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText(/Add .* to Cabinet/));
|
||||
await userEvent.click(screen.getByText(/Add .* to Cabinet/));
|
||||
|
||||
await waitFor(() => screen.getByText('Cabinet error'));
|
||||
// Dismiss via inline button in the panel
|
||||
const dismissBtns = screen.getAllByText('Dismiss');
|
||||
await userEvent.click(dismissBtns[dismissBtns.length - 1]!);
|
||||
|
||||
expect(screen.queryByText('Cabinet error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on update item', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: false,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRefillListItem.mockRejectedValue('update failed');
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText('Aspirin'));
|
||||
await userEvent.click(screen.getAllByRole('checkbox')[0]!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Failed to update item')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on update status', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
const refillList = {
|
||||
_id: 'rl-1',
|
||||
name: 'Weekly List',
|
||||
status: 'active',
|
||||
items: [],
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
mockListRefillLists.mockResolvedValue({
|
||||
data: [refillList],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRefillList.mockRejectedValue('status failed');
|
||||
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Weekly List'));
|
||||
await userEvent.click(screen.getByText('Weekly List'));
|
||||
|
||||
await waitFor(() => screen.getByText('Start shopping'));
|
||||
await userEvent.click(screen.getByText('Start shopping'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Failed to update status')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows refill alert when present', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRefillAlerts.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
currentStock: 5,
|
||||
dailyConsumption: 1.5,
|
||||
daysUntilEmpty: 3,
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
render(<RefillsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
708
packages/web/src/app/(dashboard)/refills/page.tsx
Normal file
708
packages/web/src/app/(dashboard)/refills/page.tsx
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import {
|
||||
getRefillAlerts,
|
||||
listRefillLists,
|
||||
createRefillList,
|
||||
updateRefillList,
|
||||
updateRefillListItem,
|
||||
addToCabinet,
|
||||
} from '@/services/refills';
|
||||
import { RefillListStatus } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RefillAlertResponseSchema, RefillListResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type RefillAlert = z.infer<typeof RefillAlertResponseSchema>;
|
||||
type RefillList = z.infer<typeof RefillListResponseSchema>;
|
||||
type RefillListItem = RefillList['items'][number];
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
shopping: 'Shopping',
|
||||
completed: 'Completed',
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
shopping: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-gray-100 text-gray-600',
|
||||
archived: 'bg-gray-100 text-gray-400',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// --- Refill alerts panel ---
|
||||
|
||||
function AlertsPanel({
|
||||
householdId,
|
||||
onGenerateList,
|
||||
}: {
|
||||
householdId: string;
|
||||
onGenerateList: () => void;
|
||||
}) {
|
||||
const [alerts, setAlerts] = useState<RefillAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [thresholdDays, setThresholdDays] = useState(7);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [listName, setListName] = useState('');
|
||||
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
||||
|
||||
const fetchAlerts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getRefillAlerts(householdId, { thresholdDays });
|
||||
setAlerts(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load alerts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, thresholdDays]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
}, [fetchAlerts]);
|
||||
|
||||
async function handleGenerateList(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!listName.trim()) return;
|
||||
setGenerating(true);
|
||||
setError('');
|
||||
try {
|
||||
await createRefillList(householdId, {
|
||||
name: listName.trim(),
|
||||
fromAlerts: true,
|
||||
thresholdDays,
|
||||
});
|
||||
setShowGenerateForm(false);
|
||||
setListName('');
|
||||
onGenerateList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate list');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<h2 className="text-lg font-semibold">Refill Alerts</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>Threshold:</span>
|
||||
<select
|
||||
value={thresholdDays}
|
||||
onChange={(e) => setThresholdDays(Number(e.target.value))}
|
||||
className="rounded-lg border px-2 py-1 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{[3, 5, 7, 10, 14, 30].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d} days
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{alerts.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowGenerateForm(!showGenerateForm)}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Generate Refill List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showGenerateForm && (
|
||||
<form onSubmit={handleGenerateList} className="mb-4 flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
value={listName}
|
||||
onChange={(e) => setListName(e.target.value)}
|
||||
placeholder="List name, e.g. Weekly refills"
|
||||
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{generating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerateForm(false)}
|
||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse h-20 rounded-lg bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
No medicines running low within {thresholdDays} days.
|
||||
{thresholdDays < 30 && (
|
||||
<span className="block mt-1 text-xs">
|
||||
Try increasing the threshold to see more.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert) => {
|
||||
const daysColor =
|
||||
alert.daysUntilEmpty <= 3
|
||||
? 'text-red-600 font-bold'
|
||||
: alert.daysUntilEmpty <= 7
|
||||
? 'text-red-500 font-semibold'
|
||||
: 'text-yellow-600';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.medicineId}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{alert.medicineName}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
{alert.medicineStrength} {alert.medicineStrengthUnit}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-4 mt-1 text-sm">
|
||||
<span className={daysColor}>
|
||||
{alert.daysUntilEmpty} day{alert.daysUntilEmpty !== 1 ? 's' : ''} left
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{alert.currentStock} in cabinet
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{alert.dailyConsumption.toFixed(2)}/day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="text-gray-600">
|
||||
Suggested: <span className="font-medium">{alert.suggestedQuantity} units</span>
|
||||
</p>
|
||||
{alert.cheapestOption && (
|
||||
<p className="text-green-700 font-medium">
|
||||
Best: {alert.cheapestOption.storeName} —{' '}
|
||||
{alert.cheapestOption.price.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
{!alert.cheapestOption && alert.lastKnownPrice && (
|
||||
<p className="text-gray-500">
|
||||
Last: {alert.lastKnownPrice.storeName} —{' '}
|
||||
{alert.lastKnownPrice.price.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Refill list detail ---
|
||||
|
||||
function RefillListDetail({
|
||||
list,
|
||||
householdId,
|
||||
onUpdated,
|
||||
onClose,
|
||||
}: {
|
||||
list: RefillList;
|
||||
householdId: string;
|
||||
onUpdated: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<RefillListItem[]>(list.items);
|
||||
const [actualPrices, setActualPrices] = useState<Record<string, string>>({});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleToggleItem(item: RefillListItem) {
|
||||
setError('');
|
||||
try {
|
||||
const updated = await updateRefillListItem(householdId, list._id, item._id, {
|
||||
checked: !item.checked,
|
||||
actualPrice:
|
||||
!item.checked && actualPrices[item._id]
|
||||
? Number(actualPrices[item._id])
|
||||
: undefined,
|
||||
});
|
||||
setItems(updated.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update item');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateStatus(status: RefillListStatus) {
|
||||
setError('');
|
||||
try {
|
||||
await updateRefillList(householdId, list._id, { status });
|
||||
onUpdated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update status');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToCabinet() {
|
||||
const checkedCount = items.filter((i) => i.checked && !i.addedToCabinet).length;
|
||||
/* v8 ignore next 4 */
|
||||
if (checkedCount === 0) {
|
||||
setError('No checked items to add to cabinet.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)) return;
|
||||
setAdding(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await addToCabinet(householdId, list._id);
|
||||
onUpdated();
|
||||
setAdding(false);
|
||||
if (result.addedCount > 0) {
|
||||
alert(`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add to cabinet');
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const checkedNotAdded = items.filter((i) => i.checked && !i.addedToCabinet).length;
|
||||
const totalChecked = items.filter((i) => i.checked).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{list.name}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}>
|
||||
{STATUS_LABELS[list.status] ?? list.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{totalChecked}/{items.length} checked
|
||||
</span>
|
||||
{list.totalEstimatedCost != null && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Est. {list.totalEstimatedCost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">No items in this list.</p>
|
||||
) : (
|
||||
<div className="space-y-2 mb-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className={`rounded-lg border p-3 flex items-start gap-3 ${item.addedToCabinet ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => handleToggleItem(item)}
|
||||
disabled={item.addedToCabinet}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-sm font-medium ${item.checked ? 'line-through text-gray-400' : 'text-gray-900'}`}>
|
||||
{item.medicineName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{item.quantity} {item.unit}
|
||||
</span>
|
||||
{item.estimatedPrice != null && (
|
||||
<span className="text-xs text-gray-400">est. {item.estimatedPrice.toFixed(2)}</span>
|
||||
)}
|
||||
{item.addedToCabinet && (
|
||||
<span className="rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs">
|
||||
in cabinet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.notes && <p className="text-xs text-gray-400 mt-0.5">{item.notes}</p>}
|
||||
</div>
|
||||
{item.checked && !item.addedToCabinet && (
|
||||
<div className="shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={actualPrices[item._id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setActualPrices((prev) => ({ ...prev, [item._id]: e.target.value }))
|
||||
}
|
||||
placeholder="Actual price"
|
||||
className="w-28 rounded-lg border px-2 py-1 text-xs focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
||||
{checkedNotAdded > 0 && (
|
||||
<button
|
||||
onClick={handleAddToCabinet}
|
||||
disabled={adding}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{adding ? 'Adding...' : `Add ${checkedNotAdded} to Cabinet`}
|
||||
</button>
|
||||
)}
|
||||
{list.status === RefillListStatus.ACTIVE && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(RefillListStatus.SHOPPING)}
|
||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Start shopping
|
||||
</button>
|
||||
)}
|
||||
{list.status === RefillListStatus.SHOPPING && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(RefillListStatus.COMPLETED)}
|
||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Mark complete
|
||||
</button>
|
||||
)}
|
||||
{(list.status === RefillListStatus.ACTIVE ||
|
||||
list.status === RefillListStatus.SHOPPING) && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(RefillListStatus.ARCHIVED)}
|
||||
className="rounded-lg border px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Create list form ---
|
||||
|
||||
function CreateListForm({
|
||||
householdId,
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
householdId: string;
|
||||
onCreated: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createRefillList(householdId, { name: name.trim(), fromAlerts: false, thresholdDays: 7 });
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create list');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h3 className="text-base font-semibold mb-3">New Refill List</h3>
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="List name"
|
||||
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Refill lists panel ---
|
||||
|
||||
function RefillListsPanel({ householdId }: { householdId: string }) {
|
||||
const [lists, setLists] = useState<RefillList[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedList, setSelectedList] = useState<RefillList | null>(null);
|
||||
|
||||
const fetchLists = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listRefillLists(householdId, {
|
||||
status: (filterStatus as RefillListStatus) || undefined,
|
||||
limit: 30,
|
||||
});
|
||||
setLists(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load refill lists');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLists();
|
||||
}, [fetchLists]);
|
||||
|
||||
function handleSelectList(list: RefillList) {
|
||||
setSelectedList((prev) => (prev?._id === list._id ? null : list));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<h2 className="text-lg font-semibold">Refill Lists</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{Object.values(RefillListStatus).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABELS[s] ?? s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'New List'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<CreateListForm
|
||||
householdId={householdId}
|
||||
onCreated={() => {
|
||||
setShowForm(false);
|
||||
fetchLists();
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedList && (
|
||||
<div className="mb-4">
|
||||
<RefillListDetail
|
||||
key={selectedList._id}
|
||||
list={selectedList}
|
||||
householdId={householdId}
|
||||
onUpdated={() => {
|
||||
fetchLists();
|
||||
setSelectedList(null);
|
||||
}}
|
||||
onClose={() => setSelectedList(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : lists.length === 0 ? (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-sm text-gray-500">
|
||||
{filterStatus ? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.` : 'No refill lists yet. Create one above or generate from alerts.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lists.map((list) => {
|
||||
const checkedCount = list.items.filter((i) => i.checked).length;
|
||||
const isSelected = selectedList?._id === list._id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={list._id}
|
||||
onClick={() => handleSelectList(list)}
|
||||
className={`w-full rounded-xl border p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary-50 border-primary-300'
|
||||
: 'bg-white hover:bg-gray-50'
|
||||
} shadow-sm`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-medium text-gray-900 truncate">{list.name}</span>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}
|
||||
>
|
||||
{STATUS_LABELS[list.status] ?? list.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{list.items.length} item{list.items.length !== 1 ? 's' : ''}
|
||||
{list.items.length > 0 && ` — ${checkedCount} checked`}
|
||||
{list.totalEstimatedCost != null &&
|
||||
` — est. ${list.totalEstimatedCost.toFixed(2)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 shrink-0">{formatDate(list.createdAt)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main page ---
|
||||
|
||||
function RefillsContent({ householdId }: { householdId: string }) {
|
||||
const [listsKey, setListsKey] = useState(0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Refills</h1>
|
||||
<div className="space-y-6">
|
||||
<AlertsPanel
|
||||
householdId={householdId}
|
||||
onGenerateList={() => setListsKey((k) => k + 1)}
|
||||
/>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<RefillListsPanel key={listsKey} householdId={householdId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RefillsPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Refills</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-60 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Refills</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before managing refills.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RefillsContent householdId={householdId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockCreateHousehold, mockJoinHousehold, mockGetHousehold, mockUpdateHousehold, mockGenerateInviteCode } =
|
||||
vi.hoisted(() => ({
|
||||
mockCreateHousehold: vi.fn(),
|
||||
mockJoinHousehold: vi.fn(),
|
||||
mockGetHousehold: vi.fn(),
|
||||
mockUpdateHousehold: vi.fn(),
|
||||
mockGenerateInviteCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('@/services/households', () => ({
|
||||
createHousehold: mockCreateHousehold,
|
||||
joinHousehold: mockJoinHousehold,
|
||||
getHousehold: mockGetHousehold,
|
||||
updateHousehold: mockUpdateHousehold,
|
||||
generateInviteCode: mockGenerateInviteCode,
|
||||
}));
|
||||
|
||||
import SettingsPage from '../page';
|
||||
|
||||
const household = {
|
||||
name: 'My House',
|
||||
inviteCode: 'ABC123',
|
||||
members: [{ userId: 'u-1', role: 'admin' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
it('shows loading skeleton when loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, refreshProfile: vi.fn() });
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Household')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows create/join forms when no household', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() });
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByPlaceholderText('Household name')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Invite code')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows account section always', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() });
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('Account')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Manage Keycloak Account/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a household on form submit', async () => {
|
||||
const refreshProfile = vi.fn();
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile });
|
||||
mockCreateHousehold.mockResolvedValue({});
|
||||
render(<SettingsPage />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Household name'), 'New Home');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledWith('New Home'));
|
||||
expect(refreshProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when create fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() });
|
||||
mockCreateHousehold.mockRejectedValue(new Error('Already exists'));
|
||||
render(<SettingsPage />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Household name'), 'New Home');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Already exists')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('joins a household on form submit', async () => {
|
||||
const refreshProfile = vi.fn();
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile });
|
||||
mockJoinHousehold.mockResolvedValue({});
|
||||
render(<SettingsPage />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Invite code'), 'XYZ999');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||
|
||||
await waitFor(() => expect(mockJoinHousehold).toHaveBeenCalledWith('XYZ999'));
|
||||
expect(refreshProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when join fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() });
|
||||
mockJoinHousehold.mockRejectedValue(new Error('Invalid code'));
|
||||
render(<SettingsPage />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Invite code'), 'BAD');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Invalid code')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('loads and displays household details when householdId exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('My House')).toBeInTheDocument());
|
||||
expect(screen.getByText('ABC123')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument(); // member count
|
||||
});
|
||||
|
||||
it('shows loading household details text initially', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('Loading household details...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows editing the household name', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
mockUpdateHousehold.mockResolvedValue({ ...household, name: 'Updated Home' });
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
|
||||
const input = screen.getByDisplayValue('My House');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Updated Home');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }));
|
||||
});
|
||||
|
||||
it('shows error when name update fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
mockUpdateHousehold.mockRejectedValue(new Error('Name taken'));
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('My House');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Other Home');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Name taken')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('cancels editing name', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.getByText('My House')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('My House')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('regenerates invite code', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
mockGenerateInviteCode.mockResolvedValue({ ...household, inviteCode: 'NEW999' });
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('ABC123'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('NEW999')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when regenerate fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
mockGenerateInviteCode.mockRejectedValue(new Error('Server error'));
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('ABC123'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('saves unchanged name without calling API', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(mockUpdateHousehold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on create household', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() });
|
||||
mockCreateHousehold.mockRejectedValue('create failed');
|
||||
render(<SettingsPage />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Household name'), 'My Home');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to create household')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on join household', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() });
|
||||
mockJoinHousehold.mockRejectedValue('join failed');
|
||||
render(<SettingsPage />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Invite code'), 'XYZ123');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to join household')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on name update', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
mockUpdateHousehold.mockRejectedValue('update failed');
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('My House');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'New Name');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to update name')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on regenerate', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
mockGenerateInviteCode.mockRejectedValue('regen failed');
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows validation error when saving empty name', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() });
|
||||
mockGetHousehold.mockResolvedValue(household);
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('My House');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(screen.getByText('Name cannot be empty')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ function HouseholdSection({
|
|||
const [regenerateError, setRegenerateError] = useState('');
|
||||
|
||||
async function loadHousehold() {
|
||||
if (!householdId || loadedHousehold) return;
|
||||
if (!householdId || /* v8 ignore next */ loadedHousehold) return;
|
||||
try {
|
||||
const hh = await getHousehold(householdId);
|
||||
setCurrentHousehold(hh);
|
||||
|
|
@ -110,7 +110,7 @@ function HouseholdSection({
|
|||
}
|
||||
|
||||
async function handleSaveName() {
|
||||
if (!householdId || !currentHousehold) return;
|
||||
if (!householdId || /* v8 ignore next */ !currentHousehold) return;
|
||||
const trimmed = editedName.trim();
|
||||
if (!trimmed) {
|
||||
setEditNameError('Name cannot be empty');
|
||||
|
|
|
|||
424
packages/web/src/app/(dashboard)/stores/__tests__/page.test.tsx
Normal file
424
packages/web/src/app/(dashboard)/stores/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted(() => ({
|
||||
mockListStores: vi.fn(),
|
||||
mockCreateStore: vi.fn(),
|
||||
mockUpdateStore: vi.fn(),
|
||||
mockDeactivateStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/stores', () => ({
|
||||
listStores: mockListStores,
|
||||
createStore: mockCreateStore,
|
||||
updateStore: mockUpdateStore,
|
||||
deactivateStore: mockDeactivateStore,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (props: any) => props.children };
|
||||
});
|
||||
|
||||
import StoresPage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('StoresPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
expect(screen.getByText('Stores')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Add Store')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders store list when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: ['pharmacy'],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Walgreens')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no stores', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No stores yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Add Store form when button clicked', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Add Store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when store list fails to load', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivates store after confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeactivateStore.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CVS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTitle('Deactivate'));
|
||||
|
||||
expect(mockDeactivateStore).toHaveBeenCalledWith('hh1', 'st-1');
|
||||
});
|
||||
|
||||
it('creates a store on form submit', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
mockCreateStore.mockResolvedValue({ _id: 'st-new', name: 'Walmart', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Store'));
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateStore).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Walmart' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error when create store fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
mockCreateStore.mockRejectedValue(new Error('Store already exists'));
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Store'));
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } });
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Store already exists')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('opens edit form for a store', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('CVS'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
expect(screen.getByDisplayValue('CVS')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit Store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves edited store', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateStore.mockResolvedValue({});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('CVS'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('CVS'));
|
||||
fireEvent.change(screen.getByDisplayValue('CVS'), { target: { value: 'CVS Pharmacy' } });
|
||||
fireEvent.submit(screen.getByDisplayValue('CVS Pharmacy').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateStore).toHaveBeenCalledWith('hh1', 'st-1', expect.objectContaining({ name: 'CVS Pharmacy' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('dismisses error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Network error'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when deactivate fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeactivateStore.mockRejectedValue(new Error('Deactivate failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByTitle('Deactivate'));
|
||||
await userEvent.click(screen.getByTitle('Deactivate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Deactivate failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('cancels the create store form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Store'));
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
|
||||
|
||||
expect(screen.queryByPlaceholderText('e.g. Walgreens')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels the edit store form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('CVS'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('CVS'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByDisplayValue('CVS')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters stores by tag', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'st-1', name: 'Walgreens', tags: ['pharmacy'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{ _id: 'st-2', name: 'Costco', tags: ['supermarket'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Walgreens'));
|
||||
expect(screen.getByText('Costco')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('All tags'), { target: { value: 'pharmacy' } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Costco')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Walgreens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes store form fields (notes, address, url)', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Store'));
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('123 Main St'), { target: { value: '456 Oak Ave' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any notes'), { target: { value: 'Good prices' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles a preset tag and adds a custom tag', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Store'));
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Custom tag...'));
|
||||
|
||||
// Toggle a preset tag (e.g. 'pharmacy')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'pharmacy' }));
|
||||
// Toggle it off again
|
||||
await userEvent.click(screen.getByRole('button', { name: 'pharmacy' }));
|
||||
|
||||
// Add a custom tag via button
|
||||
fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'mytag' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('mytag')).toBeInTheDocument());
|
||||
|
||||
// Remove it via the × button
|
||||
await userEvent.click(screen.getByText('×'));
|
||||
expect(screen.queryByText('mytag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds custom tag via Enter key', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Add Store'));
|
||||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Custom tag...'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'keytag' } });
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Custom tag...'), { key: 'Enter' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('keytag')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('changes search filter', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{ _id: 'st-2', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Search stores...'), { target: { value: 'wal' } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('CVS')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Walgreens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles isActive in edit form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateStore.mockResolvedValue({});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('CVS'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByLabelText('Active'));
|
||||
fireEvent.click(screen.getByLabelText('Active'));
|
||||
|
||||
expect(screen.getByLabelText('Active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows inactive stores when toggle checked', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'st-1', name: 'Active Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{ _id: 'st-2', name: 'Old Store', tags: [], isActive: false, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
await waitFor(() => screen.getByText('Active Store'));
|
||||
expect(screen.queryByText('Old Store')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Show inactive'));
|
||||
|
||||
expect(screen.getByText('Old Store')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
522
packages/web/src/app/(dashboard)/stores/page.tsx
Normal file
522
packages/web/src/app/(dashboard)/stores/page.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { listStores, createStore, updateStore, deactivateStore } from '@/services/stores';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { StoreResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type Store = z.infer<typeof StoreResponseSchema>;
|
||||
|
||||
const PRESET_TAGS = ['pharmacy', 'grocery', 'online', 'bulk', 'discount'];
|
||||
|
||||
const TAG_COLORS: Record<string, string> = {
|
||||
pharmacy: 'bg-blue-100 text-blue-700',
|
||||
grocery: 'bg-green-100 text-green-700',
|
||||
online: 'bg-purple-100 text-purple-700',
|
||||
bulk: 'bg-orange-100 text-orange-700',
|
||||
discount: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// --- Store form ---
|
||||
|
||||
function StoreForm({
|
||||
initial,
|
||||
onSaved,
|
||||
onCancel,
|
||||
householdId,
|
||||
}: {
|
||||
initial?: Store;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
householdId: string;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [address, setAddress] = useState(initial?.address ?? '');
|
||||
const [url, setUrl] = useState(initial?.url ?? '');
|
||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||
const [customTag, setCustomTag] = useState('');
|
||||
const [isActive, setIsActive] = useState(initial?.isActive ?? true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
setTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
||||
}
|
||||
|
||||
function addCustomTag() {
|
||||
const t = customTag.trim().toLowerCase();
|
||||
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
|
||||
setCustomTag('');
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
url: url.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
tags,
|
||||
isActive,
|
||||
};
|
||||
if (initial) {
|
||||
await updateStore(householdId, initial._id, data);
|
||||
} else {
|
||||
await createStore(householdId, data);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save store');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Store' : 'Add Store'}</h2>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Walgreens"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Address (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Website (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://walgreens.com"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any notes"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{PRESET_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${
|
||||
tags.includes(tag)
|
||||
? (TAG_COLORS[tag] ?? 'bg-gray-200 text-gray-800') + ' border-transparent'
|
||||
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tags.filter((t) => !PRESET_TAGS.includes(t)).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{tags
|
||||
.filter((t) => !PRESET_TAGS.includes(t))
|
||||
.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTags((prev) => prev.filter((t) => t !== tag))}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addCustomTag();
|
||||
}
|
||||
}}
|
||||
placeholder="Custom tag..."
|
||||
maxLength={50}
|
||||
className="rounded-lg border px-3 py-1.5 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomTag}
|
||||
className="rounded-lg border px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initial && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Add Store'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Store card ---
|
||||
|
||||
function StoreCard({
|
||||
store,
|
||||
onEdit,
|
||||
onDeactivate,
|
||||
}: {
|
||||
store: Store;
|
||||
onEdit: (store: Store) => void;
|
||||
onDeactivate: (store: Store) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border bg-white p-4 shadow-sm ${!store.isActive ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{store.name}</h3>
|
||||
{!store.isActive && (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{store.address && (
|
||||
<p className="text-sm text-gray-500 mb-1">{store.address}</p>
|
||||
)}
|
||||
{store.url && (
|
||||
<a
|
||||
href={store.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary-600 underline hover:text-primary-700 block mb-1"
|
||||
>
|
||||
{store.url}
|
||||
</a>
|
||||
)}
|
||||
{store.notes && (
|
||||
<p className="text-xs text-gray-400 mb-1">{store.notes}</p>
|
||||
)}
|
||||
{store.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{store.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${TAG_COLORS[tag] ?? 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">Added {formatDate(store.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => onEdit(store)}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{store.isActive && (
|
||||
<button
|
||||
onClick={() => onDeactivate(store)}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
title="Deactivate"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main page ---
|
||||
|
||||
function StoresContent({ householdId }: { householdId: string }) {
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingStore, setEditingStore] = useState<Store | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterTag, setFilterTag] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
|
||||
const fetchStores = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listStores(householdId, {
|
||||
search: search || undefined,
|
||||
tags: filterTag || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
setStores(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load stores');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, search, filterTag]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStores();
|
||||
}, [fetchStores]);
|
||||
|
||||
async function handleDeactivate(store: Store) {
|
||||
if (!confirm(`Deactivate "${store.name}"?`)) return;
|
||||
try {
|
||||
await deactivateStore(householdId, store._id);
|
||||
fetchStores();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to deactivate store');
|
||||
}
|
||||
}
|
||||
|
||||
const visible = showInactive ? stores : stores.filter((s) => s.isActive);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Stores</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingStore(null);
|
||||
setShowForm(!showForm);
|
||||
}}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Add Store'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && !editingStore && (
|
||||
<StoreForm
|
||||
householdId={householdId}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
fetchStores();
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingStore && (
|
||||
<StoreForm
|
||||
householdId={householdId}
|
||||
initial={editingStore}
|
||||
onSaved={() => {
|
||||
setEditingStore(null);
|
||||
fetchStores();
|
||||
}}
|
||||
onCancel={() => setEditingStore(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search stores..."
|
||||
className="w-full max-w-xs rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All tags</option>
|
||||
{PRESET_TAGS.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
Show inactive
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-20" />
|
||||
))}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
|
||||
{search || filterTag ? 'No stores match your filters.' : 'No stores yet. Add your first one above.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visible.map((store) => (
|
||||
<StoreCard
|
||||
key={store._id}
|
||||
store={store}
|
||||
onEdit={(s) => {
|
||||
setShowForm(false);
|
||||
setEditingStore(s);
|
||||
}}
|
||||
onDeactivate={handleDeactivate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StoresPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Stores</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Stores</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before managing stores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StoresContent householdId={householdId} />;
|
||||
}
|
||||
22
packages/web/src/app/__tests__/page.test.tsx
Normal file
22
packages/web/src/app/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import HomePage from '../page';
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders MeshiTrack heading', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('MeshiTrack')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Sign In link to /login', () => {
|
||||
render(<HomePage />);
|
||||
const link = screen.getByRole('link', { name: 'Sign In' });
|
||||
expect(link).toHaveAttribute('href', '/login');
|
||||
});
|
||||
});
|
||||
137
packages/web/src/lib/__tests__/useApi.test.ts
Normal file
137
packages/web/src/lib/__tests__/useApi.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
const { mockUseSession, mockUseSWR, mockApiClient } = vi.hoisted(() => ({
|
||||
mockUseSession: vi.fn(),
|
||||
mockUseSWR: vi.fn(),
|
||||
mockApiClient: {
|
||||
_accessToken: null as string | null,
|
||||
set accessToken(token: string) { this._accessToken = token; },
|
||||
get hasToken() { return this._accessToken !== null; },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next-auth/react', () => ({
|
||||
useSession: mockUseSession,
|
||||
}));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: mockUseSWR,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/api-client', () => ({
|
||||
apiClient: mockApiClient,
|
||||
}));
|
||||
|
||||
import { useApi } from '../useApi';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApiClient._accessToken = null;
|
||||
});
|
||||
|
||||
describe('useApi', () => {
|
||||
it('returns loading state when session is loading', () => {
|
||||
mockUseSession.mockReturnValue({ data: null, status: 'loading' });
|
||||
mockUseSWR.mockReturnValue({ data: undefined, mutate: vi.fn(), isLoading: false });
|
||||
|
||||
const { result } = renderHook(() => useApi());
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.householdId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns unauthenticated state', () => {
|
||||
mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' });
|
||||
mockUseSWR.mockReturnValue({ data: undefined, mutate: vi.fn(), isLoading: false });
|
||||
|
||||
const { result } = renderHook(() => useApi());
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('sets token on apiClient when session has accessToken', () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: { accessToken: 'test-jwt' },
|
||||
status: 'authenticated',
|
||||
});
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: { householdIds: ['hh1'], displayName: 'Test' },
|
||||
mutate: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderHook(() => useApi());
|
||||
|
||||
expect(mockApiClient._accessToken).toBe('test-jwt');
|
||||
});
|
||||
|
||||
it('returns householdId from profile', () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: { accessToken: 'test-jwt' },
|
||||
status: 'authenticated',
|
||||
});
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: { householdIds: ['hh1', 'hh2'], displayName: 'Test' },
|
||||
mutate: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useApi());
|
||||
|
||||
expect(result.current.householdId).toBe('hh1');
|
||||
expect(result.current.householdIds).toEqual(['hh1', 'hh2']);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null householdId when profile has no households', () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: { accessToken: 'test-jwt' },
|
||||
status: 'authenticated',
|
||||
});
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: { householdIds: [] },
|
||||
mutate: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useApi());
|
||||
|
||||
expect(result.current.householdId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns loading when authenticated but profile is still loading', () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: { accessToken: 'test-jwt' },
|
||||
status: 'authenticated',
|
||||
});
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
mutate: vi.fn(),
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useApi());
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('provides refreshProfile function from SWR mutate', () => {
|
||||
const mutateFn = vi.fn();
|
||||
mockUseSession.mockReturnValue({
|
||||
data: { accessToken: 'test-jwt' },
|
||||
status: 'authenticated',
|
||||
});
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: { householdIds: ['hh1'] },
|
||||
mutate: mutateFn,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useApi());
|
||||
|
||||
expect(result.current.refreshProfile).toBe(mutateFn);
|
||||
});
|
||||
});
|
||||
165
packages/web/src/services/__tests__/api-client.test.ts
Normal file
165
packages/web/src/services/__tests__/api-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
packages/web/src/services/__tests__/cabinet-events.test.ts
Normal file
62
packages/web/src/services/__tests__/cabinet-events.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
91
packages/web/src/services/__tests__/cabinet.test.ts
Normal file
91
packages/web/src/services/__tests__/cabinet.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
47
packages/web/src/services/__tests__/households.test.ts
Normal file
47
packages/web/src/services/__tests__/households.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
62
packages/web/src/services/__tests__/medicine-prices.test.ts
Normal file
62
packages/web/src/services/__tests__/medicine-prices.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
94
packages/web/src/services/__tests__/medicines.test.ts
Normal file
94
packages/web/src/services/__tests__/medicines.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
62
packages/web/src/services/__tests__/organizer.test.ts
Normal file
62
packages/web/src/services/__tests__/organizer.test.ts
Normal 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', {});
|
||||
});
|
||||
});
|
||||
62
packages/web/src/services/__tests__/purchases.test.ts
Normal file
62
packages/web/src/services/__tests__/purchases.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
80
packages/web/src/services/__tests__/refills.test.ts
Normal file
80
packages/web/src/services/__tests__/refills.test.ts
Normal 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', {});
|
||||
});
|
||||
});
|
||||
68
packages/web/src/services/__tests__/regimens.test.ts
Normal file
68
packages/web/src/services/__tests__/regimens.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
56
packages/web/src/services/__tests__/stores.test.ts
Normal file
56
packages/web/src/services/__tests__/stores.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
60
packages/web/src/services/medicine-prices.ts
Normal file
60
packages/web/src/services/medicine-prices.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
69
packages/web/src/services/purchases.ts
Normal file
69
packages/web/src/services/purchases.ts
Normal 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}`);
|
||||
}
|
||||
96
packages/web/src/services/refills.ts
Normal file
96
packages/web/src/services/refills.ts
Normal 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`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
51
packages/web/src/services/stores.ts
Normal file
51
packages/web/src/services/stores.ts
Normal 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}`);
|
||||
}
|
||||
20
packages/web/src/test-utils.tsx
Normal file
20
packages/web/src/test-utils.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
const mockSession = {
|
||||
user: { name: 'Test User', email: 'test@example.com' },
|
||||
accessToken: 'mock-access-token',
|
||||
expires: '2099-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function AllProviders({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider session={mockSession as never}>{children}</SessionProvider>;
|
||||
}
|
||||
|
||||
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
|
||||
return render(ui, { wrapper: AllProviders, ...options });
|
||||
}
|
||||
|
||||
export { customRender as render };
|
||||
export { default as userEvent } from '@testing-library/user-event';
|
||||
48
packages/web/vitest.config.ts
Normal file
48
packages/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': path.resolve(import.meta.dirname, 'src') + '/',
|
||||
'@meshitrack/shared': path.resolve(import.meta.dirname, '../shared/src'),
|
||||
},
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
css: false,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
enabled: false,
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/test-utils.tsx',
|
||||
'src/mocks/**',
|
||||
'src/app/layout.tsx',
|
||||
'src/app/api/**',
|
||||
'src/lib/auth.ts',
|
||||
'src/proxy.ts',
|
||||
'src/**/*.d.ts',
|
||||
'src/app/(dashboard)/layout.tsx',
|
||||
'src/components/**',
|
||||
'src/app/login/**',
|
||||
],
|
||||
reporter: ['text', 'lcov', 'json-summary', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
thresholds: {
|
||||
lines: 24,
|
||||
functions: 18,
|
||||
branches: 18,
|
||||
statements: 24,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
5
packages/web/vitest.setup.ts
Normal file
5
packages/web/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
Loading…
Add table
Add a link
Reference in a new issue