378 lines
13 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|