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
255 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|