PSA/packages/projects/tests/projectMaterialsDrawer.test.tsx
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

586 lines
21 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import type { IProjectMaterial, IServicePrice } from '@alga-psa/types';
import type { CatalogPickerItem } from '../src/actions/materialCatalogActions';
import { formatCurrencyFromMinorUnits } from '@alga-psa/core';
let mockMaterials: IProjectMaterial[] = [];
let mockProducts: CatalogPickerItem[] = [];
let mockPrices: IServicePrice[] = [];
vi.mock('react-hot-toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@alga-psa/ui/components/AsyncSearchableSelect', () => {
// Minimal mock: renders a select that calls loadOptions on mount and on search,
// then exposes the options for selection.
const React = require('react');
function MockAsyncSearchableSelect({ value, onChange, loadOptions, placeholder, selectedLabel, limit }: any) {
const [options, setOptions] = React.useState<any[]>([]);
React.useEffect(() => {
loadOptions({ search: '', page: 1, limit: limit ?? 10 }).then((result: any) => {
setOptions(result.options);
});
}, [loadOptions, limit]);
return (
<select
data-testid="async-searchable-select"
value={value}
onChange={(event: any) => {
const opt = options.find((o: any) => o.value === event.target.value);
onChange(event.target.value, opt);
}}
>
<option value="">{placeholder}</option>
{options.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
return {
default: MockAsyncSearchableSelect,
__esModule: true,
};
});
vi.mock('@alga-psa/ui/components/CustomSelect', () => ({
default: ({ options, value, onValueChange, placeholder, id }: any) => (
<select
data-testid={id || 'custom-select'}
value={value}
onChange={(event) => onValueChange(event.target.value)}
>
<option value="">{placeholder}</option>
{options.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
}));
vi.mock('@alga-psa/ui/ui-reflection/useAutomationIdAndRegister', () => ({
useAutomationIdAndRegister: (_config: any, _actions: any, dataAutomationId?: string) => ({
automationIdProps: dataAutomationId ? { 'data-automation-id': dataAutomationId } : {},
updateMetadata: vi.fn(),
}),
}));
vi.mock('../src/actions/materialCatalogActions', () => ({
listProjectMaterials: vi.fn(async () => mockMaterials),
searchServiceCatalogForPicker: vi.fn(async () => ({ items: mockProducts, totalCount: mockProducts.length })),
getServicePrices: vi.fn(async () => mockPrices),
addProjectMaterial: vi.fn(async () => undefined),
deleteProjectMaterial: vi.fn(async () => undefined),
}));
describe('ProjectMaterialsDrawer', () => {
beforeEach(async () => {
cleanup();
mockMaterials = [];
mockProducts = [];
mockPrices = [];
const actions = await import('../src/actions/materialCatalogActions');
vi.mocked(actions.listProjectMaterials).mockClear();
vi.mocked(actions.listProjectMaterials).mockImplementation(async () => mockMaterials);
vi.mocked(actions.searchServiceCatalogForPicker).mockClear();
vi.mocked(actions.searchServiceCatalogForPicker).mockImplementation(async () => ({ items: mockProducts, totalCount: mockProducts.length }));
vi.mocked(actions.getServicePrices).mockClear();
vi.mocked(actions.getServicePrices).mockImplementation(async () => mockPrices);
vi.mocked(actions.addProjectMaterial).mockClear();
vi.mocked(actions.deleteProjectMaterial).mockClear();
const toast = await import('react-hot-toast');
vi.mocked(toast.toast.error).mockClear();
vi.mocked(toast.toast.success).mockClear();
});
it('shows loading state while materials are fetched (T003)', async () => {
const actions = await import('../src/actions/materialCatalogActions');
let resolveMaterials: (value: IProjectMaterial[]) => void = () => undefined;
const pending = new Promise<IProjectMaterial[]>((resolve) => {
resolveMaterials = resolve;
});
vi.mocked(actions.listProjectMaterials).mockReturnValueOnce(pending);
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(screen.getByText('Loading materials...')).toBeInTheDocument();
resolveMaterials([]);
await waitFor(() => {
expect(screen.getByText('No materials added to this project.')).toBeInTheDocument();
});
});
it('shows empty state when no materials exist (T004)', async () => {
mockMaterials = [];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(await screen.findByText('No materials added to this project.')).toBeInTheDocument();
});
it('renders table columns and material data (T005)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: 'W-100',
quantity: 2,
rate: 5000,
currency_code: 'USD',
description: null,
is_billed: false,
} as IProjectMaterial,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(await screen.findByText('Product')).toBeInTheDocument();
expect(screen.getByText('Qty')).toBeInTheDocument();
expect(screen.getByText('Rate')).toBeInTheDocument();
expect(screen.getByText('Total')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('Widget')).toBeInTheDocument();
expect(screen.getByText('(W-100)')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText(formatCurrencyFromMinorUnits(5000, 'en-US', 'USD'))).toBeInTheDocument();
expect(screen.getAllByText(formatCurrencyFromMinorUnits(10000, 'en-US', 'USD'))[0]).toBeInTheDocument();
});
it('shows Pending and Billed badges based on billing state (T006)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: null,
quantity: 1,
rate: 2500,
currency_code: 'USD',
description: null,
is_billed: false,
} as IProjectMaterial,
{
project_material_id: 'material-2',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-2',
service_name: 'Gadget',
sku: null,
quantity: 1,
rate: 3500,
currency_code: 'USD',
description: null,
is_billed: true,
} as IProjectMaterial,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(await screen.findByText('Pending')).toBeInTheDocument();
expect(screen.getByText('Billed')).toBeInTheDocument();
});
it('formats currency values from minor units (T007)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: null,
quantity: 3,
rate: 1234,
currency_code: 'EUR',
description: null,
is_billed: false,
} as IProjectMaterial,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(await screen.findByText(formatCurrencyFromMinorUnits(1234, 'en-US', 'EUR'))).toBeInTheDocument();
expect(screen.getAllByText(formatCurrencyFromMinorUnits(3702, 'en-US', 'EUR'))[0]).toBeInTheDocument();
});
it('groups unbilled totals by currency (T008)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: null,
quantity: 2,
rate: 5000,
currency_code: 'USD',
description: null,
is_billed: false,
} as IProjectMaterial,
{
project_material_id: 'material-2',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-2',
service_name: 'Gadget',
sku: null,
quantity: 1,
rate: 1000,
currency_code: 'EUR',
description: null,
is_billed: false,
} as IProjectMaterial,
{
project_material_id: 'material-3',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-3',
service_name: 'Billed Item',
sku: null,
quantity: 1,
rate: 999,
currency_code: 'USD',
description: null,
is_billed: true,
} as IProjectMaterial,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(await screen.findByText('Unbilled (USD):')).toBeInTheDocument();
expect(screen.getByText('Unbilled (EUR):')).toBeInTheDocument();
expect(screen.getAllByText(formatCurrencyFromMinorUnits(10000, 'en-US', 'USD'))[0]).toBeInTheDocument();
expect(screen.getAllByText(formatCurrencyFromMinorUnits(1000, 'en-US', 'EUR'))[0]).toBeInTheDocument();
});
it('loads product options for the dropdown (T009)', async () => {
mockProducts = [
{ service_id: 'service-1', service_name: 'Widget', sku: 'W-1' } as CatalogPickerItem,
{ service_id: 'service-2', service_name: 'Gadget', sku: null } as CatalogPickerItem,
];
const actions = await import('../src/actions/materialCatalogActions');
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
await screen.findByText('Materials');
await screen.findByRole('button', { name: 'Add' }).then((button) => button.click());
expect(await screen.findByText('Widget (W-1)')).toBeInTheDocument();
expect(screen.getByText('Gadget')).toBeInTheDocument();
expect(actions.searchServiceCatalogForPicker).toHaveBeenCalledWith({
search: '',
page: 1,
limit: 10,
item_kinds: ['product'],
is_active: true,
});
});
it('shows price selector options after product selection (T010)', async () => {
mockProducts = [
{ service_id: 'service-1', service_name: 'Widget', sku: 'W-1' } as CatalogPickerItem,
];
mockPrices = [
{ service_id: 'service-1', currency_code: 'USD', rate: 1000 } as IServicePrice,
{ service_id: 'service-1', currency_code: 'EUR', rate: 900 } as IServicePrice,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
const addButton = await screen.findByRole('button', { name: 'Add' });
addButton.click();
const productSelect = await screen.findByTestId('async-searchable-select');
fireEvent.change(productSelect, { target: { value: 'service-1' } });
const usdLabel = `USD - ${formatCurrencyFromMinorUnits(1000, 'en-US', 'USD')}`;
const eurLabel = `EUR - ${formatCurrencyFromMinorUnits(900, 'en-US', 'EUR')}`;
expect(await screen.findByText(usdLabel)).toBeInTheDocument();
expect(screen.getByText(eurLabel)).toBeInTheDocument();
});
it('defaults quantity to 1 and prevents values below 1 (T011)', async () => {
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
const addButton = await screen.findByRole('button', { name: 'Add' });
addButton.click();
const quantityInput = await screen.findByRole('spinbutton');
expect(quantityInput).toHaveValue(1);
fireEvent.change(quantityInput, { target: { value: '0' } });
expect(quantityInput).toHaveValue(1);
});
it('updates total when quantity or currency changes (T012)', async () => {
mockProducts = [
{ service_id: 'service-1', service_name: 'Widget', sku: null } as CatalogPickerItem,
];
mockPrices = [
{ service_id: 'service-1', currency_code: 'USD', rate: 1000 } as IServicePrice,
{ service_id: 'service-1', currency_code: 'EUR', rate: 2000 } as IServicePrice,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
const addButton = await screen.findByRole('button', { name: 'Add' });
addButton.click();
const productSelect = await screen.findByTestId('async-searchable-select');
fireEvent.change(productSelect, { target: { value: 'service-1' } });
const initialTotal = formatCurrencyFromMinorUnits(1000, 'en-US', 'USD');
expect(await screen.findByText(initialTotal)).toBeInTheDocument();
const quantityInput = await screen.findByRole('spinbutton');
fireEvent.change(quantityInput, { target: { value: '2' } });
const updatedTotal = formatCurrencyFromMinorUnits(2000, 'en-US', 'USD');
expect(await screen.findByText(updatedTotal)).toBeInTheDocument();
const currencySelect = await screen.findByTestId('project-materials-currency-select');
fireEvent.change(currencySelect, { target: { value: 'EUR' } });
const eurTotal = formatCurrencyFromMinorUnits(4000, 'en-US', 'EUR');
expect(await screen.findByText(eurTotal)).toBeInTheDocument();
});
it('adds material and refreshes the list (T013)', async () => {
mockProducts = [
{ service_id: 'service-1', service_name: 'Widget', sku: null } as CatalogPickerItem,
];
mockPrices = [
{ service_id: 'service-1', currency_code: 'USD', rate: 1500 } as IServicePrice,
];
const actions = await import('../src/actions/materialCatalogActions');
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
const addButton = await screen.findByRole('button', { name: 'Add' });
addButton.click();
const productSelect = await screen.findByTestId('async-searchable-select');
fireEvent.change(productSelect, { target: { value: 'service-1' } });
const quantityInput = await screen.findByRole('spinbutton');
fireEvent.change(quantityInput, { target: { value: '2' } });
const descriptionInput = await screen.findByPlaceholderText('Additional notes...');
fireEvent.change(descriptionInput, { target: { value: 'Install notes' } });
const submitButton = await screen.findByRole('button', { name: 'Add Material' });
submitButton.click();
await waitFor(() => {
expect(actions.addProjectMaterial).toHaveBeenCalledWith({
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
quantity: 2,
rate: 1500,
currency_code: 'USD',
description: 'Install notes',
});
});
await waitFor(() => {
expect(actions.listProjectMaterials).toHaveBeenCalledTimes(2);
});
});
it('shows validation errors for missing product or price (T014)', async () => {
mockProducts = [
{ service_id: 'service-1', service_name: 'Widget', sku: null } as CatalogPickerItem,
];
mockPrices = [];
const toast = await import('react-hot-toast');
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
const addButton = await screen.findByRole('button', { name: 'Add' });
addButton.click();
const submitButton = await screen.findByRole('button', { name: 'Add Material' });
submitButton.click();
expect(toast.toast.error).toHaveBeenCalledWith('Please select a product');
const productSelect = await screen.findByTestId('async-searchable-select');
fireEvent.change(productSelect, { target: { value: 'service-1' } });
submitButton.click();
expect(toast.toast.error).toHaveBeenCalledWith('Please select a currency');
});
it('only shows delete button for unbilled materials (T015)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: null,
quantity: 1,
rate: 1000,
currency_code: 'USD',
description: null,
is_billed: false,
} as IProjectMaterial,
{
project_material_id: 'material-2',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-2',
service_name: 'Gadget',
sku: null,
quantity: 1,
rate: 2000,
currency_code: 'USD',
description: null,
is_billed: true,
} as IProjectMaterial,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
const { container } = render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
await screen.findByText('Widget');
expect(
container.querySelector('[data-automation-id="project-materials-drawer-delete-material-1"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-automation-id="project-materials-drawer-delete-material-2"]')
).toBeNull();
});
it('deletes material and refreshes the list (T016)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: null,
quantity: 1,
rate: 1000,
currency_code: 'USD',
description: null,
is_billed: false,
} as IProjectMaterial,
];
const actions = await import('../src/actions/materialCatalogActions');
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
const { container } = render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
await screen.findByText('Widget');
const deleteButton = container.querySelector(
'[data-automation-id="project-materials-drawer-delete-material-1"]'
) as HTMLButtonElement;
deleteButton.click();
await waitFor(() => {
expect(actions.deleteProjectMaterial).toHaveBeenCalledWith('material-1');
});
await waitFor(() => {
expect(actions.listProjectMaterials).toHaveBeenCalledTimes(2);
});
});
it('shows no-client warning and hides add button (T017)', async () => {
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
render(<ProjectMaterialsDrawer projectId="project-1" />);
expect(
await screen.findByText('A client must be assigned to this project before materials can be added.')
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
});
it('includes data automation ids on interactive elements (T018)', async () => {
mockMaterials = [
{
project_material_id: 'material-1',
project_id: 'project-1',
client_id: 'client-1',
service_id: 'service-1',
service_name: 'Widget',
sku: null,
quantity: 1,
rate: 1000,
currency_code: 'USD',
description: null,
is_billed: false,
} as IProjectMaterial,
];
const ProjectMaterialsDrawer = (await import('../src/components/ProjectMaterialsDrawer')).default;
const { container } = render(<ProjectMaterialsDrawer projectId="project-1" clientId="client-1" />);
expect(container.querySelector('[data-automation-id="project-materials-drawer"]')).toBeInTheDocument();
const addButton = await screen.findByRole('button', { name: 'Add' });
expect(addButton).toHaveAttribute('data-automation-id', 'project-materials-drawer-add-btn');
addButton.click();
await screen.findByText('Product');
expect(
container.querySelector('[data-automation-id="project-materials-drawer-quantity"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-automation-id="project-materials-drawer-description"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-automation-id="project-materials-drawer-cancel-add-btn"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-automation-id="project-materials-drawer-save-add-btn"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-automation-id="project-materials-drawer-delete-material-1"]')
).toBeInTheDocument();
});
});