MeshiTrack/packages/web/tests/app/(dashboard)/purchases/page.test.tsx

572 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
const { mockListPurchases, mockCreatePurchase, mockReceivePurchase, mockDeletePurchase } =
vi.hoisted(() => ({
mockListPurchases: vi.fn(),
mockCreatePurchase: vi.fn(),
mockReceivePurchase: vi.fn(),
mockDeletePurchase: vi.fn(),
}));
const { mockListStores } = vi.hoisted(() => ({ mockListStores: vi.fn() }));
const { mockListMedicines, mockListMedicineProducts } = vi.hoisted(() => ({
mockListMedicines: vi.fn(),
mockListMedicineProducts: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/purchases', () => ({
listPurchases: mockListPurchases,
createPurchase: mockCreatePurchase,
receivePurchase: mockReceivePurchase,
deletePurchase: mockDeletePurchase,
}));
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
vi.mock('@/services/medicines', () => ({
listMedicines: mockListMedicines,
listMedicineProducts: mockListMedicineProducts,
}));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
import PurchasesPage from '../../../../src/app/(dashboard)/purchases/page';
const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } };
beforeEach(() => {
vi.clearAllMocks();
mockListStores.mockResolvedValue(emptyResponse);
mockListMedicines.mockResolvedValue(emptyResponse);
});
describe('PurchasesPage', () => {
it('shows loading skeleton when session loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<PurchasesPage />);
expect(screen.getByText('Purchases')).toBeInTheDocument();
expect(screen.queryByText('Record Purchase')).not.toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<PurchasesPage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('shows empty state when no purchases', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument());
});
it('shows Record Purchase button', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => expect(screen.getByText('Record Purchase')).toBeInTheDocument());
});
it('shows error when purchases fail to load', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockRejectedValue(new Error('Network failure'));
render(<PurchasesPage />);
await waitFor(() => expect(screen.getByText('Network failure')).toBeInTheDocument());
});
it('renders ordered and received sections', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [
{
_id: 'p1',
status: 'ordered',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
{
_id: 'p2',
status: 'in_cabinet',
purchasedAt: '2026-01-02T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-02T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<PurchasesPage />);
await waitFor(() => expect(screen.getByText('Pending arrival')).toBeInTheDocument());
// "Received" appears as a section heading (h2) — use getAllByText since PurchaseCard may also render it
expect(screen.getAllByText('Received').length).toBeGreaterThan(0);
});
it('toggles the create form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
// After clicking, both the header toggle and the form show "Cancel"
expect(screen.getAllByRole('button', { name: 'Cancel' }).length).toBeGreaterThan(0);
});
it('shows Load more button when hasMore', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [],
pagination: { cursor: 'cur1', hasMore: true },
});
render(<PurchasesPage />);
await waitFor(() => expect(screen.getByText('Load more')).toBeInTheDocument());
});
it('shows validation error when no store selected', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByText('Save Purchase'));
// Submit without selecting a store
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
await waitFor(() => expect(screen.getByText('Please select a store.')).toBeInTheDocument());
});
it('shows validation error when no items', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByText('Save Purchase'));
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
await waitFor(() =>
expect(
screen.getByText('Add at least one item with a name and quantity.'),
).toBeInTheDocument(),
);
});
it('receives a purchase after confirmation', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [
{
_id: 'p1',
status: 'ordered',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockReceivePurchase.mockResolvedValue({});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Mark as received'));
await userEvent.click(screen.getByText('Mark as received'));
expect(mockReceivePurchase).toHaveBeenCalledWith('hh1', 'p1');
});
it('deletes a purchase after confirmation', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [
{
_id: 'p1',
status: 'ordered',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockDeletePurchase.mockResolvedValue({});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Cancel order'));
await userEvent.click(screen.getByText('Cancel order'));
expect(mockDeletePurchase).toHaveBeenCalledWith('hh1', 'p1');
});
it('shows error when receive fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [
{
_id: 'p1',
status: 'ordered',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockReceivePurchase.mockRejectedValue(new Error('Receive failed'));
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Mark as received'));
await userEvent.click(screen.getByText('Mark as received'));
await waitFor(() => expect(screen.getByText('Receive failed')).toBeInTheDocument());
});
it('shows error when delete fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [
{
_id: 'p1',
status: 'ordered',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockDeletePurchase.mockRejectedValue(new Error('Delete failed'));
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Cancel order'));
await userEvent.click(screen.getByText('Cancel order'));
await waitFor(() => expect(screen.getByText('Delete failed')).toBeInTheDocument());
});
it('creates a purchase successfully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListMedicineProducts.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockCreatePurchase.mockResolvedValue({
_id: 'p-new',
status: 'in_cabinet',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByText('Save Purchase'));
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
target: { value: 'Aspirin' },
});
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
await waitFor(() =>
expect(mockCreatePurchase).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ storeId: 'st-1' }),
),
);
// Form hides after success
await waitFor(() => expect(screen.queryByText('Save Purchase')).not.toBeInTheDocument());
});
it('cancels the create form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getAllByRole('button', { name: 'Cancel' }));
// Click the form's Cancel button (inside the form)
const cancelBtns = screen.getAllByRole('button', { name: 'Cancel' });
await userEvent.click(cancelBtns[cancelBtns.length - 1]!);
expect(screen.queryByText('Save Purchase')).not.toBeInTheDocument();
});
it('renders purchase items with price data', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [
{
_id: 'p1',
status: 'in_cabinet',
storeName: 'Walgreens',
purchasedAt: '2026-01-01T00:00:00.000Z',
items: [
{
name: 'Aspirin',
quantity: 30,
unit: 'tablet',
actualPrice: 5.99,
currency: 'USD',
},
],
householdId: 'hh1',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<PurchasesPage />);
await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument());
expect(screen.getByText(/5.99/)).toBeInTheDocument();
});
it('loads more purchases when Load more is clicked', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue({
data: [],
pagination: { cursor: 'cur1', hasMore: true },
});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Load more'));
await userEvent.click(screen.getByText('Load more'));
expect(mockListPurchases).toHaveBeenCalledTimes(2);
});
it('toggles online order checkbox and changes notes', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByLabelText('Online order (pending arrival)'));
fireEvent.click(screen.getByLabelText('Online order (pending arrival)'));
fireEvent.change(document.querySelector('input[maxLength="1000"]') as HTMLElement, {
target: { value: 'Some notes' },
});
expect(screen.getByLabelText('Online order (pending arrival)')).toBeChecked();
});
it('adds and removes purchase items', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByText('Save Purchase'));
const addItemBtn = screen.getByRole('button', { name: 'Add item' });
await userEvent.click(addItemBtn);
// Now 2 items — remove button shows
await waitFor(() => screen.getAllByText('Remove').length > 0);
await userEvent.click(screen.getAllByText('Remove')[0]!);
expect(screen.queryByText('Remove')).not.toBeInTheDocument();
});
it('changes item price and currency fields', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByPlaceholderText('9.99'));
fireEvent.change(screen.getByPlaceholderText('9.99'), { target: { value: '5.99' } });
fireEvent.change(screen.getByPlaceholderText('USD'), { target: { value: 'EUR' } });
expect(screen.getByPlaceholderText('9.99')).toBeInTheDocument();
});
it('changes item name and unit fields', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByPlaceholderText('Brand / product name'));
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
target: { value: 'Aspirin' },
});
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } });
expect(screen.getByPlaceholderText('Brand / product name')).toBeInTheDocument();
});
it('selects a medicine and product in the form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg' }],
pagination: { cursor: null, hasMore: false },
});
mockListMedicineProducts.mockResolvedValue({
data: [{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' }],
pagination: { cursor: null, hasMore: false },
});
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByText('Save Purchase'));
// Select a medicine
fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } });
// Wait for products to load
await waitFor(() => screen.getByDisplayValue('Select product'));
// Select a product
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
// Product details should auto-fill the form
expect(screen.getByDisplayValue('60')).toBeInTheDocument();
});
it('handles product list failure gracefully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg' }],
pagination: { cursor: null, hasMore: false },
});
mockListMedicineProducts.mockRejectedValue(new Error('Products failed'));
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByText('Save Purchase'));
fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } });
// Wait for products loading to finish (error is swallowed)
await waitFor(() => expect(mockListMedicineProducts).toHaveBeenCalled());
});
it('shows no stores message when store list is empty in form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Record Purchase'));
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => expect(screen.getByText(/No stores yet/)).toBeInTheDocument());
});
it('dismisses error', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockRejectedValue(new Error('Oops'));
render(<PurchasesPage />);
await waitFor(() => screen.getByText('Oops'));
await userEvent.click(screen.getByText('Dismiss'));
expect(screen.queryByText('Oops')).not.toBeInTheDocument();
});
});