/**
* @vitest-environment jsdom
*/
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
// Stable t()/formatters so hooks that list `t` in effect dependencies do not
// re-run on every render (react-i18next's no-instance fallback returns an
// unstable t, which sends ContractWizard's mount effect into a render loop).
vi.mock('@alga-psa/ui/lib/i18n/client', () => {
const t = (key: string, opts?: string | { defaultValue?: string }) => {
if (typeof opts === 'string') return opts;
return typeof opts?.defaultValue === 'string' ? opts.defaultValue : key;
};
const translation = { t };
const formatters = {
formatDate: (value: unknown) => String(value),
formatCurrency: (value: number) => `$${value}`,
};
return {
useTranslation: () => translation,
useFormatters: () => formatters,
};
});
vi.mock('@alga-psa/ui/components/Dialog', () => ({
Dialog: ({ isOpen, children, footer }: { isOpen: boolean; children: React.ReactNode; footer?: React.ReactNode }) =>
isOpen ? (
{children}
{footer}
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
DialogFooter: ({ children }: { children: React.ReactNode }) => {children}
,
DialogHeader: ({ children }: { children: React.ReactNode }) => {children}
,
DialogTitle: ({ children }: { children: React.ReactNode }) => {children}
,
DialogDescription: ({ children }: { children: React.ReactNode }) => {children}
,
DialogTrigger: ({ children }: { children: React.ReactNode }) => {children}
,
}));
vi.mock('@alga-psa/ui/components/onboarding/WizardProgress', () => ({
WizardProgress: ({ currentStep }: { currentStep: number }) => (
),
}));
vi.mock('@alga-psa/ui/components/onboarding/WizardNavigation', () => ({
WizardNavigation: ({
onNext,
onBack,
onSaveDraft,
onFinish,
}: {
onNext: () => void;
onBack: () => void;
onSaveDraft: () => void;
onFinish: () => void;
}) => (
),
}));
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/ContractBasicsStep', () => ({
ContractBasicsStep: ({
data,
updateData,
onTemplateSelect,
}: {
data: any;
updateData: (next: Record) => void;
onTemplateSelect?: (templateId: string | null) => void;
}) => (
),
}));
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/FixedFeeServicesStep', () => ({
FixedFeeServicesStep: ({
data,
updateData,
}: {
data: any;
updateData: (next: Record) => void;
}) => (
),
}));
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/ProductsStep', () => ({
ProductsStep: ({ data }: { data: any }) => (
),
}));
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/HourlyServicesStep', () => ({
HourlyServicesStep: ({ data }: { data: any }) => (
),
}));
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/UsageBasedServicesStep', () => ({
UsageBasedServicesStep: ({ data }: { data: any }) => (
),
}));
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/ReviewContractStep', () => ({
ReviewContractStep: ({ data }: { data: any }) => (
),
}));
vi.mock('@alga-psa/billing/actions/contractWizardActions', () => ({
createClientContractFromWizard: vi.fn(),
listContractTemplatesForWizard: vi.fn(async () => []),
getContractTemplateSnapshotForClientWizard: vi.fn(),
}));
vi.mock('@alga-psa/billing/actions/billingSettingsActions', () => ({
getDefaultBillingSettings: vi.fn(async () => ({
defaultRenewalMode: 'manual',
defaultNoticePeriodDays: 30,
})),
}));
describe('ContractWizard resume behavior', () => {
beforeEach(() => {
document.body.removeAttribute('data-scroll-locked');
document.body.removeAttribute('style');
cleanup();
vi.clearAllMocks();
});
it('starts at Step 1 (Contract Basics) when opened (T033)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await waitFor(() => {
expect(screen.getByTestId('step-contract-basics')).toBeInTheDocument();
});
expect(screen.queryByTestId('step-fixed-fee')).not.toBeInTheDocument();
});
it('step 1 shows pre-populated client selection from draft (T034)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
const step = await screen.findByTestId('step-contract-basics');
expect(step).toHaveAttribute('data-client-id', 'client-99');
});
it('step 1 shows pre-populated contract name from draft (T035)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
const step = await screen.findByTestId('step-contract-basics');
expect(step).toHaveAttribute('data-contract-name', 'Draft Name');
});
it('step 1 shows pre-populated dates from draft (T036)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
const step = await screen.findByTestId('step-contract-basics');
expect(step).toHaveAttribute('data-start-date', '2026-01-01');
expect(step).toHaveAttribute('data-end-date', '2026-12-31');
});
it('step 2 (Fixed Fee) shows pre-populated services from draft (T037)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Next'));
});
const step = await screen.findByTestId('step-fixed-fee');
expect(step).toHaveAttribute('data-fixed-services-count', '2');
});
it('preserves cadence_owner from resumed drafts across fixed-fee step transitions (T113)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Next'));
});
const fixedStep = await screen.findByTestId('step-fixed-fee');
expect(fixedStep).toHaveAttribute('data-cadence-owner', 'contract');
await act(async () => {
await user.click(screen.getByText('Next'));
});
await act(async () => {
await user.click(screen.getByText('Back'));
});
expect(await screen.findByTestId('step-fixed-fee')).toHaveAttribute('data-cadence-owner', 'contract');
});
it('step 3 (Products) shows pre-populated products from draft (T038)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Next'));
});
await act(async () => {
await user.click(screen.getByText('Next'));
});
const step = await screen.findByTestId('step-products');
expect(step).toHaveAttribute('data-product-services-count', '2');
});
it('step 4 (Hourly) shows pre-populated hourly services from draft (T039)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Next'));
});
await act(async () => {
await user.click(screen.getByText('Next'));
});
await act(async () => {
await user.click(screen.getByText('Next'));
});
const step = await screen.findByTestId('step-hourly');
expect(step).toHaveAttribute('data-hourly-services-count', '2');
});
it('step 5 (Usage) shows pre-populated usage services from draft (T040)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
for (let i = 0; i < 4; i++) {
await act(async () => {
await user.click(screen.getByText('Next'));
});
}
const step = await screen.findByTestId('step-usage');
expect(step).toHaveAttribute('data-usage-services-count', '3');
});
it('step 6 (Review) shows complete draft data for review (T041)', async () => {
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
for (let i = 0; i < 5; i++) {
await act(async () => {
await user.click(screen.getByText('Next'));
});
}
const step = await screen.findByTestId('step-review');
expect(step).toHaveAttribute('data-contract-name', 'Draft Alpha');
expect(step).toHaveAttribute('data-fixed-services-count', '1');
expect(step).toHaveAttribute('data-product-services-count', '1');
expect(step).toHaveAttribute('data-hourly-services-count', '1');
expect(step).toHaveAttribute('data-usage-services-count', '1');
});
it('clicking Save Draft in resumed wizard updates existing draft (T042)', async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Save Draft'));
});
await waitFor(() => {
expect(createClientContractFromWizard).toHaveBeenCalled();
});
const [submission, options] = (createClientContractFromWizard as any).mock.calls[0];
expect(submission.contract_id).toBe('contract-1');
expect(options).toEqual({ isDraft: true });
});
it('resaving a resumed draft preserves cadence_owner and partial-period defaults in the emitted draft payload', async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
const onComplete = vi.fn();
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Next'));
});
const fixedStep = await screen.findByTestId('step-fixed-fee');
expect(fixedStep).toHaveAttribute('data-cadence-owner', 'contract');
expect(fixedStep).toHaveAttribute('data-billing-timing', 'advance');
expect(fixedStep).toHaveAttribute('data-enable-proration', 'false');
await act(async () => {
await user.click(screen.getByText('Toggle Partial-Period Adjustment'));
await user.click(screen.getByText('Save Draft'));
});
await waitFor(() => {
expect(onComplete).toHaveBeenCalled();
});
const [draftPayload] = onComplete.mock.calls.at(-1)!;
expect(draftPayload).toMatchObject({
contract_id: 'contract-1',
is_draft: true,
cadence_owner: 'contract',
billing_timing: 'advance',
enable_proration: true,
});
});
it('applies template-authored cadence_owner and billing_timing to the client contract submission payload (T236)', async () => {
const {
createClientContractFromWizard,
getContractTemplateSnapshotForClientWizard,
} = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-template' });
(getContractTemplateSnapshotForClientWizard as any).mockResolvedValue({
contract_name: 'Template Contract',
billing_frequency: 'monthly',
cadence_owner: 'contract',
billing_timing: 'advance',
enable_proration: false,
fixed_base_rate: 10000,
fixed_services: [{ service_id: 'svc-template', quantity: 1 }],
});
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render();
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Seed valid basics'));
await user.click(screen.getByText('Apply Template'));
});
await waitFor(() => {
expect(getContractTemplateSnapshotForClientWizard).toHaveBeenCalledWith('template-1');
});
await act(async () => {
await user.click(screen.getByText('Next'));
});
const fixedStep = await screen.findByTestId('step-fixed-fee');
expect(fixedStep).toHaveAttribute('data-cadence-owner', 'contract');
expect(fixedStep).toHaveAttribute('data-billing-timing', 'advance');
await act(async () => {
await user.click(screen.getByText('Save Draft'));
});
await waitFor(() => {
expect(createClientContractFromWizard).toHaveBeenCalledTimes(1);
});
const [submission, options] = (createClientContractFromWizard as any).mock.calls[0];
expect(options).toEqual({ isDraft: true });
expect(submission).toMatchObject({
template_id: 'template-1',
cadence_owner: 'contract',
billing_timing: 'advance',
enable_proration: false,
fixed_services: [{ service_id: 'svc-template', quantity: 1 }],
});
});
it('save draft does not create a duplicate contract (T043)', async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-99' });
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Save Draft'));
});
await waitFor(() => {
expect(createClientContractFromWizard).toHaveBeenCalledTimes(1);
});
const [submission] = (createClientContractFromWizard as any).mock.calls[0];
expect(submission.contract_id).toBe('contract-99');
});
it('save draft preserves the original contract_id (T044)', async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
const onComplete = vi.fn();
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Save Draft'));
});
await waitFor(() => {
expect(onComplete).toHaveBeenCalled();
});
const [data] = onComplete.mock.calls[0];
expect(data.contract_id).toBe('contract-1');
});
it("completing resumed wizard sets contract status to 'active' (T045)", async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
for (let i = 0; i < 5; i++) {
await act(async () => {
await user.click(screen.getByText('Next'));
});
}
await screen.findByTestId('step-review');
await act(async () => {
await user.click(screen.getByText('Finish'));
});
await waitFor(() => {
expect(createClientContractFromWizard).toHaveBeenCalled();
});
const [submission, options] = (createClientContractFromWizard as any).mock.calls[0];
expect(submission.contract_id).toBe('contract-1');
expect(options).toBeUndefined();
});
it('completing resumed wizard sets is_active to true (T046)', async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render(
,
);
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
for (let i = 0; i < 5; i++) {
await act(async () => {
await user.click(screen.getByText('Next'));
});
}
await screen.findByTestId('step-review');
await act(async () => {
await user.click(screen.getByText('Finish'));
});
await waitFor(() => {
expect(createClientContractFromWizard).toHaveBeenCalled();
});
const [_submission, options] = (createClientContractFromWizard as any).mock.calls[0];
expect(options).toBeUndefined();
});
it('submits the client cadence default for newly created recurring lines (T112)', async () => {
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-new' });
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
render();
await screen.findByTestId('step-contract-basics');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByText('Seed valid basics'));
});
for (let i = 0; i < 5; i++) {
await act(async () => {
await user.click(screen.getByText('Next'));
});
}
expect(await screen.findByTestId('step-review')).toHaveAttribute('data-cadence-owner', 'client');
await act(async () => {
await user.click(screen.getByText('Finish'));
});
await waitFor(() => {
expect(createClientContractFromWizard).toHaveBeenCalledTimes(1);
});
const [submission] = (createClientContractFromWizard as any).mock.calls[0];
expect(submission.cadence_owner).toBe('client');
});
});