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

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

255 lines
9.2 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
};
const getQuoteMock = vi.fn();
const listQuoteVersionsMock = vi.fn();
const getQuoteApprovalSettingsMock = vi.fn();
const getAllClientsForBillingMock = vi.fn();
const getAllContactsMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
}));
vi.mock('@alga-psa/ui/lib/i18n/client', () => ({
useTranslation: () => ({
t: (key: string, opts?: any) => {
let value: string = typeof opts === 'string' ? opts : (opts?.defaultValue ?? key);
if (opts && typeof opts === 'object') {
for (const [k, v] of Object.entries(opts)) {
if (k === 'defaultValue') continue;
value = value.split(`{{${k}}}`).join(String(v));
}
}
return value;
},
i18n: { language: 'en' },
}),
useFormatters: () => ({
formatCurrency: (amount: number) => `$${Number(amount).toFixed(2)}`,
formatDate: (date: unknown) => String(date),
formatNumber: (value: number) => String(value),
}),
}));
vi.mock('@radix-ui/themes', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Box: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@alga-psa/ui/components/Alert', () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@alga-psa/ui/components/TextArea', () => ({
TextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('@alga-psa/ui/components/Button', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
}));
vi.mock('@alga-psa/ui/components/Dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@alga-psa/ui/components/LoadingIndicator', () => ({
default: ({ text }: { text?: string }) => <div>{text ?? 'Loading...'}</div>,
}));
vi.mock('../../src/components/billing-dashboard/quotes/QuoteStatusBadge', () => ({
default: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock('../../src/actions/billingClientsActions', () => ({
getAllClientsForBilling: (...args: any[]) => getAllClientsForBillingMock(...args),
}));
vi.mock('@alga-psa/user-composition/actions', () => ({
getContactsForPicker: (...args: any[]) => getAllContactsMock(...args),
getAllUsersBasic: vi.fn().mockResolvedValue([]),
getUserAvatarUrlsBatchAction: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock('../../src/actions/quoteRecipientActions', () => ({
getQuoteRecipientContacts: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/actions/billingClientLocationActions', () => ({
getActiveClientLocationsForBilling: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/actions/quoteDocumentTemplates', () => ({
getQuoteDocumentTemplates: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/actions/quoteActions', () => ({
approveQuote: vi.fn(),
convertQuoteToBoth: vi.fn(),
convertQuoteToContract: vi.fn(),
convertQuoteToInvoice: vi.fn(),
createQuoteRevision: vi.fn(),
deleteQuote: vi.fn(),
downloadQuotePdf: vi.fn(),
duplicateQuote: vi.fn(),
getQuote: (...args: any[]) => getQuoteMock(...args),
getQuoteApprovalSettings: (...args: any[]) => getQuoteApprovalSettingsMock(...args),
getQuoteConversionPreview: vi.fn(),
listQuoteVersions: (...args: any[]) => listQuoteVersionsMock(...args),
renderQuotePreview: vi.fn(),
requestQuoteApprovalChanges: vi.fn(),
resendQuote: vi.fn(),
saveQuoteAsTemplate: vi.fn(),
sendQuote: vi.fn(),
sendQuoteReminder: vi.fn(),
submitQuoteForApproval: vi.fn(),
updateQuote: vi.fn(),
}));
describe('QuoteDetail accepted optional item review state', () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
getQuoteMock.mockResolvedValue({
quote_id: 'quote-accepted-1',
quote_number: 'Q-0042',
version: 1,
client_id: 'client-1',
contact_id: 'contact-1',
title: 'Managed Services Renewal',
description: 'Renewal scope',
quote_date: '2026-03-10T00:00:00.000Z',
valid_until: '2026-03-25T00:00:00.000Z',
status: 'accepted',
currency_code: 'USD',
subtotal: 15000,
discount_total: 0,
tax: 0,
total_amount: 15000,
client_notes: 'Please review the options.',
terms_and_conditions: 'Net 30',
internal_notes: 'Internal review note',
quote_items: [
{
quote_item_id: 'item-selected',
description: 'Optional security bundle',
quantity: 1,
unit_price: 5000,
total_price: 5000,
is_optional: true,
is_selected: true,
is_recurring: false,
is_discount: false,
billing_method: 'fixed',
service_name: 'Security Bundle',
service_sku: 'SEC-1',
},
{
quote_item_id: 'item-declined',
description: 'Optional onboarding workshop',
quantity: 1,
unit_price: 3000,
total_price: 3000,
is_optional: true,
is_selected: false,
is_recurring: false,
is_discount: false,
billing_method: 'fixed',
service_name: 'Workshop',
service_sku: 'WS-1',
},
{
quote_item_id: 'item-required',
description: 'Core managed services',
quantity: 1,
unit_price: 7000,
total_price: 7000,
is_optional: false,
is_selected: true,
is_recurring: true,
billing_frequency: 'monthly',
is_discount: false,
billing_method: 'fixed',
service_name: 'Managed Services',
service_sku: 'MS-1',
},
],
activities: [],
});
listQuoteVersionsMock.mockResolvedValue([]);
getQuoteApprovalSettingsMock.mockResolvedValue({ approvalRequired: false });
getAllClientsForBillingMock.mockResolvedValue([
{ client_id: 'client-1', client_name: 'Acme Co' },
]);
getAllContactsMock.mockResolvedValue([
{ contact_name_id: 'contact-1', full_name: 'Taylor Client', email: 'taylor@example.com' },
]);
});
it('T098a: accepted quote review shows selected and declined optional-item highlights for MSP conversion review', async () => {
const QuoteDetail = (await import('../../src/components/billing-dashboard/quotes/QuoteDetail')).default;
render(<QuoteDetail quoteId="quote-accepted-1" onBack={vi.fn()} onEdit={vi.fn()} onSelectVersion={vi.fn()} />);
await waitFor(() => expect(getQuoteMock).toHaveBeenCalledWith('quote-accepted-1'));
expect(await screen.findByText('Client Configuration Submitted')).toBeTruthy();
expect(screen.getByText('Review the optional line items below before converting this quote. Selected items are marked as included, and declined items are highlighted for follow-up.')).toBeTruthy();
expect(screen.getByText('Client selected this optional item')).toBeTruthy();
expect(screen.getByText('Client declined this optional item')).toBeTruthy();
expect(screen.getByText('Optional security bundle')).toBeTruthy();
expect(screen.getByText('Optional onboarding workshop')).toBeTruthy();
});
it('T118: converted quotes show links to the created contract and invoice on the detail view', async () => {
getQuoteMock.mockResolvedValueOnce({
quote_id: 'quote-converted-1',
quote_number: 'Q-0099',
version: 1,
client_id: 'client-1',
contact_id: 'contact-1',
title: 'Converted quote',
description: 'Converted scope',
quote_date: '2026-03-10T00:00:00.000Z',
valid_until: '2026-03-25T00:00:00.000Z',
status: 'converted',
currency_code: 'USD',
subtotal: 15000,
discount_total: 0,
tax: 0,
total_amount: 15000,
converted_contract_id: 'contract-123',
converted_invoice_id: 'invoice-456',
quote_items: [],
activities: [],
});
const QuoteDetail = (await import('../../src/components/billing-dashboard/quotes/QuoteDetail')).default;
render(<QuoteDetail quoteId="quote-converted-1" onBack={vi.fn()} onEdit={vi.fn()} onSelectVersion={vi.fn()} />);
await waitFor(() => expect(getQuoteMock).toHaveBeenCalledWith('quote-converted-1'));
expect(await screen.findByText('Open Converted Contract')).toBeTruthy();
expect(screen.getByText('Open Converted Invoice')).toBeTruthy();
});
});