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
1059 lines
34 KiB
TypeScript
1059 lines
34 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import React from 'react';
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import '@testing-library/jest-dom/vitest';
|
|
|
|
let mockDraftContracts: any[] = [];
|
|
let mockDraftResumeData: any = {};
|
|
|
|
const mockRouter = {
|
|
push: vi.fn(),
|
|
replace: vi.fn(),
|
|
};
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => mockRouter,
|
|
useSearchParams: () => new URLSearchParams('tab=contracts&subtab=drafts'),
|
|
}));
|
|
|
|
vi.mock('react-hot-toast', () => ({
|
|
toast: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Interpolating t() mock so messages like 'delete the draft "{{contractName}}"'
|
|
// render with their values instead of raw placeholders.
|
|
vi.mock('@alga-psa/ui/lib/i18n/client', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, opts?: string | (Record<string, unknown> & { defaultValue?: string })) => {
|
|
if (typeof opts === 'string') {
|
|
return opts;
|
|
}
|
|
let result = typeof opts?.defaultValue === 'string' ? opts.defaultValue : key;
|
|
if (opts) {
|
|
for (const [name, value] of Object.entries(opts)) {
|
|
if (name === 'defaultValue') continue;
|
|
result = result.split(`{{${name}}}`).join(String(value));
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
}),
|
|
useFormatters: () => ({
|
|
formatDate: (value: unknown) => String(value),
|
|
formatCurrency: (value: number) => `$${value}`,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/components/CustomTabs', () => ({
|
|
default: ({
|
|
tabs,
|
|
defaultTab,
|
|
}: {
|
|
tabs: Array<{ id?: string; label: string; content: React.ReactNode }>;
|
|
defaultTab: string;
|
|
}) => {
|
|
const tab =
|
|
tabs.find((t) => t.id === defaultTab)
|
|
?? tabs.find((t) => t.label === defaultTab)
|
|
?? tabs[0];
|
|
return (
|
|
<div>
|
|
<div>
|
|
{tabs.map((t) => (
|
|
<span key={t.label}>
|
|
{t.label}
|
|
<span data-testid={`tab-icon-${t.label}`}>{(t as { icon?: React.ReactNode }).icon ?? null}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div>{tab?.content}</div>
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/components/ConfirmationDialog', () => ({
|
|
ConfirmationDialog: ({
|
|
isOpen,
|
|
title,
|
|
message,
|
|
cancelLabel,
|
|
confirmLabel,
|
|
onClose,
|
|
onConfirm,
|
|
isConfirming,
|
|
}: {
|
|
isOpen: boolean;
|
|
title: string;
|
|
message: string;
|
|
cancelLabel: string;
|
|
confirmLabel: string;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
isConfirming?: boolean;
|
|
}) =>
|
|
isOpen ? (
|
|
<div data-testid="confirmation-dialog" data-confirming={isConfirming ? 'true' : 'false'}>
|
|
<div>{title}</div>
|
|
<div>{message}</div>
|
|
<button type="button" onClick={onClose}>
|
|
{cancelLabel}
|
|
</button>
|
|
<button type="button" onClick={onConfirm}>
|
|
{confirmLabel}
|
|
</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/ContractWizard', () => ({
|
|
ContractWizard: ({ open, editingContract }: { open: boolean; editingContract?: any }) =>
|
|
open ? (
|
|
<div
|
|
data-testid="contract-wizard"
|
|
data-contract-id={editingContract?.contract_id ?? ''}
|
|
data-client-id={editingContract?.client_id ?? ''}
|
|
data-contract-name={editingContract?.contract_name ?? ''}
|
|
/>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/template-wizard/TemplateWizard', () => ({
|
|
TemplateWizard: () => null,
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/ContractDialog', () => ({
|
|
ContractDialog: () => null,
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/contractActions', () => ({
|
|
deleteContract: vi.fn(async () => undefined),
|
|
getContractTemplates: vi.fn(async () => []),
|
|
getContractsWithClients: vi.fn(async () => []),
|
|
getDraftContracts: vi.fn(async () => mockDraftContracts),
|
|
updateContract: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/contractWizardActions', () => ({
|
|
getDraftContractForResume: vi.fn(async () => mockDraftResumeData),
|
|
}));
|
|
|
|
describe('Drafts tab DataTable', () => {
|
|
beforeAll(() => {
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
configurable: true,
|
|
get() {
|
|
return 1200;
|
|
},
|
|
});
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
document.body.removeAttribute('data-scroll-locked');
|
|
document.body.removeAttribute('style');
|
|
cleanup();
|
|
mockDraftContracts = [];
|
|
mockDraftResumeData = {};
|
|
vi.clearAllMocks();
|
|
|
|
const contractActions = await import('@alga-psa/billing/actions/contractActions');
|
|
vi.mocked(contractActions.deleteContract).mockReset();
|
|
vi.mocked(contractActions.deleteContract).mockResolvedValue(undefined);
|
|
vi.mocked(contractActions.getContractTemplates).mockReset();
|
|
vi.mocked(contractActions.getContractTemplates).mockResolvedValue([]);
|
|
vi.mocked(contractActions.getContractsWithClients).mockReset();
|
|
vi.mocked(contractActions.getContractsWithClients).mockResolvedValue([]);
|
|
vi.mocked(contractActions.getDraftContracts).mockReset();
|
|
vi.mocked(contractActions.getDraftContracts).mockImplementation(async () => mockDraftContracts);
|
|
vi.mocked(contractActions.updateContract).mockReset();
|
|
vi.mocked(contractActions.updateContract).mockResolvedValue(undefined);
|
|
|
|
const wizardActions = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
vi.mocked(wizardActions.getDraftContractForResume).mockReset();
|
|
vi.mocked(wizardActions.getDraftContractForResume).mockImplementation(async () => mockDraftResumeData);
|
|
});
|
|
|
|
it('renders contract name for each draft (T012)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
updated_at: '2026-01-02T00:00:00.000Z',
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText('Draft Alpha')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders client name for each draft (T013)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
updated_at: '2026-01-02T00:00:00.000Z',
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText('Acme Co')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders created date in localized format (T014)', async () => {
|
|
const createdAt = new Date(2026, 0, 1);
|
|
const updatedAt = new Date(2026, 0, 2);
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: createdAt,
|
|
updated_at: updatedAt,
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText(createdAt.toLocaleDateString())).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders last modified date in localized format (T015)', async () => {
|
|
const createdAt = new Date(2026, 0, 1);
|
|
const updatedAt = new Date(2026, 0, 5);
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: createdAt,
|
|
updated_at: updatedAt,
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText(updatedAt.toLocaleDateString())).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders actions dropdown for each row (T016)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
{
|
|
contract_id: 'contract-2',
|
|
contract_name: 'Draft Beta',
|
|
client_name: 'Beta LLC',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 3),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
const actionsButtons = await screen.findAllByRole('button', { name: /open menu/i });
|
|
expect(actionsButtons).toHaveLength(2);
|
|
});
|
|
|
|
it('actions dropdown contains Resume option (T017)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
const user = userEvent.setup();
|
|
const actionsButton = await screen.findByRole('button', { name: /open menu/i });
|
|
await act(async () => {
|
|
await user.click(actionsButton);
|
|
});
|
|
|
|
expect(await screen.findByText('Resume')).toBeInTheDocument();
|
|
});
|
|
|
|
it('actions dropdown contains Discard option (T018)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
const user = userEvent.setup();
|
|
const actionsButton = await screen.findByRole('button', { name: /open menu/i });
|
|
await act(async () => {
|
|
await user.click(actionsButton);
|
|
});
|
|
|
|
expect(await screen.findByText('Discard')).toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking column header sorts by that column (T019)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'b draft',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 10),
|
|
},
|
|
{
|
|
contract_id: 'contract-2',
|
|
contract_name: 'A draft',
|
|
client_name: 'Beta LLC',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 5),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
// Initial sorting is by updated_at desc, so "b draft" should be first.
|
|
await waitFor(async () => {
|
|
const rows = await screen.findAllByRole('row');
|
|
const firstDataRow = rows[1];
|
|
expect(within(firstDataRow).getByText('b draft')).toBeInTheDocument();
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const contractNameHeader = screen.getByRole('columnheader', { name: /contract name/i });
|
|
await act(async () => {
|
|
await user.click(contractNameHeader);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const rows = screen.getAllByRole('row');
|
|
const firstDataRow = rows[1];
|
|
expect(within(firstDataRow).getByText('A draft')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('search input filters drafts by contract name (T020)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
{
|
|
contract_id: 'contract-2',
|
|
contract_name: 'Draft Beta',
|
|
client_name: 'Beta LLC',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 3),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
await screen.findByText('Draft Beta');
|
|
|
|
const user = userEvent.setup();
|
|
const searchInput = screen.getByLabelText('Search draft contracts');
|
|
await act(async () => {
|
|
await user.type(searchInput, 'Alpha');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Draft Alpha')).toBeInTheDocument();
|
|
expect(screen.queryByText('Draft Beta')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('search input filters drafts by client name (T021)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
{
|
|
contract_id: 'contract-2',
|
|
contract_name: 'Draft Beta',
|
|
client_name: 'Beta LLC',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 3),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
await screen.findByText('Draft Beta');
|
|
|
|
const user = userEvent.setup();
|
|
const searchInput = screen.getByLabelText('Search draft contracts');
|
|
await act(async () => {
|
|
await user.type(searchInput, 'Beta');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Draft Beta')).toBeInTheDocument();
|
|
expect(screen.queryByText('Draft Alpha')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('search is case-insensitive (T022)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
const searchInput = screen.getByLabelText('Search draft contracts');
|
|
await act(async () => {
|
|
await user.type(searchInput, 'acme');
|
|
});
|
|
|
|
expect(await screen.findByText('Draft Alpha')).toBeInTheDocument();
|
|
});
|
|
|
|
it('pagination controls appear when drafts exceed page size (T023)', async () => {
|
|
mockDraftContracts = Array.from({ length: 11 }, (_v, idx) => ({
|
|
contract_id: `contract-${idx + 1}`,
|
|
contract_name: `Draft ${idx + 1}`,
|
|
client_name: `Client ${idx + 1}`,
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, idx + 1),
|
|
}));
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText('Draft 11')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Pagination')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument();
|
|
|
|
const nextButton = document.getElementById('draft-contracts-table-pagination-next-btn') as HTMLButtonElement | null;
|
|
expect(nextButton).not.toBeNull();
|
|
expect(nextButton?.disabled).toBe(false);
|
|
});
|
|
|
|
it('pagination controls navigate between pages (T024)', async () => {
|
|
mockDraftContracts = Array.from({ length: 11 }, (_v, idx) => ({
|
|
contract_id: `contract-${idx + 1}`,
|
|
contract_name: `Draft ${idx + 1}`,
|
|
client_name: `Client ${idx + 1}`,
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, idx + 1),
|
|
}));
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText('Draft 11')).toBeInTheDocument();
|
|
expect(screen.queryByText('Draft 1')).not.toBeInTheDocument();
|
|
|
|
const user = userEvent.setup();
|
|
const pageTwoButton = screen.getByRole('button', { name: '2' });
|
|
await act(async () => {
|
|
await user.click(pageTwoButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Draft 1')).toBeInTheDocument();
|
|
expect(screen.queryByText('Draft 11')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('empty state displays when no drafts exist (T025)', async () => {
|
|
mockDraftContracts = [];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(
|
|
await screen.findByText('No draft contracts. Start creating a new contract to save as draft.'),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('empty state message mentions saving drafts (T026)', async () => {
|
|
mockDraftContracts = [];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
expect(await screen.findByText(/start creating a new contract to save as draft/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking Resume opens ContractWizard dialog (T031)', async () => {
|
|
mockDraftResumeData = {
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
};
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
const user = userEvent.setup();
|
|
const actionsButton = await screen.findByRole('button', { name: /open menu/i });
|
|
await act(async () => {
|
|
await user.click(actionsButton);
|
|
});
|
|
|
|
const resumeItem = await screen.findByText('Resume');
|
|
await act(async () => {
|
|
await user.click(resumeItem);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const wizard = screen.getByTestId('contract-wizard');
|
|
expect(wizard).toHaveAttribute('data-contract-id', 'contract-1');
|
|
});
|
|
});
|
|
|
|
it('resumed wizard displays with draft data loaded (T032)', async () => {
|
|
mockDraftResumeData = {
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
};
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
const user = userEvent.setup();
|
|
const actionsButton = await screen.findByRole('button', { name: /open menu/i });
|
|
await act(async () => {
|
|
await user.click(actionsButton);
|
|
});
|
|
const resumeItem = await screen.findByText('Resume');
|
|
await act(async () => {
|
|
await user.click(resumeItem);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const wizard = screen.getByTestId('contract-wizard');
|
|
expect(wizard).toHaveAttribute('data-contract-id', 'contract-1');
|
|
expect(wizard).toHaveAttribute('data-client-id', 'client-1');
|
|
expect(wizard).toHaveAttribute('data-contract-name', 'Draft Alpha');
|
|
});
|
|
});
|
|
|
|
it('clicking Discard opens confirmation dialog (T049)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
const user = userEvent.setup();
|
|
const actionsButton = await screen.findByRole('button', { name: /open menu/i });
|
|
await act(async () => {
|
|
await user.click(actionsButton);
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
expect(await screen.findByTestId('confirmation-dialog')).toBeInTheDocument();
|
|
});
|
|
|
|
it('confirmation dialog displays contract name (T050)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
const dialog = await screen.findByTestId('confirmation-dialog');
|
|
expect(within(dialog).getByText(/Draft Alpha/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('confirmation dialog displays client name (T051)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
const dialog = await screen.findByTestId('confirmation-dialog');
|
|
expect(within(dialog).getByText(/Acme Co/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('confirmation dialog displays warning about permanent deletion (T052)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
const dialog = await screen.findByTestId('confirmation-dialog');
|
|
expect(within(dialog).getByText(/permanently delete/i)).toBeInTheDocument();
|
|
expect(within(dialog).getByText(/cannot be undone/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('confirmation dialog has Cancel button (T053)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
const dialog = await screen.findByTestId('confirmation-dialog');
|
|
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('confirmation dialog has Discard button (T054)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
const dialog = await screen.findByTestId('confirmation-dialog');
|
|
expect(within(dialog).getByRole('button', { name: 'Discard' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking Cancel closes dialog without deleting (T055)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { deleteContract } = await import('@alga-psa/billing/actions/contractActions');
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
expect(await screen.findByTestId('confirmation-dialog')).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
expect(deleteContract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clicking Discard calls deleteContract action (T056)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { deleteContract } = await import('@alga-psa/billing/actions/contractActions');
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(deleteContract).toHaveBeenCalledWith('contract-1');
|
|
});
|
|
});
|
|
|
|
it('deleted draft no longer appears in list (T057)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { deleteContract } = await import('@alga-psa/billing/actions/contractActions');
|
|
(deleteContract as unknown as { mockImplementationOnce: (fn: any) => void }).mockImplementationOnce(async () => {
|
|
mockDraftContracts = [];
|
|
});
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Draft Alpha')).not.toBeInTheDocument();
|
|
});
|
|
|
|
expect(
|
|
await screen.findByText('No draft contracts. Start creating a new contract to save as draft.'),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('deletion shows success toast notification (T058)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { toast } = await import('react-hot-toast');
|
|
const { deleteContract } = await import('@alga-psa/billing/actions/contractActions');
|
|
(deleteContract as unknown as { mockResolvedValueOnce: (val: any) => void }).mockResolvedValueOnce(undefined);
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(toast.success).toHaveBeenCalledWith('Draft discarded');
|
|
});
|
|
});
|
|
|
|
it('deletion error shows error toast notification (T059)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { toast } = await import('react-hot-toast');
|
|
const { deleteContract } = await import('@alga-psa/billing/actions/contractActions');
|
|
(deleteContract as unknown as { mockRejectedValueOnce: (val: any) => void }).mockRejectedValueOnce(
|
|
new Error('Delete failed'),
|
|
);
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
// Discard failures route through handleError, which prefers the provided
|
|
// fallback message over the raw error message.
|
|
expect(toast.error).toHaveBeenCalledWith('Failed to discard draft');
|
|
});
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drafts list refreshes automatically after successful deletion (T060)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { deleteContract, getDraftContracts } = await import('@alga-psa/billing/actions/contractActions');
|
|
(deleteContract as unknown as { mockImplementationOnce: (fn: any) => void }).mockImplementationOnce(async () => {
|
|
mockDraftContracts = [];
|
|
});
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
expect(getDraftContracts).toHaveBeenCalledTimes(1);
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(getDraftContracts).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it('tab badge count updates after deletion (T061)', async () => {
|
|
mockDraftContracts = [
|
|
{
|
|
contract_id: 'contract-1',
|
|
contract_name: 'Draft Alpha',
|
|
client_name: 'Acme Co',
|
|
created_at: new Date(2026, 0, 1),
|
|
updated_at: new Date(2026, 0, 2),
|
|
},
|
|
];
|
|
|
|
const { deleteContract } = await import('@alga-psa/billing/actions/contractActions');
|
|
(deleteContract as unknown as { mockImplementationOnce: (fn: any) => void }).mockImplementationOnce(async () => {
|
|
mockDraftContracts = [];
|
|
});
|
|
|
|
const Contracts = (await import('../src/components/billing-dashboard/contracts/Contracts')).default;
|
|
render(<Contracts />);
|
|
|
|
await screen.findByText('Draft Alpha');
|
|
|
|
const badgeContainer = screen.getByTestId('tab-icon-Drafts');
|
|
expect(within(badgeContainer).getByText('1')).toBeInTheDocument();
|
|
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(await screen.findByRole('button', { name: /open menu/i }));
|
|
});
|
|
await act(async () => {
|
|
await user.click(await screen.findByText('Discard'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(within(badgeContainer).queryByText('1')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|