Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
337
packages/web/tests/app/(dashboard)/dashboard/page.test.tsx
Normal file
337
packages/web/tests/app/(dashboard)/dashboard/page.test.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
const { mockUseSWR } = vi.hoisted(() => ({ mockUseSWR: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('swr', () => ({ default: mockUseSWR }));
|
||||
|
||||
vi.mock('@/services/cabinet', () => ({
|
||||
getCabinetSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/regimens', () => ({
|
||||
getBurnRates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/purchases', () => ({
|
||||
listPurchases: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/refills', () => ({
|
||||
getRefillAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/cabinet-events', () => ({
|
||||
listCabinetEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
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 '../../../../src/app/(dashboard)/dashboard/page';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
|
||||
mockUseSWR.mockReturnValue({ data: undefined });
|
||||
});
|
||||
|
||||
describe(DashboardPage.name, () => {
|
||||
it('renders heading', () => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading skeleton when session loading', () => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders greeting with user name', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'John Doe' },
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText(/John/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders generic greeting without profile', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, profile: null });
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText(/there/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cabinet items that are in active regimens', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const summaryData = {
|
||||
data: [
|
||||
{ medicineId: '1', medicineName: 'Aspirin', totalQuantity: 50, unit: 'tablets' },
|
||||
{ medicineId: '2', medicineName: 'Ibuprofen', totalQuantity: 100, unit: 'tablets' },
|
||||
],
|
||||
};
|
||||
|
||||
const burnRateData = {
|
||||
data: [
|
||||
{ medicineId: '1', medicineName: 'Aspirin', daysUntilEmpty: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: summaryData };
|
||||
if (key.includes('burn-rates')) return { data: burnRateData };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
// Ibuprofen should NOT be in the document because it has no burn rate
|
||||
expect(screen.queryByText('Ibuprofen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty states when no data', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('No active medicines in regimens.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No pending orders.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows refill alerts with days left', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const refillData = {
|
||||
data: [{ medicineId: 'm1', medicineName: 'Vitamin C', daysUntilEmpty: 5 }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: refillData };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Vitamin C')).toBeInTheDocument();
|
||||
expect(screen.getByText('5d')).toBeInTheDocument();
|
||||
expect(screen.getByText(/critically low/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pending purchases', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const purchaseData = {
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
storeName: 'Pharmacy Plus',
|
||||
items: [{ name: 'A' }, { name: 'B' }],
|
||||
status: 'ordered',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: purchaseData };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Pharmacy Plus')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 items')).toBeInTheDocument();
|
||||
expect(screen.getByText('ordered')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows recent events', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const eventData = {
|
||||
data: [
|
||||
{
|
||||
_id: 'e1',
|
||||
eventType: 'consumed',
|
||||
medicineName: 'Aspirin',
|
||||
createdAt: '2026-01-15T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: eventData };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('consumed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "good shape" message when no critical alerts', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Your cabinet is in good shape.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stat badges with summary data', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary'))
|
||||
return { data: { data: [{ medicineId: '1' }, { medicineId: '2' }, { medicineId: '3' }] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts'))
|
||||
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Total medicines')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Running low')).toHaveLength(2);
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles store without name', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const purchaseData = {
|
||||
data: [{ _id: 'p1', storeName: undefined, items: [{ name: 'A' }], status: 'ordered' }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: purchaseData };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Unknown store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles cabinet item with no name', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const summaryData = {
|
||||
data: [{ medicineId: 'c1', medicineName: undefined, totalQuantity: 10, unit: 'pills' }],
|
||||
};
|
||||
|
||||
const burnRateData = {
|
||||
data: [{ medicineId: 'c1', medicineName: undefined, daysUntilEmpty: 5 }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: summaryData };
|
||||
if (key.includes('burn-rates')) return { data: burnRateData };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
26
packages/web/tests/app/(dashboard)/layout.test.tsx
Normal file
26
packages/web/tests/app/(dashboard)/layout.test.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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 '../../../src/app/(dashboard)/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/tests/app/(dashboard)/loading.test.tsx
Normal file
11
packages/web/tests/app/(dashboard)/loading.test.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import DashboardLoading from '../../../src/app/(dashboard)/loading';
|
||||
|
||||
describe('DashboardLoading', () => {
|
||||
it('renders a loading spinner', () => {
|
||||
const { container: c } = render(<DashboardLoading />);
|
||||
expect(c.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
397
packages/web/tests/app/(dashboard)/medicine-prices/page.test.tsx
Normal file
397
packages/web/tests/app/(dashboard)/medicine-prices/page.test.tsx
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
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: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
|
||||
import MedicinePricesPage from '../../../../src/app/(dashboard)/medicine-prices/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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
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 '../../../../src/app/(dashboard)/medicines/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.5,
|
||||
currency: 'USD',
|
||||
byMedicine: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
totalSpent: 125.5,
|
||||
purchaseCount: 2,
|
||||
avgUnitPrice: 0.69,
|
||||
},
|
||||
],
|
||||
byPeriod: [{ period: '2026-01', totalSpent: 125.5 }],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
684
packages/web/tests/app/(dashboard)/medicines/CabinetTab.test.tsx
Normal file
684
packages/web/tests/app/(dashboard)/medicines/CabinetTab.test.tsx
Normal file
|
|
@ -0,0 +1,684 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SWRConfig } from 'swr';
|
||||
import type React from 'react';
|
||||
|
||||
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: { href?: string; children: React.ReactNode }) => (
|
||||
<a href={props.href}>{props.children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CabinetTab } from '../../../../src/app/(dashboard)/medicines/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);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
describe('CabinetTab', () => {
|
||||
it('shows empty state when no items', async () => {
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
// First click expands — handleExpand calls listCabinetItems
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
|
||||
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" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
|
||||
// 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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
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" />, { wrapper });
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
250
packages/web/tests/app/(dashboard)/medicines/LibraryTab.test.tsx
Normal file
250
packages/web/tests/app/(dashboard)/medicines/LibraryTab.test.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
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: { href?: string; children: React.ReactNode }) => (
|
||||
<a href={props.href}>{props.children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { LibraryTab } from '../../../../src/app/(dashboard)/medicines/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,524 @@
|
|||
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 '../../../../src/app/(dashboard)/medicines/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,580 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
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 '../../../../src/app/(dashboard)/medicines/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: [] });
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
describe('RegimensTab', () => {
|
||||
it('shows empty state when no regimens', async () => {
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Load failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('dismisses error', async () => {
|
||||
mockListRegimens.mockRejectedValue(new Error('Load failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to load data')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on create regimen', async () => {
|
||||
mockCreateRegimen.mockRejectedValue('save failed');
|
||||
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
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" />, { wrapper });
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
794
packages/web/tests/app/(dashboard)/medicines/[id]/page.test.tsx
Normal file
794
packages/web/tests/app/(dashboard)/medicines/[id]/page.test.tsx
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import type React from '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: { href?: string; children: React.ReactNode }) => (
|
||||
<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 '../../../../../src/app/(dashboard)/medicines/[id]/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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/ActivityTab', () => ({
|
||||
ActivityTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="activity-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import ActivityPage from '../../../../../src/app/(dashboard)/medicines/activity/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,38 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/CabinetTab', () => ({
|
||||
CabinetTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="cabinet-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import CabinetPage from '../../../../../src/app/(dashboard)/medicines/cabinet/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,38 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/LibraryTab', () => ({
|
||||
LibraryTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="library-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import LibraryPage from '../../../../../src/app/(dashboard)/medicines/library/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,38 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/OrganizerTab', () => ({
|
||||
OrganizerTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="organizer-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import OrganizerPage from '../../../../../src/app/(dashboard)/medicines/organizer/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');
|
||||
});
|
||||
});
|
||||
49
packages/web/tests/app/(dashboard)/medicines/page.test.tsx
Normal file
49
packages/web/tests/app/(dashboard)/medicines/page.test.tsx
Normal file
|
|
@ -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 '../../../../src/app/(dashboard)/medicines/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,38 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/RegimensTab', () => ({
|
||||
RegimensTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="regimens-tab">{householdId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import RegimensPage from '../../../../../src/app/(dashboard)/medicines/regimens/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,305 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListRegimens } = vi.hoisted(() => ({
|
||||
mockListRegimens: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/regimens', () => ({
|
||||
listRegimens: mockListRegimens,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import SchedulePage from '../../../../../src/app/(dashboard)/medicines/schedule/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const makeRegimen = (overrides = {}) => ({
|
||||
_id: 'reg1',
|
||||
householdId: 'hh1',
|
||||
name: 'Daily Vitamins',
|
||||
isActive: true,
|
||||
startDate: '2026-01-01',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Vitamin D',
|
||||
medicineStrength: '1000',
|
||||
medicineStrengthUnit: 'IU',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
timeOfDay: 'morning',
|
||||
},
|
||||
],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SchedulePage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no household message', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no regimens', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No active regimens found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows set up link in empty state', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up a regimen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders medication in morning slot', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
|
||||
expect(screen.getByText('Morning')).toBeInTheDocument();
|
||||
expect(screen.getByText('1000 IU')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily Vitamins')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows frequency label', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Once daily/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows custom frequency', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Custom Med',
|
||||
medicineStrength: '50',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'custom',
|
||||
customFrequencyPerDay: 4,
|
||||
timeOfDay: 'morning',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/4x daily/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows instructions when present', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Med A',
|
||||
medicineStrength: '10',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'pill',
|
||||
frequency: 'daily',
|
||||
timeOfDay: 'evening',
|
||||
instructions: 'Take with food',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Take with food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Evening')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows dose count', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 dose')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error for non-Error failures', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockRejectedValue('unexpected');
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load regimens')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('groups into "any" slot when timeOfDay is missing', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm2',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: '100',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'as_needed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Any time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('paginates through regimens', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens
|
||||
.mockResolvedValueOnce({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: 'next', hasMore: true },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
makeRegimen({
|
||||
_id: 'reg2',
|
||||
name: 'Second Regimen',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm2',
|
||||
medicineName: 'Iron',
|
||||
medicineStrength: '65',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
timeOfDay: 'afternoon',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
|
||||
expect(screen.getByText('Iron')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockListRegimens).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows regimen and dose summary', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 active regimen/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 dose per day/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
322
packages/web/tests/app/(dashboard)/pantry/page.test.tsx
Normal file
322
packages/web/tests/app/(dashboard)/pantry/page.test.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListPantryItems, mockTransitionPantryItem, mockDeletePantryItem } = vi.hoisted(() => ({
|
||||
mockListPantryItems: vi.fn(),
|
||||
mockTransitionPantryItem: vi.fn(),
|
||||
mockDeletePantryItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/pantry', () => ({
|
||||
listPantryItems: mockListPantryItems,
|
||||
transitionPantryItem: mockTransitionPantryItem,
|
||||
deletePantryItem: mockDeletePantryItem,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (props: any) => props.children };
|
||||
});
|
||||
|
||||
import PantryPage from '../../../../src/app/(dashboard)/pantry/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
const SAMPLE_ITEM = {
|
||||
_id: 'pi-1',
|
||||
householdId: 'hh1',
|
||||
productId: 'p1',
|
||||
productName: 'Whole Milk',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'liter',
|
||||
purchaseDate: '2026-05-01T00:00:00.000Z',
|
||||
status: 'sealed',
|
||||
freshnessEstimate: {
|
||||
estimatedExpiryDate: '2026-05-15T00:00:00.000Z',
|
||||
daysRemaining: 11,
|
||||
urgency: 'fresh',
|
||||
source: 'rule',
|
||||
},
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('PantryPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pantry items when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no items', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows freshness badge', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('11d left')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows overdue text for negative days', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
...SAMPLE_ITEM,
|
||||
_id: 'pi-2',
|
||||
freshnessEstimate: {
|
||||
...SAMPLE_ITEM.freshnessEstimate,
|
||||
daysRemaining: -2,
|
||||
urgency: 'expired',
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2d overdue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows transition buttons for active items', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
expect(screen.getByText('Consume')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles transition click', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockTransitionPantryItem.mockResolvedValue({
|
||||
...SAMPLE_ITEM,
|
||||
status: 'opened',
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Open'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTransitionPantryItem).toHaveBeenCalledWith('hh1', 'pi-1', { status: 'opened' });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete with confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeletePantryItem.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeletePantryItem).toHaveBeenCalledWith('hh1', 'pi-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when confirm is cancelled', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
expect(mockDeletePantryItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by storage tab', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Fridge'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListPantryItems).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ storageLocation: 'fridge' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by status select', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'opened' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListPantryItems).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ status: 'opened' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when transition fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockTransitionPantryItem.mockRejectedValue(new Error('Transition failed'));
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Open'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Transition failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filter empty state message', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
// Click a filter tab to trigger filter state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Fridge'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No items match your filters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockImportProducts } = vi.hoisted(() => ({
|
||||
mockImportProducts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/products', () => ({
|
||||
importProducts: mockImportProducts,
|
||||
}));
|
||||
|
||||
import { ImportDialog } from '../../../../src/app/(dashboard)/products/ImportDialog';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
householdId: 'hh1',
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ImportDialog', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ImportDialog {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders Import Products title when open', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
expect(screen.getByText('Import Products')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file chooser button', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Upload button is disabled without file', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
expect(screen.getByText('Upload')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows file name after selection', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['name,category\nTest,other'], 'products.csv', {
|
||||
type: 'text/csv',
|
||||
});
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
expect(screen.getByText('products.csv')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uploads file and shows success result', async () => {
|
||||
mockImportProducts.mockResolvedValue({
|
||||
imported: 5,
|
||||
skippedDuplicates: 1,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(defaultProps.onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows errors in result', async () => {
|
||||
mockImportProducts.mockResolvedValue({
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
errors: [{ row: 2, message: 'Missing name' }],
|
||||
});
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Row 2: Missing name/)).toBeInTheDocument();
|
||||
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error on import failure', async () => {
|
||||
mockImportProducts.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error on non-Error rejection', async () => {
|
||||
mockImportProducts.mockRejectedValue('unknown');
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dialog via Cancel button', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes dialog via Close button after result', async () => {
|
||||
mockImportProducts.mockResolvedValue({
|
||||
imported: 1,
|
||||
skippedDuplicates: 0,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Close'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockLookupBarcode } = vi.hoisted(() => ({
|
||||
mockLookupBarcode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/products', () => ({
|
||||
lookupBarcode: mockLookupBarcode,
|
||||
}));
|
||||
|
||||
import { ProductModal } from '../../../../src/app/(dashboard)/products/ProductModal';
|
||||
import { ProductCategory, ServingUnit } from '@meshitrack/shared';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn().mockResolvedValue(undefined),
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
describe('ProductModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ProductModal {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders Add Product title by default', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
expect(screen.getByText('Add Product')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom title when provided', () => {
|
||||
render(<ProductModal {...defaultProps} title="Custom Title" />);
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Edit Product title when initial is provided', () => {
|
||||
render(<ProductModal {...defaultProps} initial={{ name: 'Test' }} />);
|
||||
expect(screen.getByText('Edit Product')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills form fields from initial', () => {
|
||||
render(
|
||||
<ProductModal
|
||||
{...defaultProps}
|
||||
initial={{
|
||||
name: 'Chicken',
|
||||
brand: 'Tyson',
|
||||
barcode: '12345678',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
tags: ['organic', 'protein'],
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue('Chicken')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Tyson')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('12345678')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('organic, protein')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('165')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when name is empty and form submitted', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
expect(defaultProps.onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when serving size is empty', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(screen.getByText('Serving size must be a positive number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when nutrition fields are missing', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
|
||||
target: { value: '100' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(
|
||||
screen.getByText('Calories, protein, carbs, and fat are required'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSave with correct data on valid submit', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Chicken Breast' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
|
||||
target: { value: '100' },
|
||||
});
|
||||
|
||||
// Fill nutrition
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
// serving size is index 0, density=1, calories=2, protein=3, carbs=4, fat=5
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '165' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '31' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '0' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '3.6' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Chicken Breast',
|
||||
servingSize: 100,
|
||||
nutrition: expect.objectContaining({
|
||||
calories: 165,
|
||||
protein: 31,
|
||||
carbs: 0,
|
||||
fat: 3.6,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal after successful save', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when onSave rejects', async () => {
|
||||
const saveFn = vi.fn().mockRejectedValue(new Error('Server error'));
|
||||
render(<ProductModal {...defaultProps} onSave={saveFn} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error when onSave throws non-Error', async () => {
|
||||
const saveFn = vi.fn().mockRejectedValue('unknown');
|
||||
render(<ProductModal {...defaultProps} onSave={saveFn} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes when Cancel is clicked', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes when backdrop is clicked', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
// Click the backdrop (outermost div with onClick=onClose)
|
||||
const backdrop = screen.getByText('Add Product').closest('div')!.parentElement!;
|
||||
fireEvent.click(backdrop);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Lookup button is disabled when barcode is empty', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
const lookupBtn = screen.getByText('Lookup');
|
||||
expect(lookupBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('barcode lookup fills form on success', async () => {
|
||||
mockLookupBarcode.mockResolvedValue({
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Nutella',
|
||||
brand: 'Ferrero',
|
||||
category: 'snacks',
|
||||
servingSize: 15,
|
||||
servingUnit: 'g',
|
||||
nutrition: { calories: 80, protein: 1, carbs: 8.5, fat: 4.7 },
|
||||
tags: ['spread'],
|
||||
source: 'barcode_lookup',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||
target: { value: '3017620422003' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Lookup'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Nutella')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByDisplayValue('Ferrero')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('80')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('barcode lookup shows not found', async () => {
|
||||
mockLookupBarcode.mockResolvedValue({ found: false });
|
||||
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||
target: { value: '0000000000000' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Lookup'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Product not found for this barcode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('barcode lookup shows error on failure', async () => {
|
||||
mockLookupBarcode.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||
target: { value: '1234567890123' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Lookup'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Barcode lookup failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
342
packages/web/tests/app/(dashboard)/products/page.test.tsx
Normal file
342
packages/web/tests/app/(dashboard)/products/page.test.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListProducts, mockDeleteProduct, mockCreateProduct, mockUpdateProduct } = vi.hoisted(() => ({
|
||||
mockListProducts: vi.fn(),
|
||||
mockDeleteProduct: vi.fn(),
|
||||
mockCreateProduct: vi.fn(),
|
||||
mockUpdateProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/products', () => ({
|
||||
listProducts: mockListProducts,
|
||||
deleteProduct: mockDeleteProduct,
|
||||
createProduct: mockCreateProduct,
|
||||
updateProduct: mockUpdateProduct,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (props: any) => props.children };
|
||||
});
|
||||
|
||||
import ProductsPage from '../../../../src/app/(dashboard)/products/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_PRODUCT = {
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Chicken Breast',
|
||||
brand: 'Tyson',
|
||||
category: 'meat',
|
||||
servingSize: 100,
|
||||
servingUnit: 'g',
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: 'manual',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('ProductsPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
expect(screen.getByText('Product Library')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All categories')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders products when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Tyson')).toBeInTheDocument();
|
||||
expect(screen.getByText('165 kcal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no products', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No products yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on fetch failure', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by search input with debounce', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search products...');
|
||||
fireEvent.change(searchInput, { target: { value: 'chicken' } });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockListProducts).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ q: 'chicken' }),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by category dropdown', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
|
||||
|
||||
const categorySelect = screen.getByRole('combobox');
|
||||
fireEvent.change(categorySelect, { target: { value: 'meat' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListProducts).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ category: 'meat' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes product on confirm', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteProduct.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteProduct).toHaveBeenCalledWith('hh1', 'p1');
|
||||
});
|
||||
expect(screen.queryByText('Chicken Breast')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not delete when confirm is cancelled', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
expect(mockDeleteProduct).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when delete fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteProduct.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows macro breakdown in product card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByText('P: 31g')).toBeInTheDocument();
|
||||
expect(screen.getByText('C: 0g')).toBeInTheDocument();
|
||||
expect(screen.getByText('F: 3.6g')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles import dialog open and close', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
await waitFor(() => expect(screen.getByText('Import')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Import'));
|
||||
expect(screen.getByText('Import Products')).toBeInTheDocument();
|
||||
|
||||
// Use button name or text inside the dialog container to close
|
||||
const cancelBtns = screen.getAllByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelBtns[cancelBtns.length - 1]);
|
||||
});
|
||||
|
||||
it('toggles add product modal open and close', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
await waitFor(() => expect(screen.getByText('Add Product')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Add Product'));
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings.some(h => h.textContent === 'Add Product')).toBe(true);
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
it('opens and closes edit product modal', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
await screen.findByText('Chicken Breast');
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: /edit product/i });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings.some(h => h.textContent === 'Edit Product')).toBe(true);
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
it('submits add product modal successfully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateProduct.mockResolvedValue({ _id: 'pnew' });
|
||||
|
||||
render(<ProductsPage />);
|
||||
await screen.findByText('Add Product');
|
||||
fireEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Product name');
|
||||
fireEvent.change(nameInput, { target: { value: 'Fresh Banana' } });
|
||||
|
||||
// Fill mandatory numeric inputs to satisfy form validation
|
||||
const numInputs = screen.getAllByRole('spinbutton');
|
||||
numInputs.forEach(input => {
|
||||
fireEvent.change(input, { target: { value: '100' } });
|
||||
});
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateProduct).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Fresh Banana' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('submits edit product modal successfully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateProduct.mockResolvedValue({ _id: 'p1' });
|
||||
|
||||
render(<ProductsPage />);
|
||||
await screen.findByText('Chicken Breast');
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: /edit product/i });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Product name');
|
||||
fireEvent.change(nameInput, { target: { value: 'Updated Chicken' } });
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateProduct).toHaveBeenCalledWith('hh1', 'p1', expect.objectContaining({ name: 'Updated Chicken' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
572
packages/web/tests/app/(dashboard)/purchases/page.test.tsx
Normal file
572
packages/web/tests/app/(dashboard)/purchases/page.test.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
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: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
|
||||
import PurchasesPage from '../../../../src/app/(dashboard)/purchases/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();
|
||||
});
|
||||
});
|
||||
262
packages/web/tests/app/(dashboard)/recipes/RecipeEditor.test.tsx
Normal file
262
packages/web/tests/app/(dashboard)/recipes/RecipeEditor.test.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockCreateRecipe, mockUpdateRecipe } = vi.hoisted(() => ({
|
||||
mockCreateRecipe: vi.fn(),
|
||||
mockUpdateRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockPush, mockBack } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockBack: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
createRecipe: mockCreateRecipe,
|
||||
updateRecipe: mockUpdateRecipe,
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
getRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush, back: mockBack }),
|
||||
useParams: () => ({ id: 'r1' }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import { RecipeEditor } from '../../../../src/app/(dashboard)/recipes/RecipeEditor';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('RecipeEditor', () => {
|
||||
it('renders create form by default', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create recipe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ingredients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Instructions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit form when existing recipe provided', () => {
|
||||
const existing = {
|
||||
_id: 'r1',
|
||||
name: 'Pasta',
|
||||
description: 'Good pasta',
|
||||
servings: 2,
|
||||
prepTime: 10,
|
||||
cookTime: 20,
|
||||
cuisine: 'Italian',
|
||||
tags: ['pasta', 'quick'],
|
||||
isFavorite: true,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Spaghetti',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
originalQuantity: 200,
|
||||
originalUnit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Boil water', duration: 5, tip: 'Use salted water' }],
|
||||
perServingNutrition: { calories: 300, protein: 10, carbs: 40, fat: 8 },
|
||||
totalNutrition: { calories: 600, protein: 20, carbs: 80, fat: 16 },
|
||||
warnings: [],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Good pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits create form', async () => {
|
||||
mockCreateRecipe.mockResolvedValue({ _id: 'new1' });
|
||||
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||
target: { value: 'New Recipe' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Flour' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Create recipe'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRecipe).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'New Recipe' }),
|
||||
);
|
||||
expect(mockPush).toHaveBeenCalledWith('/recipes/new1');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits update form', async () => {
|
||||
mockUpdateRecipe.mockResolvedValue({ _id: 'r1' });
|
||||
const existing = {
|
||||
_id: 'r1',
|
||||
name: 'Old Name',
|
||||
description: '',
|
||||
servings: 4,
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Test',
|
||||
quantity: 100,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Do thing' }],
|
||||
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 3 },
|
||||
totalNutrition: { calories: 400, protein: 20, carbs: 40, fat: 12 },
|
||||
warnings: [],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Old Name'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Save changes'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRecipe).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'r1',
|
||||
expect.objectContaining({ name: 'New Name' }),
|
||||
);
|
||||
expect(mockPush).toHaveBeenCalledWith('/recipes/r1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when create fails', async () => {
|
||||
mockCreateRecipe.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Item' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Create recipe'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error for non-Error failure', async () => {
|
||||
mockCreateRecipe.mockRejectedValue('unexpected');
|
||||
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Item' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Create recipe'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to save recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can add and remove ingredients', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add ingredient'));
|
||||
|
||||
const nameInputs = screen.getAllByPlaceholderText('Product name');
|
||||
expect(nameInputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('can add and remove steps', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add step'));
|
||||
|
||||
const stepInputs = screen.getAllByPlaceholderText('Instruction...');
|
||||
expect(stepInputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('cancel button calls router.back', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles updating ingredient optional properties and removing ingredients', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add ingredient'));
|
||||
|
||||
const prepInputs = screen.getAllByPlaceholderText(/Preparation/i);
|
||||
fireEvent.change(prepInputs[0], { target: { value: 'Diced' } });
|
||||
|
||||
const removeBtns = screen.getAllByRole('button', { name: '×' });
|
||||
fireEvent.click(removeBtns[0]);
|
||||
|
||||
const remainingPrepInputs = screen.getAllByPlaceholderText(/Preparation/i);
|
||||
expect(remainingPrepInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles step metadata and removing steps', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add step'));
|
||||
|
||||
const durationInputs = screen.getAllByPlaceholderText(/Duration/i);
|
||||
const tipInputs = screen.getAllByPlaceholderText(/Tip/i);
|
||||
|
||||
fireEvent.change(durationInputs[0], { target: { value: '15' } });
|
||||
fireEvent.change(tipInputs[0], { target: { value: "Don't burn it" } });
|
||||
|
||||
const removeBtns = screen.getAllByRole('button', { name: '×' });
|
||||
fireEvent.click(removeBtns[removeBtns.length - 1]);
|
||||
|
||||
const remainingDurationInputs = screen.getAllByPlaceholderText(/Duration/i);
|
||||
expect(remainingDurationInputs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetRecipe } = vi.hoisted(() => ({
|
||||
mockGetRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
getRecipe: mockGetRecipe,
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'r1' }),
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import EditRecipePage from '../../../../../../src/app/(dashboard)/recipes/[id]/edit/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_RECIPE = {
|
||||
_id: 'r1',
|
||||
name: 'Pasta',
|
||||
description: '',
|
||||
servings: 4,
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
ingredients: [
|
||||
{ productId: 'p1', productName: 'Flour', quantity: 200, unit: 'g', isOptional: false },
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Mix' }],
|
||||
perServingNutrition: { calories: 200, protein: 5, carbs: 30, fat: 4 },
|
||||
totalNutrition: { calories: 800, protein: 20, carbs: 120, fat: 16 },
|
||||
warnings: [],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
describe('EditRecipePage', () => {
|
||||
it('shows loading state', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
expect(screen.getByText('Edit Recipe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders editor when recipe loads', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save changes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback when recipe is not found', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue('unexpected');
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
291
packages/web/tests/app/(dashboard)/recipes/[id]/page.test.tsx
Normal file
291
packages/web/tests/app/(dashboard)/recipes/[id]/page.test.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetRecipe, mockScaleRecipe } = vi.hoisted(() => ({
|
||||
mockGetRecipe: vi.fn(),
|
||||
mockScaleRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
getRecipe: mockGetRecipe,
|
||||
scaleRecipe: mockScaleRecipe,
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'r1' }),
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import RecipeDetailPage from '../../../../../src/app/(dashboard)/recipes/[id]/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_RECIPE = {
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
description: 'Classic Italian pasta',
|
||||
servings: 4,
|
||||
prepTime: 15,
|
||||
cookTime: 30,
|
||||
totalTime: 45,
|
||||
cuisine: 'Italian',
|
||||
tags: ['pasta'],
|
||||
isFavorite: true,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Spaghetti',
|
||||
quantity: 400,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
{
|
||||
productId: 'p2',
|
||||
productName: 'Parmesan',
|
||||
quantity: 50,
|
||||
unit: 'g',
|
||||
isOptional: true,
|
||||
preparation: 'grated',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{ order: 1, instruction: 'Boil water', duration: 10 },
|
||||
{ order: 2, instruction: 'Cook pasta', tip: 'Al dente' },
|
||||
],
|
||||
perServingNutrition: {
|
||||
calories: 450,
|
||||
protein: 25,
|
||||
carbs: 55,
|
||||
fat: 12,
|
||||
fiber: 3,
|
||||
sugar: 5,
|
||||
sodium: 400,
|
||||
saturatedFat: 4,
|
||||
cholesterol: 50,
|
||||
},
|
||||
totalNutrition: {
|
||||
calories: 1800,
|
||||
protein: 100,
|
||||
carbs: 220,
|
||||
fat: 48,
|
||||
},
|
||||
warnings: ['high_calories'],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('RecipeDetailPage', () => {
|
||||
it('shows loading skeleton', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
expect(screen.getByText('Recipe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows recipe not found fallback', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
// Resolve with null-like to trigger error path
|
||||
mockGetRecipe.mockRejectedValue(new Error('Recipe not found'));
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recipe not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders recipe details', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Classic Italian pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 servings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Starred')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows ingredients with optional indicator', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parmesan')).toBeInTheDocument();
|
||||
expect(screen.getByText('(optional)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows steps with duration and tips', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Boil water')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cook pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tip: Al dente')).toBeInTheDocument();
|
||||
expect(screen.getByText('10 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutritional warnings', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nutritional alerts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutrition panel', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('450 kcal')).toBeInTheDocument();
|
||||
expect(screen.getByText('25.0g')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows edit link', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-Error fetch failure', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue('string error');
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders recipe without description', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue({
|
||||
...SAMPLE_RECIPE,
|
||||
description: undefined,
|
||||
warnings: [],
|
||||
isFavorite: false,
|
||||
});
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Starred')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('scales the recipe servings', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
mockScaleRecipe.mockResolvedValue({
|
||||
...SAMPLE_RECIPE,
|
||||
servings: 8,
|
||||
ingredients: SAMPLE_RECIPE.ingredients.map(i => ({ ...i, quantity: i.quantity * 2 })),
|
||||
});
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
await screen.findByText('Spaghetti Bolognese');
|
||||
|
||||
const scaleInput = screen.getByRole('spinbutton');
|
||||
fireEvent.change(scaleInput, { target: { value: '8' } });
|
||||
|
||||
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
|
||||
fireEvent.click(scaleBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockScaleRecipe).toHaveBeenCalledWith('hh1', 'r1', { targetServings: 8 });
|
||||
});
|
||||
|
||||
// Test resetting back to original
|
||||
const resetBtn = screen.getByRole('button', { name: /Reset to original/i });
|
||||
fireEvent.click(resetBtn);
|
||||
expect((scaleInput as HTMLInputElement).value).toBe('4');
|
||||
});
|
||||
|
||||
it('handles scale recipe failure gracefully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
mockScaleRecipe.mockRejectedValue(new Error('Scale failed'));
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
await screen.findByText('Spaghetti Bolognese');
|
||||
|
||||
const scaleInput = screen.getByRole('spinbutton');
|
||||
fireEvent.change(scaleInput, { target: { value: '6' } });
|
||||
|
||||
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
|
||||
fireEvent.click(scaleBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scale failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('skips scaling if servings did not change', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
await screen.findByText('Spaghetti Bolognese');
|
||||
|
||||
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
|
||||
fireEvent.click(scaleBtn);
|
||||
|
||||
expect(mockScaleRecipe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
61
packages/web/tests/app/(dashboard)/recipes/new/page.test.tsx
Normal file
61
packages/web/tests/app/(dashboard)/recipes/new/page.test.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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('@/services/recipes', () => ({
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
getRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import NewRecipePage from '../../../../../src/app/(dashboard)/recipes/new/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NewRecipePage', () => {
|
||||
it('shows loading state', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<NewRecipePage />);
|
||||
|
||||
expect(screen.getByText('New Recipe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<NewRecipePage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders editor when household exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<NewRecipePage />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
423
packages/web/tests/app/(dashboard)/recipes/page.test.tsx
Normal file
423
packages/web/tests/app/(dashboard)/recipes/page.test.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListRecipes, mockDeleteRecipe } = vi.hoisted(() => ({
|
||||
mockListRecipes: vi.fn(),
|
||||
mockDeleteRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
listRecipes: mockListRecipes,
|
||||
deleteRecipe: mockDeleteRecipe,
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
getRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import RecipesPage from '../../../../src/app/(dashboard)/recipes/page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_RECIPE = {
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
description: 'Classic Italian pasta',
|
||||
servings: 4,
|
||||
prepTime: 15,
|
||||
cookTime: 30,
|
||||
totalTime: 45,
|
||||
cuisine: 'Italian',
|
||||
tags: ['pasta', 'comfort'],
|
||||
isFavorite: true,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Spaghetti',
|
||||
quantity: 400,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Boil water' }],
|
||||
perServingNutrition: {
|
||||
calories: 450,
|
||||
protein: 25,
|
||||
carbs: 55,
|
||||
fat: 12,
|
||||
fiber: 3,
|
||||
sugar: 5,
|
||||
sodium: 400,
|
||||
},
|
||||
totalNutrition: {
|
||||
calories: 1800,
|
||||
protein: 100,
|
||||
carbs: 220,
|
||||
fat: 48,
|
||||
},
|
||||
warnings: ['high_calories'],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('RecipesPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Search recipes...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders recipe list when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no recipes', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No recipes yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutrition info on recipe card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('450 kcal')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 servings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows time on recipe card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warning labels', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High cal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tags on recipe card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('comfort')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows favorite star', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Star character is rendered for favorites
|
||||
expect(screen.getByText('Spaghetti Bolognese').closest('a')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows edit link', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete with confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRecipe.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRecipe).toHaveBeenCalledWith('hh1', 'r1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when confirm is cancelled', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
expect(mockDeleteRecipe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filter empty state message', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Cuisine...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Cuisine...'), {
|
||||
target: { value: 'Thai' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No recipes match your filters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows recipe without totalTime using prep+cook', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, totalTime: undefined }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('formats hours correctly', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, totalTime: 90 }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1h 30m')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('formats exact hours', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, totalTime: 120 }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2h')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRecipe.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-Error delete failure', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRecipe.mockRejectedValue('string error');
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to delete recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows + New Recipe link', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('recipe with no warnings renders without warning badges', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, warnings: [], tags: [] }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High cal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles favorites checkbox', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Favorites')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Favorites'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListRecipes).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ isFavorite: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
689
packages/web/tests/app/(dashboard)/refills/page.test.tsx
Normal file
689
packages/web/tests/app/(dashboard)/refills/page.test.tsx
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
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: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
|
||||
import RefillsPage from '../../../../src/app/(dashboard)/refills/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());
|
||||
});
|
||||
});
|
||||
280
packages/web/tests/app/(dashboard)/settings/page.test.tsx
Normal file
280
packages/web/tests/app/(dashboard)/settings/page.test.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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 '../../../../src/app/(dashboard)/settings/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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SWRConfig } from 'swr';
|
||||
import ShoppingListDetailPage from '../../../../../src/app/(dashboard)/shopping-lists/[id]/page';
|
||||
import * as useApiModule from '@/lib/useApi';
|
||||
import * as ShoppingListsService from '@/services/shopping-lists';
|
||||
import * as ProductsService from '@/services/products';
|
||||
import * as useShoppingListSyncModule from '@/lib/useShoppingListSync';
|
||||
|
||||
vi.mock('@/lib/useApi');
|
||||
vi.mock('@/services/shopping-lists');
|
||||
vi.mock('@/services/products');
|
||||
vi.mock('@/lib/useShoppingListSync');
|
||||
|
||||
const { mockPush } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next/navigation params
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'list1' }),
|
||||
useRouter: () => ({ push: mockPush, back: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe('ShoppingListDetailPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock browser API modals
|
||||
window.confirm = vi.fn().mockReturnValue(true);
|
||||
window.alert = vi.fn();
|
||||
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
user: null,
|
||||
token: '123',
|
||||
} as any);
|
||||
|
||||
vi.mocked(ProductsService.listProducts).mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'p1', name: 'Apple', category: 'Produce', unit: 'pcs' }
|
||||
]
|
||||
} as any);
|
||||
|
||||
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
|
||||
id: 'list1',
|
||||
name: 'Test List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{ id: 'item1', productId: 'p1', quantity: 5, checked: false, unit: 'pcs' },
|
||||
],
|
||||
createdAt: '2026-05-10',
|
||||
} as any);
|
||||
|
||||
vi.mocked(useShoppingListSyncModule.useShoppingListSync).mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
toggleItemCheck: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
it('renders loading initially', () => {
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
expect(screen.getByText(/Hydrating session checklist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads and displays the list', async () => {
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test List')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Apple')[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles an item check', async () => {
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
|
||||
|
||||
const checkbox = screen.getByRole('button', { name: /Toggle check for Apple/i });
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(useShoppingListSyncModule.useShoppingListSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds a new item to the list', async () => {
|
||||
vi.mocked(ShoppingListsService.addShoppingItem).mockResolvedValue({
|
||||
id: 'list1',
|
||||
items: [
|
||||
{ id: 'item1', productId: 'p1', quantity: 5, checked: false },
|
||||
{ id: 'item2', customName: 'Banana', quantity: 2, checked: false },
|
||||
]
|
||||
} as any);
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
|
||||
|
||||
const customInput = screen.getByPlaceholderText(/e.g., Generic Flour/i);
|
||||
fireEvent.change(customInput, { target: { value: 'Banana' } });
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /Add to List/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.addShoppingItem).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'list1',
|
||||
expect.objectContaining({ customName: 'Banana' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('completes the trip and syncs to pantry', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
|
||||
id: 'list1',
|
||||
name: 'Test List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{ id: 'item1', productId: 'p1', quantity: 5, checked: true, addedToPantry: false },
|
||||
],
|
||||
createdAt: '2026-05-10',
|
||||
} as any);
|
||||
|
||||
vi.mocked(ShoppingListsService.updateShoppingList).mockResolvedValue({ status: 'completed' } as any);
|
||||
vi.mocked(ShoppingListsService.syncToPantry).mockResolvedValue({ addedCount: 1 } as any);
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
|
||||
|
||||
const syncBtn = screen.getByRole('button', { name: /Sync.*items to Pantry/i });
|
||||
fireEvent.click(syncBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.syncToPantry).toHaveBeenCalledWith('hh1', 'list1');
|
||||
expect(ShoppingListsService.updateShoppingList).toHaveBeenCalledWith('hh1', 'list1', { status: 'completed' });
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an item from the list', async () => {
|
||||
vi.mocked(ShoppingListsService.removeShoppingItem).mockResolvedValue({
|
||||
id: 'list1',
|
||||
items: []
|
||||
} as any);
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
|
||||
|
||||
const trashBtn = screen.getByRole('button', { name: /Delete Apple/i });
|
||||
fireEvent.click(trashBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.removeShoppingItem).toHaveBeenCalledWith('hh1', 'list1', 'item1');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads store comparisons when clicked', async () => {
|
||||
vi.mocked(ShoppingListsService.getBasketStoreComparison).mockResolvedValue({
|
||||
singleStoreOptions: [
|
||||
{ storeId: 's1', storeName: 'Walmart', estimatedTotal: 45.00, itemsCovered: 1 }
|
||||
]
|
||||
} as any);
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
|
||||
|
||||
const checkBtn = screen.getByRole('button', { name: /Check Lowest Store Options/i });
|
||||
fireEvent.click(checkBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.getBasketStoreComparison).toHaveBeenCalledWith('hh1', 'list1');
|
||||
expect(screen.getByText('Walmart')).toBeInTheDocument();
|
||||
expect(screen.getByText('$45.00')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fills out the complete add item form and submits', async () => {
|
||||
vi.mocked(ShoppingListsService.addShoppingItem).mockResolvedValue({
|
||||
id: 'list1',
|
||||
items: [
|
||||
{ id: 'item1', productId: 'p1', quantity: 10, checked: false, unit: 'piece', notes: 'Fresh' }
|
||||
]
|
||||
} as any);
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
|
||||
|
||||
const selectInputs = screen.getAllByRole('combobox');
|
||||
// First combobox is Link Product Catalog
|
||||
fireEvent.change(selectInputs[0], { target: { value: 'p1' } });
|
||||
|
||||
const qtyInput = screen.getByRole('spinbutton');
|
||||
fireEvent.change(qtyInput, { target: { value: '10' } });
|
||||
|
||||
// Second combobox is Unit
|
||||
fireEvent.change(selectInputs[1], { target: { value: 'piece' } });
|
||||
|
||||
const notesInput = screen.getByPlaceholderText(/Brand preference/i);
|
||||
fireEvent.change(notesInput, { target: { value: 'Fresh' } });
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /Add to List/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.addShoppingItem).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'list1',
|
||||
expect.objectContaining({
|
||||
productId: 'p1',
|
||||
quantity: 10,
|
||||
unit: 'piece',
|
||||
notes: 'Fresh'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates back to hub when button clicked', async () => {
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await screen.findAllByText('Apple');
|
||||
|
||||
const backBtn = screen.getByRole('button', { name: /Back to Hub/i });
|
||||
fireEvent.click(backBtn);
|
||||
expect(mockPush).toHaveBeenCalledWith('/shopping-lists');
|
||||
});
|
||||
|
||||
it('handles delete failure gracefully', async () => {
|
||||
vi.mocked(ShoppingListsService.removeShoppingItem).mockRejectedValue(new Error('Delete failed'));
|
||||
const spy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
await screen.findAllByText('Apple');
|
||||
|
||||
const trashBtn = screen.getByRole('button', { name: /Delete Apple/i });
|
||||
fireEvent.click(trashBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith('Delete failed');
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels sync to pantry if confirm is rejected', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
|
||||
id: 'list1',
|
||||
name: 'Test List',
|
||||
status: 'active',
|
||||
items: [
|
||||
{ id: 'item1', productId: 'p1', checked: true, addedToPantry: false, quantity: 5, unit: 'pcs' }
|
||||
]
|
||||
} as any);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<ShoppingListDetailPage />, { wrapper });
|
||||
const syncBtn = await screen.findByRole('button', { name: /Sync 1 items to Pantry/i });
|
||||
fireEvent.click(syncBtn);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(ShoppingListsService.syncToPantry).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
220
packages/web/tests/app/(dashboard)/shopping-lists/page.test.tsx
Normal file
220
packages/web/tests/app/(dashboard)/shopping-lists/page.test.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ShoppingListsPage from '../../../../src/app/(dashboard)/shopping-lists/page';
|
||||
import * as useApiModule from '@/lib/useApi';
|
||||
import * as ShoppingListsService from '@/services/shopping-lists';
|
||||
import * as MealPlansService from '@/services/meal-plans';
|
||||
|
||||
vi.mock('@/lib/useApi');
|
||||
vi.mock('@/services/shopping-lists');
|
||||
vi.mock('@/services/meal-plans');
|
||||
|
||||
describe('ShoppingListsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
user: null,
|
||||
token: '123',
|
||||
});
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
|
||||
{ id: 'list1', name: 'Groceries', status: 'active', items: [], totalEstimatedCost: 10, createdAt: '2026-05-10' } as any,
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders loading initially', () => {
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
|
||||
render(<ShoppingListsPage />);
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders prompt if no household', () => {
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: false } as any);
|
||||
render(<ShoppingListsPage />);
|
||||
expect(screen.getByText('Please join a household.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads and displays shopping lists', async () => {
|
||||
render(<ShoppingListsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Groceries/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles create list modal and submits', async () => {
|
||||
vi.mocked(ShoppingListsService.createShoppingList).mockResolvedValue({
|
||||
id: 'list2', name: 'New List', status: 'active', items: [], createdAt: '2026-05-11'
|
||||
} as any);
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /New Shopping List/i })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
|
||||
|
||||
// Wait for modal
|
||||
const input = await screen.findByPlaceholderText(/e.g., Weekly Costco Run/i);
|
||||
fireEvent.change(input, { target: { value: 'New List' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.createShoppingList).toHaveBeenCalledWith('hh1', { name: 'New List', items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('opens generate from meal plan modal', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({
|
||||
id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11'
|
||||
} as any);
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Generate from Meal Plan/i })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MealPlansService.listMealPlans).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const generateBtn = await screen.findByText(/Week of/i);
|
||||
fireEvent.click(generateBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ShoppingListsService.generateFromMealPlan).toHaveBeenCalledWith('hh1', 'mp1');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles fetch errors', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockRejectedValue(new Error('Failed to load'));
|
||||
render(<ShoppingListsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can cancel/close creation and gap scanning modals', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0, page: 1, limit: 10
|
||||
});
|
||||
|
||||
const { container } = render(<ShoppingListsPage />);
|
||||
|
||||
// 1. Open Create Modal and close via overlay backdrop click
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
|
||||
const createHeading = screen.getByText('Create Shopping List');
|
||||
expect(createHeading).toBeInTheDocument();
|
||||
|
||||
// The overlay is the grandparent of the heading. Let's click it!
|
||||
const overlay = createHeading.parentElement?.parentElement;
|
||||
if (overlay) fireEvent.click(overlay);
|
||||
expect(screen.queryByText('Create Shopping List')).not.toBeInTheDocument();
|
||||
|
||||
// 2. Re-open Create Modal and close via Cancel button click
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
expect(screen.queryByText('Create Shopping List')).not.toBeInTheDocument();
|
||||
|
||||
// 3. Open Gap Modal and close via Close button
|
||||
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan Meal Plan Gaps')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Close/i }));
|
||||
expect(screen.queryByText('Scan Meal Plan Gaps')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state and opens create modal', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
const createFirstBtn = await screen.findByRole('button', { name: /Create First List/i });
|
||||
fireEvent.click(createFirstBtn);
|
||||
expect(screen.getByText('Create Shopping List')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders completed lists', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
|
||||
{ id: 'listc', name: 'Completed Groceries', status: 'completed', items: [], totalEstimatedCost: 25, createdAt: '2026-05-09' } as any
|
||||
]);
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
await screen.findByText('Completed Runs');
|
||||
expect(screen.getByText('Completed Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles generate from meal plan failure', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
|
||||
total: 1, page: 1, limit: 10
|
||||
});
|
||||
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
|
||||
|
||||
const generateBtn = await screen.findByText(/Week of/i);
|
||||
fireEvent.click(generateBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('Gen failed');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('sorts lists by status and creation date', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
|
||||
{ id: 'list1', name: 'List 1', status: 'completed', createdAt: '2026-05-10', items: [], totalEstimatedCost: 10 } as any,
|
||||
{ id: 'list2', name: 'List 2', status: 'active', createdAt: '2026-05-12', items: [], totalEstimatedCost: 20 } as any,
|
||||
]);
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
await screen.findByText('List 2');
|
||||
expect(screen.getByText('List 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles create list failure', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
|
||||
vi.mocked(ShoppingListsService.createShoppingList).mockRejectedValue(new Error('Create failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
|
||||
const openBtn = await screen.findByRole('button', { name: /Create First List/i });
|
||||
fireEvent.click(openBtn);
|
||||
|
||||
const input = screen.getByPlaceholderText(/e.g., Weekly Costco Run/i);
|
||||
fireEvent.change(input, { target: { value: 'New List' } });
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /^Create$/ });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('Create failed');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles open gap modal fetch failure', async () => {
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
|
||||
vi.mocked(MealPlansService.listMealPlans).mockRejectedValue(new Error('Fetch plans failed'));
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(<ShoppingListsPage />);
|
||||
const btn = await screen.findByRole('button', { name: /Generate from Meal Plan/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ShoppingListPricesPage from '../../../../../src/app/(dashboard)/shopping-lists/prices/page';
|
||||
import * as useApiModule from '@/lib/useApi';
|
||||
import * as PricesService from '@/services/prices';
|
||||
|
||||
vi.mock('@/lib/useApi');
|
||||
vi.mock('@/services/prices');
|
||||
const { mockPush } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
back: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ShoppingListPricesPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
user: null,
|
||||
token: '123',
|
||||
} as any);
|
||||
|
||||
vi.mocked(PricesService.getPriceAnalytics).mockResolvedValue({
|
||||
timeframe: { start: '2026-04-01', end: '2026-05-14' },
|
||||
totalSpent: 150.00,
|
||||
totalRecords: 10,
|
||||
storeBreakdown: [
|
||||
{ storeId: 'store1', storeName: 'Costco', spent: 100 },
|
||||
{ storeId: 'store2', storeName: 'Trader Joes', spent: 50 },
|
||||
],
|
||||
recentPriceChanges: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Milk',
|
||||
storeId: 'store1',
|
||||
storeName: 'Costco',
|
||||
oldPrice: 3.50,
|
||||
newPrice: 4.00,
|
||||
percentageChange: 14.28,
|
||||
trend: 'up',
|
||||
}
|
||||
],
|
||||
priceAlerts: [
|
||||
{ productName: 'Bread', storeName: 'Costco', changePercent: 15, previousPrice: 2.00, currentPrice: 2.30 }
|
||||
],
|
||||
spendingOverTime: [
|
||||
{ period: 'Apr', total: 50 },
|
||||
{ period: 'May', total: 100 }
|
||||
],
|
||||
spendingByCategory: [
|
||||
{ category: 'Produce', total: 30 },
|
||||
{ category: 'Bakery', total: 20 }
|
||||
],
|
||||
averageBasketByStore: [
|
||||
{ storeId: 'store1', storeName: 'Costco', avgTotal: 150.00, tripCount: 5 },
|
||||
{ storeId: 'store2', storeName: 'Trader Joes', avgTotal: 50.00, tripCount: 2 },
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('renders loading initially', () => {
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
|
||||
render(<ShoppingListPricesPage />);
|
||||
expect(screen.getByText(/Synthesizing financial graphs/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads and displays analytics', async () => {
|
||||
render(<ShoppingListPricesPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('$150.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('Costco')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trader Joes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles fetch errors gracefully', async () => {
|
||||
vi.mocked(PricesService.getPriceAnalytics).mockRejectedValue(new Error('Analytics failed'));
|
||||
render(<ShoppingListPricesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Analytics failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates back to checklists when button is clicked', async () => {
|
||||
render(<ShoppingListPricesPage />);
|
||||
await screen.findByText('Costco');
|
||||
|
||||
const backBtn = screen.getByRole('button', { name: /Back to Checklists/i });
|
||||
fireEvent.click(backBtn);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/shopping-lists');
|
||||
});
|
||||
});
|
||||
551
packages/web/tests/app/(dashboard)/stores/page.test.tsx
Normal file
551
packages/web/tests/app/(dashboard)/stores/page.test.tsx
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
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 '../../../../src/app/(dashboard)/stores/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();
|
||||
});
|
||||
});
|
||||
22
packages/web/tests/app/page.test.tsx
Normal file
22
packages/web/tests/app/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 '../../src/app/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');
|
||||
});
|
||||
});
|
||||
84
packages/web/tests/components/Sidebar.test.tsx
Normal file
84
packages/web/tests/components/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUsePathname } = vi.hoisted(() => ({
|
||||
mockUsePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: mockUsePathname,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
import { Sidebar } from '../../src/components/layout/Sidebar';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/dashboard');
|
||||
mockUseApi.mockReturnValue({ profile: { displayName: 'Alice' } });
|
||||
});
|
||||
|
||||
it('renders brand name', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('MeshiTrack')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders navigation sections', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medicines')).toBeInTheDocument();
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders medicines nav items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Cabinet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regimens')).toBeInTheDocument();
|
||||
expect(screen.getByText('Library')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders food nav items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights active route', () => {
|
||||
mockUsePathname.mockReturnValue('/medicines/cabinet');
|
||||
render(<Sidebar />);
|
||||
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||
expect(cabinetLink).toHaveAttribute('href', '/medicines/cabinet');
|
||||
});
|
||||
|
||||
it('shows user avatar with display name', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback name when no profile', () => {
|
||||
mockUseApi.mockReturnValue({ profile: null });
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByLabelText('User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights nested route', () => {
|
||||
mockUsePathname.mockReturnValue('/medicines/cabinet/some-id');
|
||||
render(<Sidebar />);
|
||||
// Cabinet link should still match via startsWith
|
||||
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||
expect(cabinetLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
125
packages/web/tests/components/ThemeProvider.test.tsx
Normal file
125
packages/web/tests/components/ThemeProvider.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ThemeProvider, useTheme } from '../../src/components/ThemeProvider';
|
||||
import { Providers } from '../../src/components/Providers';
|
||||
|
||||
// Mock next-auth SessionProvider
|
||||
vi.mock('next-auth/react', () => ({
|
||||
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
function ThemeConsumer() {
|
||||
const { theme, accent, setTheme, setAccent, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="accent">{accent}</span>
|
||||
<button onClick={() => setTheme('dark')}>Set dark</button>
|
||||
<button onClick={() => setAccent('cobalt')}>Set cobalt</button>
|
||||
<button onClick={toggleTheme}>Toggle</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.cssText = '';
|
||||
});
|
||||
|
||||
it('provides default light/sage theme', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(screen.getByTestId('accent').textContent).toBe('sage');
|
||||
});
|
||||
|
||||
it('hydrates from localStorage', () => {
|
||||
localStorage.setItem('mt-theme', 'dark');
|
||||
localStorage.setItem('mt-accent', 'terracotta');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(screen.getByTestId('accent').textContent).toBe('terracotta');
|
||||
});
|
||||
|
||||
it('setTheme updates theme and persists', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Set dark')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(localStorage.getItem('mt-theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('setAccent updates accent and applies CSS vars', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Set cobalt')));
|
||||
|
||||
expect(screen.getByTestId('accent').textContent).toBe('cobalt');
|
||||
expect(localStorage.getItem('mt-accent')).toBe('cobalt');
|
||||
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
it('toggleTheme flips light to dark', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Toggle')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('toggleTheme flips dark to light', () => {
|
||||
localStorage.setItem('mt-theme', 'dark');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Toggle')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('useTheme throws outside provider', () => {
|
||||
expect(() => render(<ThemeConsumer />)).toThrow('useTheme must be used inside ThemeProvider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Providers', () => {
|
||||
it('renders children with session and theme providers', () => {
|
||||
render(
|
||||
<Providers>
|
||||
<span>child content</span>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('child content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
packages/web/tests/components/TopBar.test.tsx
Normal file
42
packages/web/tests/components/TopBar.test.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/ThemeProvider', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/PageHeaderContext', () => ({
|
||||
usePageHeader: () => ({
|
||||
header: {
|
||||
title: 'Test Page',
|
||||
subtitle: 'Subtitle here',
|
||||
crumbs: ['Home', 'Test Page'],
|
||||
actions: null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TopBar } from '../../src/components/layout/TopBar';
|
||||
|
||||
describe('TopBar', () => {
|
||||
it('renders title and subtitle', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Page');
|
||||
expect(screen.getByText('Subtitle here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumbs', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByPlaceholderText(/Search medicines/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme toggle button', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByLabelText('Toggle theme')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { PageHeaderProvider, usePageHeader } from '../../../src/components/layout/PageHeaderContext';
|
||||
|
||||
function TestComponent() {
|
||||
const { header, setHeader } = usePageHeader();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="title">{header.title}</span>
|
||||
<button onClick={() => setHeader({ title: 'New Title' })}>Change</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PageHeaderContext', () => {
|
||||
it('provides and updates header state', () => {
|
||||
render(
|
||||
<PageHeaderProvider>
|
||||
<TestComponent />
|
||||
</PageHeaderProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('title').textContent).toBe('MeshiTrack');
|
||||
|
||||
fireEvent.click(screen.getByText('Change'));
|
||||
expect(screen.getByTestId('title').textContent).toBe('New Title');
|
||||
});
|
||||
|
||||
it('returns fallback when used outside provider', () => {
|
||||
render(<TestComponent />);
|
||||
// Outside provider title returns '' fallback
|
||||
expect(screen.getByTestId('title').textContent).toBe('');
|
||||
});
|
||||
});
|
||||
118
packages/web/tests/components/ui.test.tsx
Normal file
118
packages/web/tests/components/ui.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Avatar } from '../../src/components/ui/Avatar';
|
||||
import { IconButton } from '../../src/components/ui/IconButton';
|
||||
import { Ring } from '../../src/components/ui/Ring';
|
||||
import { SparkBars } from '../../src/components/ui/SparkBars';
|
||||
import { SupplyBar } from '../../src/components/ui/SupplyBar';
|
||||
import { Card, CardHeader, CardBody } from '../../src/components/ui/Card';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initial from name', () => {
|
||||
render(<Avatar name="Alice" />);
|
||||
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses custom size', () => {
|
||||
render(<Avatar name="Bob" size={48} />);
|
||||
const el = screen.getByLabelText('Bob');
|
||||
expect(el).toHaveStyle({ width: '48px', height: '48px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('renders with label and icon', () => {
|
||||
render(<IconButton icon="settings" label="Settings" />);
|
||||
expect(screen.getByLabelText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification dot when dot=true', () => {
|
||||
render(<IconButton icon="bell" label="Notifications" dot />);
|
||||
// dot is a span inside the button
|
||||
const btn = screen.getByLabelText('Notifications');
|
||||
expect(btn.querySelectorAll('span').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ring', () => {
|
||||
it('renders value and total', () => {
|
||||
render(<Ring value={5} total={10} />);
|
||||
expect(screen.getByLabelText('5 of 10 doses taken')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles zero total', () => {
|
||||
render(<Ring value={0} total={0} />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SparkBars', () => {
|
||||
it('renders bars with labels', () => {
|
||||
const data = [
|
||||
{ label: 'Jan', amount: 100 },
|
||||
{ label: 'Feb', amount: 200 },
|
||||
];
|
||||
render(<SparkBars data={data} />);
|
||||
expect(screen.getByText('Jan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feb')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted amounts', () => {
|
||||
const data = [{ label: 'Mar', amount: 5000 }];
|
||||
render(<SparkBars data={data} />);
|
||||
expect(screen.getByText('5k')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SupplyBar', () => {
|
||||
it('renders days value', () => {
|
||||
render(<SupplyBar days={30} />);
|
||||
expect(screen.getByText('30')).toBeInTheDocument();
|
||||
expect(screen.getByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders for critical days', () => {
|
||||
render(<SupplyBar days={5} />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders for medium days', () => {
|
||||
render(<SupplyBar days={10} />);
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders children and applies custom styles', () => {
|
||||
render(
|
||||
<Card className="custom-card" style={{ margin: '20px' }}>
|
||||
<div>Card Content</div>
|
||||
</Card>
|
||||
);
|
||||
expect(screen.getByText('Card Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders header with title, subtitle and action', () => {
|
||||
render(
|
||||
<CardHeader
|
||||
title="My Card"
|
||||
subtitle="My Subtitle"
|
||||
action={<button>Click Me</button>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('My Card')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Subtitle')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Click Me' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders body with children', () => {
|
||||
render(
|
||||
<CardBody>
|
||||
<div>Body Content</div>
|
||||
</CardBody>
|
||||
);
|
||||
expect(screen.getByText('Body Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
packages/web/tests/components/ui/index.test.ts
Normal file
16
packages/web/tests/components/ui/index.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import * as UI from '../../../src/components/ui/index';
|
||||
|
||||
describe('UI Index Exports', () => {
|
||||
it('should export all defined UI components', () => {
|
||||
expect(UI.Icon).toBeDefined();
|
||||
expect(UI.Card).toBeDefined();
|
||||
expect(UI.Button).toBeDefined();
|
||||
expect(UI.Pill).toBeDefined();
|
||||
expect(UI.SupplyBar).toBeDefined();
|
||||
expect(UI.Ring).toBeDefined();
|
||||
expect(UI.SparkBars).toBeDefined();
|
||||
expect(UI.IconButton).toBeDefined();
|
||||
expect(UI.Avatar).toBeDefined();
|
||||
});
|
||||
});
|
||||
141
packages/web/tests/lib/useApi.test.ts
Normal file
141
packages/web/tests/lib/useApi.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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 '../../src/lib/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);
|
||||
});
|
||||
});
|
||||
107
packages/web/tests/lib/useShoppingListSync.test.ts
Normal file
107
packages/web/tests/lib/useShoppingListSync.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useShoppingListSync } from '../../src/lib/useShoppingListSync';
|
||||
|
||||
vi.mock('@/services/shopping-lists', () => ({
|
||||
getShoppingListSyncSocketUrl: vi.fn(() => 'ws://localhost/sync'),
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onopen: any = null;
|
||||
onmessage: any = null;
|
||||
onerror: any = null;
|
||||
onclose: any = null;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
describe('useShoppingListSync', () => {
|
||||
let originalWebSocket: any;
|
||||
let createdSockets: MockWebSocket[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createdSockets = [];
|
||||
originalWebSocket = global.WebSocket;
|
||||
const MockClass = class extends MockWebSocket {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
createdSockets.push(this);
|
||||
}
|
||||
};
|
||||
(MockClass as any).OPEN = 1;
|
||||
(MockClass as any).CONNECTING = 0;
|
||||
global.WebSocket = MockClass as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.WebSocket = originalWebSocket;
|
||||
});
|
||||
|
||||
it('initializes and connects to the correct socket URL', () => {
|
||||
const onSync = vi.fn();
|
||||
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
|
||||
|
||||
expect(createdSockets).toHaveLength(1);
|
||||
expect(createdSockets[0].url).toBe('ws://localhost/sync');
|
||||
});
|
||||
|
||||
it('handles incoming item_updated messages correctly', () => {
|
||||
const onSync = vi.fn();
|
||||
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
|
||||
|
||||
const ws = createdSockets[0];
|
||||
act(() => { if (ws.onopen) ws.onopen(); });
|
||||
|
||||
act(() => {
|
||||
if (ws.onmessage) ws.onmessage({ data: JSON.stringify({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }) });
|
||||
});
|
||||
|
||||
expect(onSync).toHaveBeenCalledWith({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } });
|
||||
});
|
||||
|
||||
it('broadcasts toggle item messages when connected', () => {
|
||||
const onSync = vi.fn();
|
||||
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
|
||||
|
||||
const ws = createdSockets[0];
|
||||
act(() => { if (ws.onopen) ws.onopen(); });
|
||||
|
||||
act(() => {
|
||||
result.current.toggleItemCheck('item1', true);
|
||||
});
|
||||
|
||||
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({
|
||||
type: 'TOGGLE_ITEM',
|
||||
itemId: 'item1',
|
||||
checked: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles disconnect and reconnect backoff', () => {
|
||||
vi.useFakeTimers();
|
||||
const onSync = vi.fn();
|
||||
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
|
||||
|
||||
let ws = createdSockets[0];
|
||||
act(() => { if (ws.onopen) ws.onopen(); });
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
|
||||
act(() => { if (ws.onclose) ws.onclose({ reason: 'test' }); });
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
|
||||
// After 1000ms it should attempt reconnect
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
expect(createdSockets).toHaveLength(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
167
packages/web/tests/services/api-client.test.ts
Normal file
167
packages/web/tests/services/api-client.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import type { apiClient as ApiClientType } from '../../src/services/api-client';
|
||||
|
||||
// Create a fresh ApiClient for each test by re-importing
|
||||
let apiClient: typeof ApiClientType;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
// Reset module to get a fresh singleton
|
||||
vi.resetModules();
|
||||
const mod = await import('../../src/services/api-client');
|
||||
apiClient = mod.apiClient;
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown, status = 200) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe('ApiClient', () => {
|
||||
describe('token management', () => {
|
||||
it('hasToken returns false initially', () => {
|
||||
expect(apiClient.hasToken).toBe(false);
|
||||
});
|
||||
|
||||
it('hasToken returns true after setting token', () => {
|
||||
apiClient.accessToken = 'test-token';
|
||||
expect(apiClient.hasToken).toBe(true);
|
||||
});
|
||||
|
||||
it('includes Authorization header when token is set', async () => {
|
||||
const spy = mockFetch({ data: [] });
|
||||
apiClient.accessToken = 'my-jwt';
|
||||
|
||||
await apiClient.get('/test');
|
||||
|
||||
const headers = spy.mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer my-jwt');
|
||||
});
|
||||
|
||||
it('omits Authorization header when no token', async () => {
|
||||
const spy = mockFetch({ data: [] });
|
||||
|
||||
await apiClient.get('/test');
|
||||
|
||||
const headers = spy.mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('makes GET request to correct URL', async () => {
|
||||
const spy = mockFetch({ id: '1' });
|
||||
|
||||
await apiClient.get('/households/hh1/stores');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/households/hh1/stores'),
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns parsed JSON body', async () => {
|
||||
mockFetch({ id: '1', name: 'CVS' });
|
||||
|
||||
const result = await apiClient.get('/test');
|
||||
|
||||
expect(result).toEqual({ id: '1', name: 'CVS' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('post', () => {
|
||||
it('makes POST request with JSON body', async () => {
|
||||
const spy = mockFetch({ id: '1' });
|
||||
const body = { name: 'Walgreens' };
|
||||
|
||||
await apiClient.post('/stores', body);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/stores'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles post without body', async () => {
|
||||
const spy = mockFetch({ id: '1' });
|
||||
|
||||
await apiClient.post('/test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ body: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patch', () => {
|
||||
it('makes PATCH request with JSON body', async () => {
|
||||
const spy = mockFetch({ id: '1' });
|
||||
const body = { name: 'Updated' };
|
||||
|
||||
await apiClient.patch('/stores/1', body);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/stores/1'),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('makes DELETE request', async () => {
|
||||
const spy = mockFetch(undefined, 204);
|
||||
|
||||
await apiClient.delete('/stores/1');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/stores/1'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws with message from error response body', async () => {
|
||||
mockFetch({ message: 'Store not found' }, 404);
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toThrow('Store not found');
|
||||
});
|
||||
|
||||
it('throws with status code when body has no message', async () => {
|
||||
mockFetch({}, 500);
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toThrow('Request failed: 500');
|
||||
});
|
||||
|
||||
it('throws with status text when JSON parsing fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
json: () => Promise.reject(new Error('parse error')),
|
||||
} as Response);
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toThrow('Request failed: 502 Bad Gateway');
|
||||
});
|
||||
|
||||
it('returns undefined for 204 No Content', async () => {
|
||||
mockFetch(null, 204);
|
||||
|
||||
const result = await apiClient.get('/test');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
67
packages/web/tests/services/cabinet-events.test.ts
Normal file
67
packages/web/tests/services/cabinet-events.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet },
|
||||
}));
|
||||
|
||||
import { listCabinetEvents, getEventsByItem, getSpendingSummary } from '../../src/services/cabinet-events';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('cabinet-events service', () => {
|
||||
it('listCabinetEvents with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetEvents('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet-events');
|
||||
});
|
||||
|
||||
it('listCabinetEvents builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetEvents('hh1', {
|
||||
medicineId: 'med-1',
|
||||
eventType: 'dispense',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-02-01',
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('eventType=dispense'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('startDate=2026-01-01'));
|
||||
});
|
||||
|
||||
it('getEventsByItem builds URL with cabinetItemId', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getEventsByItem('hh1', 'ci-1', { limit: 5 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/cabinet-events/by-item/ci-1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
|
||||
});
|
||||
|
||||
it('listCabinetEvents with cursor and limit', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetEvents('hh1', { cursor: 'cur1', limit: 10 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=10'));
|
||||
});
|
||||
|
||||
it('getEventsByItem with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getEventsByItem('hh1', 'ci-1', { cursor: 'cur2' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur2'));
|
||||
});
|
||||
|
||||
it('getSpendingSummary with no query', async () => {
|
||||
mockGet.mockResolvedValue({});
|
||||
await getSpendingSummary('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet-events/spending-summary');
|
||||
});
|
||||
|
||||
it('getSpendingSummary builds query string', async () => {
|
||||
mockGet.mockResolvedValue({});
|
||||
await getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('period=month'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
|
||||
});
|
||||
});
|
||||
100
packages/web/tests/services/cabinet.test.ts
Normal file
100
packages/web/tests/services/cabinet.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listCabinetItems,
|
||||
getCabinetItem,
|
||||
getCabinetSummary,
|
||||
getExpiringSoon,
|
||||
createCabinetItem,
|
||||
updateCabinetItem,
|
||||
adjustCabinetItemQuantity,
|
||||
deleteCabinetItem,
|
||||
} from '../../src/services/cabinet';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('cabinet service', () => {
|
||||
it('listCabinetItems with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetItems('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet');
|
||||
});
|
||||
|
||||
it('listCabinetItems builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetItems('hh1', { medicineId: 'med-1', status: 'active', expiringWithin: 30 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=active'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('expiringWithin=30'));
|
||||
});
|
||||
|
||||
it('listCabinetItems with cursor and limit', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetItems('hh1', { cursor: 'cur1', limit: 20 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=20'));
|
||||
});
|
||||
|
||||
it('getCabinetItem calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'ci-1' });
|
||||
await getCabinetItem('hh1', 'ci-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1');
|
||||
});
|
||||
|
||||
it('getCabinetSummary calls GET', async () => {
|
||||
mockGet.mockResolvedValue({});
|
||||
await getCabinetSummary('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/summary');
|
||||
});
|
||||
|
||||
it('getExpiringSoon uses default days', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/expiring-soon?days=30');
|
||||
});
|
||||
|
||||
it('getExpiringSoon uses custom days', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1', 7);
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/cabinet/expiring-soon?days=7');
|
||||
});
|
||||
|
||||
it('createCabinetItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'ci-1' });
|
||||
const data = { medicineId: 'med-1' } as never;
|
||||
await createCabinetItem('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/cabinet', data);
|
||||
});
|
||||
|
||||
it('updateCabinetItem calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'ci-1' });
|
||||
await updateCabinetItem('hh1', 'ci-1', { notes: 'x' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1', { notes: 'x' });
|
||||
});
|
||||
|
||||
it('adjustCabinetItemQuantity calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'ci-1' });
|
||||
await adjustCabinetItemQuantity('hh1', 'ci-1', { adjustment: -5, reason: 'used' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1/adjust', {
|
||||
adjustment: -5,
|
||||
reason: 'used',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteCabinetItem calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteCabinetItem('hh1', 'ci-1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1');
|
||||
});
|
||||
});
|
||||
53
packages/web/tests/services/households.test.ts
Normal file
53
packages/web/tests/services/households.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
|
||||
}));
|
||||
|
||||
import {
|
||||
createHousehold,
|
||||
getHousehold,
|
||||
updateHousehold,
|
||||
generateInviteCode,
|
||||
joinHousehold,
|
||||
} from '../../src/services/households';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('households service', () => {
|
||||
it('createHousehold calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'hh1' });
|
||||
await createHousehold('My Home');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households', { name: 'My Home' });
|
||||
});
|
||||
|
||||
it('getHousehold calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'hh1' });
|
||||
await getHousehold('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1');
|
||||
});
|
||||
|
||||
it('updateHousehold calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'hh1' });
|
||||
await updateHousehold('hh1', { name: 'Updated' });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('generateInviteCode calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'hh1' });
|
||||
await generateInviteCode('hh1');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/invite');
|
||||
});
|
||||
|
||||
it('joinHousehold calls POST with invite code', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'hh1' });
|
||||
await joinHousehold('ABC123');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/join', { inviteCode: 'ABC123' });
|
||||
});
|
||||
});
|
||||
68
packages/web/tests/services/meal-plans.test.ts
Normal file
68
packages/web/tests/services/meal-plans.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { apiClient } from '../../src/services/api-client';
|
||||
import * as MealPlansService from '../../src/services/meal-plans';
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('meal-plans service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('listMealPlans with and without query', async () => {
|
||||
await MealPlansService.listMealPlans('hh1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans');
|
||||
|
||||
await MealPlansService.listMealPlans('hh1', { cursor: 'cur', limit: 10 });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans?cursor=cur&limit=10');
|
||||
});
|
||||
|
||||
it('getMealPlanByWeek', async () => {
|
||||
await MealPlansService.getMealPlanByWeek('hh1', '2026-05-10');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/week/2026-05-10');
|
||||
});
|
||||
|
||||
it('getMealPlan', async () => {
|
||||
await MealPlansService.getMealPlan('hh1', 'mp1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
|
||||
});
|
||||
|
||||
it('createMealPlan', async () => {
|
||||
const data = { weekStartDate: '2026-05-10' } as any;
|
||||
await MealPlansService.createMealPlan('hh1', data);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/meal-plans', data);
|
||||
});
|
||||
|
||||
it('updateMealPlan', async () => {
|
||||
const data = { days: [] } as any;
|
||||
await MealPlansService.updateMealPlan('hh1', 'mp1', data);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1', data);
|
||||
});
|
||||
|
||||
it('updateMealPlanStatus', async () => {
|
||||
await MealPlansService.updateMealPlanStatus('hh1', 'mp1', 'active' as any);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/status', { status: 'active' });
|
||||
});
|
||||
|
||||
it('deleteMealPlan', async () => {
|
||||
await MealPlansService.deleteMealPlan('hh1', 'mp1');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
|
||||
});
|
||||
|
||||
it('getSuggestions', async () => {
|
||||
await MealPlansService.getSuggestions('hh1', 3);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/suggestions?limit=3');
|
||||
});
|
||||
|
||||
it('getShoppingGap', async () => {
|
||||
await MealPlansService.getShoppingGap('hh1', 'mp1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/gap');
|
||||
});
|
||||
});
|
||||
62
packages/web/tests/services/medicine-prices.test.ts
Normal file
62
packages/web/tests/services/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('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost },
|
||||
}));
|
||||
|
||||
import { recordPrice, getPriceHistory, compareStores, getPriceAnalytics } from '../../src/services/medicine-prices';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('medicine-prices service', () => {
|
||||
it('recordPrice calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'pr-1' });
|
||||
const data = { medicineProductId: 'mp-1', storeId: 'st-1', price: 10 } as never;
|
||||
await recordPrice('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicine-prices', data);
|
||||
});
|
||||
|
||||
it('getPriceHistory with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getPriceHistory('hh1', 'med-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/history/med-1');
|
||||
});
|
||||
|
||||
it('getPriceHistory builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getPriceHistory('hh1', 'med-1', { storeId: 'st-1', startDate: '2026-01-01' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('storeId=st-1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('startDate=2026-01-01'));
|
||||
});
|
||||
|
||||
it('getPriceHistory with endDate, cursor and limit', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getPriceHistory('hh1', 'med-1', { endDate: '2026-12-31', cursor: 'cur1', limit: 10 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('endDate=2026-12-31'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=10'));
|
||||
});
|
||||
|
||||
it('compareStores calls GET with medicineId', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await compareStores('hh1', 'med-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/compare/med-1');
|
||||
});
|
||||
|
||||
it('getPriceAnalytics uses default period', async () => {
|
||||
mockGet.mockResolvedValue({});
|
||||
await getPriceAnalytics('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/analytics?period=month');
|
||||
});
|
||||
|
||||
it('getPriceAnalytics uses custom period', async () => {
|
||||
mockGet.mockResolvedValue({});
|
||||
await getPriceAnalytics('hh1', 'year');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicine-prices/analytics?period=year');
|
||||
});
|
||||
});
|
||||
105
packages/web/tests/services/medicines.test.ts
Normal file
105
packages/web/tests/services/medicines.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listMedicines,
|
||||
getMedicine,
|
||||
createMedicine,
|
||||
updateMedicine,
|
||||
deleteMedicine,
|
||||
listMedicineProducts,
|
||||
createMedicineProduct,
|
||||
updateMedicineProduct,
|
||||
deleteMedicineProduct,
|
||||
} from '../../src/services/medicines';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('medicines service', () => {
|
||||
it('listMedicines with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listMedicines('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicines');
|
||||
});
|
||||
|
||||
it('listMedicines builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listMedicines('hh1', { q: 'aspirin', category: 'pain', form: 'tablet', limit: 10 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('q=aspirin'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('category=pain'));
|
||||
});
|
||||
|
||||
it('listMedicines with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listMedicines('hh1', { cursor: 'cur1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
});
|
||||
|
||||
it('getMedicine calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'med-1' });
|
||||
await getMedicine('hh1', 'med-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/medicines/med-1');
|
||||
});
|
||||
|
||||
it('createMedicine calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'med-1' });
|
||||
await createMedicine('hh1', { name: 'Aspirin' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicines', { name: 'Aspirin' });
|
||||
});
|
||||
|
||||
it('updateMedicine calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'med-1' });
|
||||
await updateMedicine('hh1', 'med-1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/medicines/med-1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteMedicine calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteMedicine('hh1', 'med-1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/medicines/med-1');
|
||||
});
|
||||
|
||||
it('listMedicineProducts builds URL with medicineId', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listMedicineProducts('hh1', 'med-1', { limit: 5 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/medicines/med-1/products'));
|
||||
});
|
||||
|
||||
it('listMedicineProducts with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listMedicineProducts('hh1', 'med-1', { cursor: 'cur1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
});
|
||||
|
||||
it('createMedicineProduct calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'mp-1' });
|
||||
await createMedicineProduct('hh1', 'med-1', { brand: 'Bayer' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicines/med-1/products', {
|
||||
brand: 'Bayer',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMedicineProduct uses medicine-products path', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'mp-1' });
|
||||
await updateMedicineProduct('hh1', 'mp-1', { brand: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/medicine-products/mp-1', {
|
||||
brand: 'Updated',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteMedicineProduct calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteMedicineProduct('hh1', 'mp-1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/medicine-products/mp-1');
|
||||
});
|
||||
});
|
||||
37
packages/web/tests/services/nutrition-targets.test.ts
Normal file
37
packages/web/tests/services/nutrition-targets.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { apiClient } from '../../src/services/api-client';
|
||||
import * as NutritionTargetsService from '../../src/services/nutrition-targets';
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('nutrition-targets service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getActiveNutritionTarget', async () => {
|
||||
await NutritionTargetsService.getActiveNutritionTarget('hh1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets');
|
||||
});
|
||||
|
||||
it('getNutritionTargetHistory', async () => {
|
||||
await NutritionTargetsService.getNutritionTargetHistory('hh1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets/history');
|
||||
});
|
||||
|
||||
it('setNutritionTarget', async () => {
|
||||
const data = { calories: 2000 } as any;
|
||||
await NutritionTargetsService.setNutritionTarget('hh1', data);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets', data);
|
||||
});
|
||||
|
||||
it('calculateTargetPreset', async () => {
|
||||
await NutritionTargetsService.calculateTargetPreset('hh1', 2500, 'gain');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets/presets', { calories: 2500, strategy: 'gain' });
|
||||
});
|
||||
});
|
||||
62
packages/web/tests/services/organizer.test.ts
Normal file
62
packages/web/tests/services/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('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost },
|
||||
}));
|
||||
|
||||
import { listFills, getFill, previewFill, executeFill, undoFill } from '../../src/services/organizer';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('organizer service', () => {
|
||||
it('listFills with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listFills('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/organizer/fills');
|
||||
});
|
||||
|
||||
it('listFills builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listFills('hh1', { regimenId: 'reg-1', status: 'active' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('regimenId=reg-1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=active'));
|
||||
});
|
||||
|
||||
it('listFills with cursor and limit', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listFills('hh1', { cursor: 'cur1', limit: 5 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
|
||||
});
|
||||
|
||||
it('getFill calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'f-1' });
|
||||
await getFill('hh1', 'f-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/organizer/fills/f-1');
|
||||
});
|
||||
|
||||
it('previewFill calls POST', async () => {
|
||||
mockPost.mockResolvedValue({});
|
||||
const data = { regimenId: 'reg-1' } as never;
|
||||
await previewFill('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/organizer/preview', data);
|
||||
});
|
||||
|
||||
it('executeFill calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'f-1' });
|
||||
const data = { regimenId: 'reg-1' } as never;
|
||||
await executeFill('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/organizer/fill', data);
|
||||
});
|
||||
|
||||
it('undoFill calls POST with empty body', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'f-1' });
|
||||
await undoFill('hh1', 'f-1');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/organizer/fills/f-1/undo', {});
|
||||
});
|
||||
});
|
||||
124
packages/web/tests/services/pantry.test.ts
Normal file
124
packages/web/tests/services/pantry.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listPantryItems,
|
||||
getPantryItem,
|
||||
createPantryItem,
|
||||
updatePantryItem,
|
||||
transitionPantryItem,
|
||||
batchTransitionPantryItems,
|
||||
getExpiringSoon,
|
||||
getWasteStats,
|
||||
deletePantryItem,
|
||||
} from '../../src/services/pantry';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('pantry service', () => {
|
||||
it('listPantryItems with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry');
|
||||
});
|
||||
|
||||
it('listPantryItems builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1', {
|
||||
storageLocation: 'fridge',
|
||||
status: 'sealed',
|
||||
urgency: 'urgent',
|
||||
productId: 'p1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('storageLocation=fridge');
|
||||
expect(url).toContain('status=sealed');
|
||||
expect(url).toContain('urgency=urgent');
|
||||
expect(url).toContain('productId=p1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('listPantryItems with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1', { cursor: 'cur1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
});
|
||||
|
||||
it('getPantryItem calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'item1' });
|
||||
await getPantryItem('hh1', 'item1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/item1');
|
||||
});
|
||||
|
||||
it('createPantryItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'item1' });
|
||||
const data = { productId: 'p1', storageLocation: 'fridge', quantity: 1, unit: 'piece' };
|
||||
await createPantryItem('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry', data);
|
||||
});
|
||||
|
||||
it('updatePantryItem calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'item1' });
|
||||
await updatePantryItem('hh1', 'item1', { quantity: 2 });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/pantry/item1', { quantity: 2 });
|
||||
});
|
||||
|
||||
it('transitionPantryItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'item1' });
|
||||
await transitionPantryItem('hh1', 'item1', { status: 'opened' as never });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/item1/transition', {
|
||||
status: 'opened',
|
||||
});
|
||||
});
|
||||
|
||||
it('batchTransitionPantryItems calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ transitioned: 2, failed: 0 });
|
||||
const data = { itemIds: ['a', 'b'], status: 'consumed' as const };
|
||||
await batchTransitionPantryItems('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/batch-transition', data);
|
||||
});
|
||||
|
||||
it('getExpiringSoon with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/expiring-soon');
|
||||
});
|
||||
|
||||
it('getExpiringSoon builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1', { days: 3, cursor: 'c1', limit: 5 });
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('days=3');
|
||||
expect(url).toContain('cursor=c1');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
|
||||
it('getWasteStats with no period', async () => {
|
||||
mockGet.mockResolvedValue({ wastePercentage: 10 });
|
||||
await getWasteStats('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats');
|
||||
});
|
||||
|
||||
it('getWasteStats with period', async () => {
|
||||
mockGet.mockResolvedValue({ wastePercentage: 10 });
|
||||
await getWasteStats('hh1', 'week');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats?period=week');
|
||||
});
|
||||
|
||||
it('deletePantryItem calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deletePantryItem('hh1', 'item1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/pantry/item1');
|
||||
});
|
||||
});
|
||||
54
packages/web/tests/services/prices.test.ts
Normal file
54
packages/web/tests/services/prices.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { apiClient } from '../../src/services/api-client';
|
||||
import * as PricesService from '../../src/services/prices';
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('prices service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('recordPrice', async () => {
|
||||
const data = { productId: 'p1', price: 10 } as any;
|
||||
await PricesService.recordPrice('hh1', data);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices', data);
|
||||
});
|
||||
|
||||
it('recordBulkPrices', async () => {
|
||||
const data = { storeId: 's1', items: [] } as any;
|
||||
await PricesService.recordBulkPrices('hh1', data);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices/bulk', data);
|
||||
});
|
||||
|
||||
it('getPriceHistory with and without query', async () => {
|
||||
await PricesService.getPriceHistory('hh1', 'p1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/history/p1');
|
||||
|
||||
await PricesService.getPriceHistory('hh1', 'p1', {
|
||||
storeId: 's1',
|
||||
startDate: '2026-05-01',
|
||||
endDate: '2026-05-10',
|
||||
cursor: 'cur',
|
||||
limit: 10,
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
'/households/hh1/prices/history/p1?storeId=s1&startDate=2026-05-01&endDate=2026-05-10&cursor=cur&limit=10'
|
||||
);
|
||||
});
|
||||
|
||||
it('compareStores', async () => {
|
||||
await PricesService.compareStores('hh1', 'p1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/compare/p1');
|
||||
});
|
||||
|
||||
it('getPriceAnalytics', async () => {
|
||||
await PricesService.getPriceAnalytics('hh1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/analytics');
|
||||
});
|
||||
});
|
||||
163
packages/web/tests/services/products.test.ts
Normal file
163
packages/web/tests/services/products.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listProducts,
|
||||
getProduct,
|
||||
lookupBarcode,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
smartAddProduct,
|
||||
importProducts,
|
||||
} from '../../src/services/products';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('products service', () => {
|
||||
it('listProducts with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listProducts('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products');
|
||||
});
|
||||
|
||||
it('listProducts builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listProducts('hh1', {
|
||||
q: 'chicken',
|
||||
category: 'meat',
|
||||
tags: 'organic',
|
||||
barcode: '1234',
|
||||
cursor: 'cur1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=chicken');
|
||||
expect(url).toContain('category=meat');
|
||||
expect(url).toContain('tags=organic');
|
||||
expect(url).toContain('barcode=1234');
|
||||
expect(url).toContain('cursor=cur1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('getProduct calls GET with correct path', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p1' });
|
||||
await getProduct('hh1', 'p1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/p1');
|
||||
});
|
||||
|
||||
it('lookupBarcode calls GET barcode endpoint', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p1' });
|
||||
await lookupBarcode('hh1', '1234567890');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/barcode/1234567890');
|
||||
});
|
||||
|
||||
it('createProduct calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'p1' });
|
||||
const data = {
|
||||
name: 'Chicken',
|
||||
category: 'meat' as never,
|
||||
servingSize: 100,
|
||||
servingUnit: 'g' as never,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: 'manual' as never,
|
||||
};
|
||||
await createProduct('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products', data);
|
||||
});
|
||||
|
||||
it('updateProduct calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'p1' });
|
||||
await updateProduct('hh1', 'p1', { name: 'Updated' });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/products/p1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteProduct calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteProduct('hh1', 'p1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/products/p1');
|
||||
});
|
||||
|
||||
it('smartAddProduct calls POST smart-add', async () => {
|
||||
mockPost.mockResolvedValue({ available: false, message: 'LLM not configured' });
|
||||
const result = await smartAddProduct('hh1', 'chicken breast');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products/smart-add', {
|
||||
text: 'chicken breast',
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
describe('importProducts', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
it('uploads file via fetch and returns result', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ imported: 3, skippedDuplicates: 1, errors: [] }),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
const result = await importProducts('hh1', file);
|
||||
|
||||
expect(result.imported).toBe(3);
|
||||
expect(result.skippedDuplicates).toBe(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/households/hh1/products/import'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-ok response with JSON body', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: () => Promise.resolve({ message: 'File too large' }),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow('File too large');
|
||||
});
|
||||
|
||||
it('throws with status on non-ok response without JSON', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow(
|
||||
'Import failed: 500 Internal Server Error',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws with status when JSON body has no message', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow('Import failed: 422');
|
||||
});
|
||||
});
|
||||
});
|
||||
69
packages/web/tests/services/purchases.test.ts
Normal file
69
packages/web/tests/services/purchases.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listPurchases,
|
||||
getPurchase,
|
||||
createPurchase,
|
||||
updatePurchase,
|
||||
receivePurchase,
|
||||
deletePurchase,
|
||||
} from '../../src/services/purchases';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('purchases service', () => {
|
||||
it('listPurchases with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPurchases('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/purchases');
|
||||
});
|
||||
|
||||
it('listPurchases builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPurchases('hh1', { status: 'ordered', storeId: 'st-1', limit: 10 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=ordered'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('storeId=st-1'));
|
||||
});
|
||||
|
||||
it('getPurchase calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p-1' });
|
||||
await getPurchase('hh1', 'p-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/purchases/p-1');
|
||||
});
|
||||
|
||||
it('createPurchase calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'p-1' });
|
||||
const data = { storeId: 'st-1', items: [] } as never;
|
||||
await createPurchase('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/purchases', data);
|
||||
});
|
||||
|
||||
it('updatePurchase calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'p-1' });
|
||||
await updatePurchase('hh1', 'p-1', { notes: 'updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/purchases/p-1', { notes: 'updated' });
|
||||
});
|
||||
|
||||
it('receivePurchase calls POST with empty body', async () => {
|
||||
mockPost.mockResolvedValue({ addedCount: 1, priceRecordsCreated: 0 });
|
||||
await receivePurchase('hh1', 'p-1');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/purchases/p-1/receive', {});
|
||||
});
|
||||
|
||||
it('deletePurchase calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue({ _id: 'p-1' });
|
||||
await deletePurchase('hh1', 'p-1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/purchases/p-1');
|
||||
});
|
||||
});
|
||||
118
packages/web/tests/services/recipes.test.ts
Normal file
118
packages/web/tests/services/recipes.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listRecipes,
|
||||
getRecipe,
|
||||
createRecipe,
|
||||
updateRecipe,
|
||||
deleteRecipe,
|
||||
scaleRecipe,
|
||||
importRecipeFromText,
|
||||
importRecipeFromUrl,
|
||||
listRecipesByProduct,
|
||||
} from '../../src/services/recipes';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('recipes service', () => {
|
||||
it('listRecipes with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipes('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes');
|
||||
});
|
||||
|
||||
it('listRecipes builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipes('hh1', {
|
||||
q: 'pasta',
|
||||
tags: 'italian',
|
||||
cuisine: 'Italian',
|
||||
maxCalories: 500,
|
||||
isFavorite: true,
|
||||
cursor: 'cur1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=pasta');
|
||||
expect(url).toContain('tags=italian');
|
||||
expect(url).toContain('cuisine=Italian');
|
||||
expect(url).toContain('maxCalories=500');
|
||||
expect(url).toContain('isFavorite=true');
|
||||
expect(url).toContain('cursor=cur1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('getRecipe calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'r1' });
|
||||
await getRecipe('hh1', 'r1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/r1');
|
||||
});
|
||||
|
||||
it('createRecipe calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'r1' });
|
||||
const data = { name: 'Pasta' };
|
||||
await createRecipe('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes', data);
|
||||
});
|
||||
|
||||
it('updateRecipe calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'r1' });
|
||||
await updateRecipe('hh1', 'r1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/recipes/r1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteRecipe calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteRecipe('hh1', 'r1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/recipes/r1');
|
||||
});
|
||||
|
||||
it('scaleRecipe calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'r1' });
|
||||
await scaleRecipe('hh1', 'r1', { targetServings: 8 });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/r1/scale', {
|
||||
targetServings: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('importRecipeFromText calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ available: false });
|
||||
await importRecipeFromText('hh1', { text: 'recipe text' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-text', {
|
||||
text: 'recipe text',
|
||||
});
|
||||
});
|
||||
|
||||
it('importRecipeFromUrl calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ available: false });
|
||||
await importRecipeFromUrl('hh1', { url: 'http://example.com' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-url', {
|
||||
url: 'http://example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('listRecipesByProduct with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipesByProduct('hh1', 'p1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/by-product/p1');
|
||||
});
|
||||
|
||||
it('listRecipesByProduct with query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipesByProduct('hh1', 'p1', { cursor: 'c1', limit: 5 });
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('cursor=c1');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
});
|
||||
86
packages/web/tests/services/refills.test.ts
Normal file
86
packages/web/tests/services/refills.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
|
||||
}));
|
||||
|
||||
import {
|
||||
getRefillAlerts,
|
||||
listRefillLists,
|
||||
createRefillList,
|
||||
getRefillList,
|
||||
updateRefillList,
|
||||
updateRefillListItem,
|
||||
addToCabinet,
|
||||
} from '../../src/services/refills';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('refills service', () => {
|
||||
it('getRefillAlerts with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getRefillAlerts('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/refills/alerts');
|
||||
});
|
||||
|
||||
it('getRefillAlerts builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getRefillAlerts('hh1', { thresholdDays: 14, userId: 'u-1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('thresholdDays=14'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('userId=u-1'));
|
||||
});
|
||||
|
||||
it('listRefillLists with query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRefillLists('hh1', { status: 'active' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('status=active'));
|
||||
});
|
||||
|
||||
it('listRefillLists with cursor and limit', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRefillLists('hh1', { cursor: 'cur1', limit: 25 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=25'));
|
||||
});
|
||||
|
||||
it('createRefillList calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'rl-1' });
|
||||
const data = { name: 'Weekly' } as never;
|
||||
await createRefillList('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/refills/lists', data);
|
||||
});
|
||||
|
||||
it('getRefillList calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'rl-1' });
|
||||
await getRefillList('hh1', 'rl-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1');
|
||||
});
|
||||
|
||||
it('updateRefillList calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'rl-1' });
|
||||
await updateRefillList('hh1', 'rl-1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1', {
|
||||
name: 'Updated',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateRefillListItem calls PATCH with nested path', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'rl-1' });
|
||||
await updateRefillListItem('hh1', 'rl-1', 'item-1', { purchased: true } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1/items/item-1', {
|
||||
purchased: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('addToCabinet calls POST with empty body', async () => {
|
||||
mockPost.mockResolvedValue({});
|
||||
await addToCabinet('hh1', 'rl-1');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1/add-to-cabinet', {});
|
||||
});
|
||||
});
|
||||
75
packages/web/tests/services/regimens.test.ts
Normal file
75
packages/web/tests/services/regimens.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listRegimens,
|
||||
getRegimen,
|
||||
getBurnRates,
|
||||
createRegimen,
|
||||
updateRegimen,
|
||||
deleteRegimen,
|
||||
} from '../../src/services/regimens';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('regimens service', () => {
|
||||
it('listRegimens with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRegimens('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/regimens');
|
||||
});
|
||||
|
||||
it('listRegimens builds query string with isActive', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRegimens('hh1', { isActive: true, limit: 10 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('isActive=true'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=10'));
|
||||
});
|
||||
|
||||
it('listRegimens with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRegimens('hh1', { cursor: 'cur1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
});
|
||||
|
||||
it('getRegimen calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'reg-1' });
|
||||
await getRegimen('hh1', 'reg-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/regimens/reg-1');
|
||||
});
|
||||
|
||||
it('getBurnRates calls GET', async () => {
|
||||
mockGet.mockResolvedValue({});
|
||||
await getBurnRates('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/regimens/burn-rate');
|
||||
});
|
||||
|
||||
it('createRegimen calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'reg-1' });
|
||||
const data = { name: 'Morning' } as never;
|
||||
await createRegimen('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/regimens', data);
|
||||
});
|
||||
|
||||
it('updateRegimen calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'reg-1' });
|
||||
await updateRegimen('hh1', 'reg-1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/regimens/reg-1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteRegimen calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteRegimen('hh1', 'reg-1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/regimens/reg-1');
|
||||
});
|
||||
});
|
||||
87
packages/web/tests/services/shopping-lists.test.ts
Normal file
87
packages/web/tests/services/shopping-lists.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { apiClient } from '../../src/services/api-client';
|
||||
import * as ShoppingListsService from '../../src/services/shopping-lists';
|
||||
|
||||
vi.mock('../../src/services/api-client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
baseUrl: 'http://localhost:3001',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('shopping-lists service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getShoppingLists', async () => {
|
||||
await ShoppingListsService.getShoppingLists('hh1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists');
|
||||
});
|
||||
|
||||
it('getShoppingList', async () => {
|
||||
await ShoppingListsService.getShoppingList('hh1', 'list1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
|
||||
});
|
||||
|
||||
it('createShoppingList', async () => {
|
||||
const data = { name: 'Test' } as any;
|
||||
await ShoppingListsService.createShoppingList('hh1', data);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists', data);
|
||||
});
|
||||
|
||||
it('updateShoppingList', async () => {
|
||||
const data = { name: 'Updated' } as any;
|
||||
await ShoppingListsService.updateShoppingList('hh1', 'list1', data);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1', data);
|
||||
});
|
||||
|
||||
it('deleteShoppingList', async () => {
|
||||
await ShoppingListsService.deleteShoppingList('hh1', 'list1');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
|
||||
});
|
||||
|
||||
it('addShoppingItem', async () => {
|
||||
const data = { productId: 'p1' } as any;
|
||||
await ShoppingListsService.addShoppingItem('hh1', 'list1', data);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items', data);
|
||||
});
|
||||
|
||||
it('updateShoppingItem', async () => {
|
||||
const data = { checked: true } as any;
|
||||
await ShoppingListsService.updateShoppingItem('hh1', 'list1', 'item1', data);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1', data);
|
||||
});
|
||||
|
||||
it('removeShoppingItem', async () => {
|
||||
await ShoppingListsService.removeShoppingItem('hh1', 'list1', 'item1');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1');
|
||||
});
|
||||
|
||||
it('generateFromMealPlan', async () => {
|
||||
await ShoppingListsService.generateFromMealPlan('hh1', 'mp1');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/from-meal-plan/mp1');
|
||||
});
|
||||
|
||||
it('syncToPantry', async () => {
|
||||
await ShoppingListsService.syncToPantry('hh1', 'list1');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/sync-to-pantry');
|
||||
});
|
||||
|
||||
it('getBasketStoreComparison', async () => {
|
||||
await ShoppingListsService.getBasketStoreComparison('hh1', 'list1');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/stores');
|
||||
});
|
||||
|
||||
it('getShoppingListSyncSocketUrl handles insecure and secure contexts', () => {
|
||||
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
|
||||
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
|
||||
|
||||
apiClient.baseUrl = 'https://api.meshitrack.com';
|
||||
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
|
||||
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');
|
||||
});
|
||||
});
|
||||
56
packages/web/tests/services/stores.test.ts
Normal file
56
packages/web/tests/services/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('../../src/services/api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import { listStores, getStore, createStore, updateStore, deactivateStore } from '../../src/services/stores';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('stores service', () => {
|
||||
it('listStores calls GET with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listStores('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/stores');
|
||||
});
|
||||
|
||||
it('listStores builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listStores('hh1', { tags: 'pharmacy', search: 'cvs', cursor: 'c1', limit: 5 });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('tags=pharmacy'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('search=cvs'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
|
||||
});
|
||||
|
||||
it('getStore calls GET with id', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'st-1' });
|
||||
await getStore('hh1', 'st-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/stores/st-1');
|
||||
});
|
||||
|
||||
it('createStore calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'st-1' });
|
||||
await createStore('hh1', { name: 'CVS' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/stores', { name: 'CVS' });
|
||||
});
|
||||
|
||||
it('updateStore calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'st-1' });
|
||||
await updateStore('hh1', 'st-1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/stores/st-1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deactivateStore calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue({ _id: 'st-1' });
|
||||
await deactivateStore('hh1', 'st-1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/stores/st-1');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue