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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
586 lines
21 KiB
TypeScript
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();
|
|
});
|
|
});
|