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';
|
2026-04-26 18:44:59 +09:00
|
|
|
import type React from 'react';
|
2026-04-18 12:36:29 +09:00
|
|
|
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(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } = vi.hoisted(
|
|
|
|
|
() => ({
|
2026-04-18 12:36:29 +09:00
|
|
|
mockListCabinetItems: vi.fn(),
|
|
|
|
|
mockAdjustCabinetItemQuantity: vi.fn(),
|
|
|
|
|
mockDeleteCabinetItem: vi.fn(),
|
2026-04-26 18:44:59 +09:00
|
|
|
}),
|
|
|
|
|
);
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
|
|
|
|
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
|
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
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import MedicineDetailPage from '../../../../../src/app/(dashboard)/medicines/[id]/page';
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
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 />);
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
await waitFor(() => expect(screen.getByText(/No products yet/)).toBeInTheDocument());
|
2026-04-18 12:36:29 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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' }));
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
await waitFor(() =>
|
|
|
|
|
expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
|
|
|
|
|
);
|
2026-04-18 12:36:29 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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'));
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByDisplayValue('Glucophage'), {
|
|
|
|
|
target: { value: 'Glucophage XR' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
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
|
2026-04-26 18:44:59 +09:00
|
|
|
const unitSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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
|
2026-04-26 18:44:59 +09:00
|
|
|
const concUnitSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).options[0]?.text === '--') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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
|
2026-04-26 18:44:59 +09:00
|
|
|
const unitSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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)
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
|
|
|
|
target: { value: 'Store in fridge' },
|
|
|
|
|
});
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
|
|
|
|
target: { value: '' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
// Change unit to ml to show concentration fields
|
2026-04-26 18:44:59 +09:00
|
|
|
const unitSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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
|
2026-04-26 18:44:59 +09:00
|
|
|
const concUnitSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).value === '') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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
|
2026-04-26 18:44:59 +09:00
|
|
|
const formSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'tablet') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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
|
2026-04-26 18:44:59 +09:00
|
|
|
const catSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find(
|
|
|
|
|
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
|
|
|
|
) as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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)
|
2026-04-26 18:44:59 +09:00
|
|
|
const unitSelect = screen
|
|
|
|
|
.getAllByRole('combobox')
|
|
|
|
|
.find((s) => (s as HTMLSelectElement).value === 'mg') as HTMLSelectElement;
|
2026-04-18 12:36:29 +09:00
|
|
|
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'));
|
2026-04-26 18:44:59 +09:00
|
|
|
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
|
|
|
|
target: { value: 'Brand X' },
|
|
|
|
|
});
|
2026-04-18 12:36:29 +09:00
|
|
|
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]!);
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
await waitFor(() =>
|
|
|
|
|
expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument(),
|
|
|
|
|
);
|
2026-04-18 12:36:29 +09:00
|
|
|
});
|
|
|
|
|
});
|