import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
const {
mockGetRefillAlerts,
mockListRefillLists,
mockCreateRefillList,
mockUpdateRefillList,
mockUpdateRefillListItem,
mockAddToCabinet,
} = vi.hoisted(() => ({
mockGetRefillAlerts: vi.fn(),
mockListRefillLists: vi.fn(),
mockCreateRefillList: vi.fn(),
mockUpdateRefillList: vi.fn(),
mockUpdateRefillListItem: vi.fn(),
mockAddToCabinet: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/refills', () => ({
getRefillAlerts: mockGetRefillAlerts,
listRefillLists: mockListRefillLists,
createRefillList: mockCreateRefillList,
updateRefillList: mockUpdateRefillList,
updateRefillListItem: mockUpdateRefillListItem,
addToCabinet: mockAddToCabinet,
}));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
import RefillsPage from '../../../../src/app/(dashboard)/refills/page';
const emptyAlerts = { data: [], pagination: { cursor: null, hasMore: false } };
const emptyLists = { data: [], pagination: { cursor: null, hasMore: false } };
beforeEach(() => {
vi.clearAllMocks();
mockGetRefillAlerts.mockResolvedValue(emptyAlerts);
mockListRefillLists.mockResolvedValue(emptyLists);
});
describe('RefillsPage', () => {
it('shows loading skeleton when session loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render();
expect(screen.getByText('Refills')).toBeInTheDocument();
expect(screen.queryByText('New Refill List')).not.toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render();
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders Refills heading when householdId exists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render();
expect(screen.getByText('Refills')).toBeInTheDocument();
});
it('shows empty state for alerts', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render();
await waitFor(() => expect(screen.getByText(/No medicines running low/)).toBeInTheDocument());
});
it('shows empty state for refill lists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render();
await waitFor(() => expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument());
});
it('shows error when alerts fail', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRefillAlerts.mockRejectedValue(new Error('Alert error'));
render();
await waitFor(() => expect(screen.getByText('Alert error')).toBeInTheDocument());
});
it('shows New List button and toggles form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render();
await waitFor(() => screen.getByText('New List'));
await userEvent.click(screen.getByText('New List'));
expect(screen.getByPlaceholderText('List name')).toBeInTheDocument();
});
it('creates a new refill list', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockCreateRefillList.mockResolvedValue({
_id: 'rl-1',
name: 'Test List',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
});
render();
await waitFor(() => screen.getByText('New List'));
await userEvent.click(screen.getByText('New List'));
await waitFor(() => screen.getByPlaceholderText('List name'));
fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Test List' } });
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
await waitFor(() =>
expect(mockCreateRefillList).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'Test List' }),
),
);
});
it('shows refill list and can select it', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRefillLists.mockResolvedValue({
data: [
{
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument());
});
it('updates a refill list status', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRefillList.mockResolvedValue({});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText('Start shopping'));
await userEvent.click(screen.getByText('Start shopping'));
expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'shopping' });
});
it('shows error when create list fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockCreateRefillList.mockRejectedValue(new Error('Create failed'));
render();
await waitFor(() => screen.getByText('New List'));
await userEvent.click(screen.getByText('New List'));
await waitFor(() => screen.getByPlaceholderText('List name'));
fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Test' } });
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
await waitFor(() => expect(screen.getByText('Create failed')).toBeInTheDocument());
});
it('adds checked items to cabinet', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockAddToCabinet.mockResolvedValue({ addedCount: 1 });
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.spyOn(window, 'alert').mockImplementation(() => {});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText(/Add .* to Cabinet/));
await userEvent.click(screen.getByText(/Add .* to Cabinet/));
expect(mockAddToCabinet).toHaveBeenCalledWith('hh1', 'rl-1');
});
it('generates a refill list from alerts', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRefillAlerts.mockResolvedValue({
data: [
{
medicineId: 'm1',
medicineName: 'Aspirin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
currentStock: 5,
dailyConsumption: 1.5,
daysUntilEmpty: 3,
},
],
pagination: { cursor: null, hasMore: false },
});
mockCreateRefillList.mockResolvedValue({
_id: 'rl-gen',
name: 'Auto Refills',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
});
render();
await waitFor(() => screen.getByText('Aspirin'));
await userEvent.click(screen.getByText('Generate Refill List'));
await waitFor(() => screen.getByPlaceholderText('List name, e.g. Weekly refills'));
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
target: { value: 'Auto Refills' },
});
fireEvent.submit(
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
);
await waitFor(() =>
expect(mockCreateRefillList).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'Auto Refills', fromAlerts: true }),
),
);
});
it('cancels create list form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render();
await waitFor(() => screen.getByText('New List'));
await userEvent.click(screen.getByText('New List'));
await waitFor(() => screen.getByPlaceholderText('List name'));
await userEvent.click(
screen.getAllByRole('button', { name: 'Cancel' })[
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
]!,
);
expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument();
});
it('closes the refill list detail panel', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRefillLists.mockResolvedValue({
data: [
{
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByTitle('Close'));
await userEvent.click(screen.getByTitle('Close'));
// After close, detail panel should not be visible (no Close button)
expect(screen.queryByTitle('Close')).not.toBeInTheDocument();
});
it('archives a refill list', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRefillList.mockResolvedValue({});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText('Archive'));
await userEvent.click(screen.getByText('Archive'));
expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'archived' });
});
it('shows error when generate list fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRefillAlerts.mockResolvedValue({
data: [
{
medicineId: 'm1',
medicineName: 'Aspirin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
currentStock: 5,
dailyConsumption: 1.5,
daysUntilEmpty: 3,
},
],
pagination: { cursor: null, hasMore: false },
});
mockCreateRefillList.mockRejectedValue(new Error('Generate failed'));
render();
await waitFor(() => screen.getByText('Aspirin'));
await userEvent.click(screen.getByText('Generate Refill List'));
await waitFor(() => screen.getByPlaceholderText('List name, e.g. Weekly refills'));
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
target: { value: 'Auto Refills' },
});
fireEvent.submit(
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
);
await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument());
});
it('dismisses refill list panel error', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRefillLists.mockRejectedValue(new Error('List error'));
render();
await waitFor(() => screen.getByText('List error'));
await userEvent.click(screen.getByText('Dismiss'));
expect(screen.queryByText('List error')).not.toBeInTheDocument();
});
it('toggles item checked state', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRefillListItem.mockResolvedValue({
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText('Aspirin'));
await userEvent.click(screen.getByRole('checkbox'));
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', {
checked: true,
});
});
it('marks a shopping list as complete', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'shopping',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRefillList.mockResolvedValue({});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText('Mark complete'));
await userEvent.click(screen.getByText('Mark complete'));
expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'completed' });
});
it('filters refill lists by status', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRefillLists.mockResolvedValue({
data: [
{
_id: 'rl-1',
name: 'Active List',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render();
await waitFor(() => screen.getByText('Active List'));
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'active' } });
await waitFor(() => expect(mockListRefillLists).toHaveBeenCalledTimes(2));
});
it('shows actual price input after checking item and allows price entry', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByPlaceholderText('Actual price'));
fireEvent.change(screen.getByPlaceholderText('Actual price'), { target: { value: '5.99' } });
expect(screen.getByPlaceholderText('Actual price')).toBeInTheDocument();
});
it('dismisses panel-level error', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockAddToCabinet.mockRejectedValue(new Error('Cabinet error'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText(/Add .* to Cabinet/));
await userEvent.click(screen.getByText(/Add .* to Cabinet/));
await waitFor(() => screen.getByText('Cabinet error'));
// Dismiss via inline button in the panel
const dismissBtns = screen.getAllByText('Dismiss');
await userEvent.click(dismissBtns[dismissBtns.length - 1]!);
expect(screen.queryByText('Cabinet error')).not.toBeInTheDocument();
});
it('shows fallback error when non-Error thrown on update item', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRefillListItem.mockRejectedValue('update failed');
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText('Aspirin'));
await userEvent.click(screen.getAllByRole('checkbox')[0]!);
await waitFor(() => expect(screen.getByText('Failed to update item')).toBeInTheDocument());
});
it('shows fallback error when non-Error thrown on update status', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
const refillList = {
_id: 'rl-1',
name: 'Weekly List',
status: 'active',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
};
mockListRefillLists.mockResolvedValue({
data: [refillList],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRefillList.mockRejectedValue('status failed');
render();
await waitFor(() => screen.getByText('Weekly List'));
await userEvent.click(screen.getByText('Weekly List'));
await waitFor(() => screen.getByText('Start shopping'));
await userEvent.click(screen.getByText('Start shopping'));
await waitFor(() => expect(screen.getByText('Failed to update status')).toBeInTheDocument());
});
it('shows refill alert when present', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRefillAlerts.mockResolvedValue({
data: [
{
medicineId: 'm1',
medicineName: 'Aspirin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
currentStock: 5,
dailyConsumption: 1.5,
daysUntilEmpty: 3,
},
],
pagination: { cursor: null, hasMore: false },
});
render();
await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument());
});
});