Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue