IcostPro/__tests__/components/AddIngredientModal.test.tsx
2026-02-12 21:40:57 +01:00

378 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddIngredientModal from '@/app/dashboard/ingredients/AddIngredientModal';
const mockCategories = [
{ _id: 'cat1', name: 'Dairy' },
{ _id: 'cat2', name: 'Vegetables' },
];
const mockSubcategories = [
{ _id: 'sub1', name: 'Fresh', categoryId: 'cat1' },
{ _id: 'sub2', name: 'Frozen', categoryId: 'cat2' },
];
const mockSuppliers = [
{ _id: 'sup1', name: 'Acme Foods' },
];
function setupFetchMock(overrides: Record<string, unknown> = {}) {
vi.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as RequestInfo).toString();
const method = init?.method ?? 'GET';
if (/\/api\/ingredients\/[a-zA-Z0-9]+$/.test(url) && method === 'GET') {
return {
ok: true,
json: () => Promise.resolve(overrides.ingredient ?? {}),
} as Response;
}
if (url.endsWith('/api/ingredients') || /\/api\/ingredients\/[a-zA-Z0-9]+$/.test(url)) {
if (method === 'POST' || method === 'PUT') {
return {
ok: overrides.saveOk !== false,
status: overrides.saveOk === false ? 400 : 201,
json: () => Promise.resolve(overrides.saveResponse ?? { _id: 'new-id' }),
} as Response;
}
}
if (url.endsWith('/api/categories')) {
return { ok: true, json: () => Promise.resolve(overrides.categories ?? mockCategories) } as Response;
}
if (url.endsWith('/api/subcategories')) {
return { ok: true, json: () => Promise.resolve(overrides.subcategories ?? mockSubcategories) } as Response;
}
if (url.endsWith('/api/suppliers')) {
return { ok: true, json: () => Promise.resolve(overrides.suppliers ?? mockSuppliers) } as Response;
}
return { ok: true, json: () => Promise.resolve({}) } as Response;
});
}
function getInputByLabel(container: HTMLElement, labelText: string): HTMLInputElement {
const labels = container.querySelectorAll('label');
for (const label of labels) {
if (label.textContent?.trim() === labelText) {
const input = label.parentElement?.querySelector('input, select, textarea');
if (input) return input as HTMLInputElement;
}
}
throw new Error(`Could not find input for label "${labelText}"`);
}
async function fillRequiredFields(container: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter');
await user.type(nameInput, 'Test Ingredient');
const categorySelect = within(container).getByDisplayValue('Select category\u2026');
fireEvent.change(categorySelect, { target: { value: 'cat1' } });
await waitFor(() => {
const subSelects = within(container).getAllByDisplayValue('Select subcategory\u2026');
expect(subSelects[0]).not.toBeDisabled();
});
const subSelect = within(container).getAllByDisplayValue('Select subcategory\u2026')[0];
fireEvent.change(subSelect, { target: { value: 'sub1' } });
const quantityInput = getInputByLabel(container, 'Quantity');
await user.type(quantityInput, '5');
const unitInput = within(container).getByPlaceholderText('kg, L, pcs\u2026');
await user.type(unitInput, 'kg');
const grossInput = getInputByLabel(container, 'Gross Price (\u20AC)');
await user.type(grossInput, '10');
const supplierSelect = within(container).getByDisplayValue('Select supplier\u2026');
fireEvent.change(supplierSelect, { target: { value: 'sup1' } });
}
describe('AddIngredientModal', () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onSaved: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
it('renders nothing when open=false', () => {
setupFetchMock();
const { container } = render(
<AddIngredientModal open={false} onClose={vi.fn()} onSaved={vi.fn()} />
);
expect(container.innerHTML).toBe('');
});
it('loads reference data on open', async () => {
setupFetchMock();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
});
it('shows title "Add Ingredient" in add mode', () => {
setupFetchMock();
const { container } = render(<AddIngredientModal {...defaultProps} />);
expect(within(container).getByText('Add Ingredient')).toBeInTheDocument();
});
it('shows title "Edit Ingredient" in edit mode', () => {
setupFetchMock({
ingredient: {
_id: 'ing1',
name: 'Butter',
categoryId: 'cat1',
subcategoryId: 'sub1',
quantity: 10,
unit: 'kg',
unitPrice: 5,
vat: 9,
supplierId: 'sup1',
},
});
const { container } = render(<AddIngredientModal {...defaultProps} ingredientId="ing1" />);
expect(within(container).getByText('Edit Ingredient')).toBeInTheDocument();
});
it('shows validation error for empty name', async () => {
setupFetchMock();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Ingredient name is required.')).toBeInTheDocument();
});
});
it('shows validation error for no category', async () => {
setupFetchMock();
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter');
await user.type(nameInput, 'Test');
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Please select a category.')).toBeInTheDocument();
});
});
it('shows validation error for no subcategory', async () => {
setupFetchMock();
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter');
await user.type(nameInput, 'Test');
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Please select a subcategory.')).toBeInTheDocument();
});
});
it('shows validation error for quantity <= 0', async () => {
setupFetchMock();
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
await waitFor(() => {
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
});
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Quantity must be greater than 0.')).toBeInTheDocument();
});
});
it('shows validation error for no unit', async () => {
setupFetchMock();
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
await waitFor(() => {
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
});
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
await user.type(getInputByLabel(container, 'Quantity'), '5');
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Unit is required.')).toBeInTheDocument();
});
});
it('shows validation error for gross price <= 0', async () => {
setupFetchMock();
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
await waitFor(() => {
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
});
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
await user.type(getInputByLabel(container, 'Quantity'), '5');
await user.type(within(container).getByPlaceholderText('kg, L, pcs\u2026'), 'kg');
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Gross price must be greater than 0.')).toBeInTheDocument();
});
});
it('shows validation error for no supplier', async () => {
setupFetchMock();
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
await waitFor(() => {
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
});
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
await user.type(getInputByLabel(container, 'Quantity'), '5');
await user.type(within(container).getByPlaceholderText('kg, L, pcs\u2026'), 'kg');
await user.type(getInputByLabel(container, 'Gross Price (\u20AC)'), '10');
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Please select a supplier.')).toBeInTheDocument();
});
});
it('calls onSaved and onClose on successful save', async () => {
setupFetchMock();
const user = userEvent.setup();
const onSaved = vi.fn();
const onClose = vi.fn();
const { container } = render(<AddIngredientModal open={true} onClose={onClose} onSaved={onSaved} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
await fillRequiredFields(container, user);
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
});
it('shows API error on failed save', async () => {
setupFetchMock({
saveOk: false,
saveResponse: { error: 'Server error occurred' },
});
const user = userEvent.setup();
const { container } = render(<AddIngredientModal {...defaultProps} />);
await waitFor(() => {
expect(within(container).getByText('Dairy')).toBeInTheDocument();
});
await fillRequiredFields(container, user);
fireEvent.click(within(container).getByText('Save'));
await waitFor(() => {
expect(within(container).getByText('Server error occurred')).toBeInTheDocument();
});
});
it('fetches and populates form in edit mode', async () => {
setupFetchMock({
ingredient: {
_id: 'ing1',
name: 'Butter',
categoryId: 'cat1',
subcategoryId: 'sub1',
quantity: 10,
unit: 'kg',
unitPrice: 5,
vat: 9,
supplierId: 'sup1',
},
});
const { container } = render(<AddIngredientModal {...defaultProps} ingredientId="ing1" />);
await waitFor(() => {
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter') as HTMLInputElement;
expect(nameInput.value).toBe('Butter');
});
});
});