2026-04-18 12:36:29 +09:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
|
|
|
import userEvent from '@testing-library/user-event';
|
2026-04-26 18:44:59 +09:00
|
|
|
import type React from 'react';
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
vi.mock('next/link', () => ({
|
|
|
|
|
default: (props: { href?: string; children: React.ReactNode }) => (
|
|
|
|
|
<a href={props.href}>{props.children}</a>
|
|
|
|
|
),
|
|
|
|
|
}));
|
2026-04-18 12:36:29 +09:00
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import { LibraryTab } from '../../../../src/app/(dashboard)/medicines/LibraryTab';
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
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 () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockListMedicines.mockResolvedValue({
|
|
|
|
|
data: [medicine],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
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' }));
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
await waitFor(() =>
|
|
|
|
|
expect(mockCreateMedicine).toHaveBeenCalledWith(
|
|
|
|
|
'hh1',
|
|
|
|
|
expect.objectContaining({ name: 'Aspirin' }),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-04-18 12:36:29 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('deletes medicine after confirmation', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockListMedicines.mockResolvedValue({
|
|
|
|
|
data: [medicine],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
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)
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
|
|
|
|
target: { value: 'test notes' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
// Clear notes (covers falsy branch → undefined)
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
|
|
|
|
target: { value: '' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
// 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
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
|
|
|
|
target: { value: 'met' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
// Category filter
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByDisplayValue('All Categories'), {
|
|
|
|
|
target: { value: 'prescription' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
// 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 () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockListMedicines.mockResolvedValue({
|
|
|
|
|
data: [medicine],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
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 () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockListMedicines.mockResolvedValue({
|
|
|
|
|
data: [medicine],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|