/** * @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'); }); });