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
770 lines
30 KiB
TypeScript
770 lines
30 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function readJson<T>(relativePath: string): T {
|
|
return JSON.parse(
|
|
fs.readFileSync(path.resolve(__dirname, relativePath), 'utf8'),
|
|
) as T;
|
|
}
|
|
|
|
function read(relativePath: string): string {
|
|
return fs.readFileSync(path.resolve(__dirname, relativePath), 'utf8');
|
|
}
|
|
|
|
function expectNamedImport(source: string, modulePath: string, names: string[]): void {
|
|
const pattern = new RegExp(
|
|
`import\\s+\\{[^}]*\\}\\s+from '${modulePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}';`,
|
|
);
|
|
const match = source.match(pattern);
|
|
expect(match, `expected import from ${modulePath}`).toBeTruthy();
|
|
const importLine = match?.[0] ?? '';
|
|
for (const name of names) {
|
|
expect(importLine).toContain(name);
|
|
}
|
|
}
|
|
|
|
function getLeaf(record: Record<string, unknown>, dottedPath: string): unknown {
|
|
return dottedPath.split('.').reduce<unknown>((value, key) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
return (value as Record<string, unknown>)[key];
|
|
}, record);
|
|
}
|
|
|
|
describe('Quotes i18n wiring contract', () => {
|
|
it('T001: english quotes namespace exposes the planned top-level groups', () => {
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expect(Object.keys(en)).toEqual([
|
|
'common',
|
|
'quotesTab',
|
|
'quoteForm',
|
|
'quoteDetail',
|
|
'quoteLineItems',
|
|
'quoteRecipients',
|
|
'quoteConversion',
|
|
'quoteApproval',
|
|
'quoteTemplates',
|
|
'quotePreview',
|
|
'templateEditor',
|
|
'templatesPage',
|
|
]);
|
|
});
|
|
|
|
it('T003: QuoteForm uses msp/quotes translation keys for form chrome, workflow actions, and dialogs', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteForm.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'quoteForm.headings.editQuote',
|
|
'quoteForm.headings.newTemplate',
|
|
'common.actions.submitForApproval',
|
|
'quoteForm.actions.cancelQuote',
|
|
'quoteForm.actions.convertToBoth',
|
|
'quoteForm.fields.createFromTemplate',
|
|
'quoteForm.fields.recipients',
|
|
'quoteForm.dialogs.send.title',
|
|
'quoteForm.dialogs.approval.approveTitle',
|
|
'quoteForm.dialogs.conversion.title',
|
|
'quoteForm.notices.sent',
|
|
'quoteForm.errors.save',
|
|
'quoteForm.validation.clientRequired',
|
|
'common.labels.quoteLayout',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T004: QuoteForm no longer renders bare English JSX literals for form labels, actions, or dialog copy', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteForm.tsx');
|
|
|
|
const residualPatterns = [
|
|
/text="Loading quote form\.\.\."/,
|
|
/>Quote Accepted</,
|
|
/>Quote Rejected</,
|
|
/>Quote Converted</,
|
|
/>Submit for Approval</,
|
|
/>Send to Client</,
|
|
/>Cancel Quote</,
|
|
/>Convert to Contract</,
|
|
/>Convert to Invoice</,
|
|
/>Convert to Both</,
|
|
/>Create New Revision</,
|
|
/>Title</,
|
|
/>Description \/ Scope</,
|
|
/placeholder="Select client"/,
|
|
/placeholder="Select contact"/,
|
|
/placeholder="Select currency"/,
|
|
/title="Send Quote to Client"/,
|
|
/>Recipients</,
|
|
/>Additional email addresses \(comma-separated\)</,
|
|
/>Message \(optional\)</,
|
|
/>Approve Quote</,
|
|
/>Request Changes</,
|
|
/>Conversion Preview</,
|
|
/>Contract Items</,
|
|
/>Invoice Items</,
|
|
/>Excluded Items</,
|
|
/>Will Become Contract Lines</,
|
|
/>Will Become Invoice Charges</,
|
|
/>Excluded from Conversion</,
|
|
/text="Loading conversion preview\.\.\."/,
|
|
];
|
|
|
|
for (const pattern of residualPatterns) {
|
|
expect(source).not.toMatch(pattern);
|
|
}
|
|
});
|
|
|
|
it('T005: QuoteDetail uses msp/quotes translation keys for sections, actions, dialogs, and line-item badges', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteDetail.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'quoteDetail.sections.quoteLayout',
|
|
'quoteDetail.sections.versionHistory',
|
|
'quoteDetail.sections.activityLog',
|
|
'quoteDetail.actions.back',
|
|
'quoteDetail.actions.openConvertedContract',
|
|
'quoteDetail.alerts.clientConfigurationSubmitted',
|
|
'quoteDetail.clientSelections.selectedOptionalItem',
|
|
'quoteDetail.dialogs.approval.approveDescription',
|
|
'quoteDetail.dialogs.send.message',
|
|
'quoteDetail.errors.load',
|
|
'quoteDetail.notices.templateAssigned',
|
|
'quoteDetail.preview.loading',
|
|
'quoteDetail.table.description',
|
|
'quoteDetail.labels.phase',
|
|
'quoteForm.dialogs.conversion.title',
|
|
'quoteConversion.sections.willBecomeInvoiceCharges',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T006: QuoteDetail no longer renders bare English JSX literals for headings, actions, dialogs, or table labels', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteDetail.tsx');
|
|
|
|
const residualPatterns = [
|
|
/text="Loading quote details\.\.\."/,
|
|
/>Back to Quotes</,
|
|
/>Quote Detail</,
|
|
/>Back</,
|
|
/>Preview</,
|
|
/>Download PDF</,
|
|
/>Open Converted Contract</,
|
|
/>Open Converted Invoice</,
|
|
/>Quote Layout</,
|
|
/>Version History</,
|
|
/>Scope of Work</,
|
|
/>Quote Accepted</,
|
|
/>Quote Rejected</,
|
|
/>Line Items</,
|
|
/>Client Configuration Submitted</,
|
|
/>Description</,
|
|
/>Billing</,
|
|
/>Unit Price</,
|
|
/>Client Notes</,
|
|
/>Internal Notes</,
|
|
/>Activity Log</,
|
|
/>Conversion Preview</,
|
|
/>Contract Items</,
|
|
/>Invoice Items</,
|
|
/>Excluded Items</,
|
|
/>Quote Preview</,
|
|
/>Close</,
|
|
/>Send Quote to Client</,
|
|
/>Recipients</,
|
|
/>Optional message to include in the email</,
|
|
/>Approve Quote</,
|
|
/>Request Changes</,
|
|
];
|
|
|
|
for (const pattern of residualPatterns) {
|
|
expect(source).not.toMatch(pattern);
|
|
}
|
|
});
|
|
|
|
it('T007: QuotesTab uses msp/quotes translation keys for tabs, table chrome, row actions, and dialogs', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuotesTab.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'quotesTab.title',
|
|
'quotesTab.tabs.active',
|
|
'quotesTab.tabs.approval',
|
|
'quotesTab.actions.quoteActions',
|
|
'quotesTab.filters.client',
|
|
'quotesTab.filters.allClients',
|
|
'quotesTab.empty.byCategory',
|
|
'quotesTab.dialogs.delete.title',
|
|
'quotesTab.dialogs.send.title',
|
|
'quotesTab.dialogs.send.additionalRecipients',
|
|
'quotesTab.dialogs.send.messagePlaceholder',
|
|
'quotesTab.errors.load',
|
|
'quotesTab.loading',
|
|
'common.columns.quoteNumber',
|
|
'common.columns.actions',
|
|
'common.actions.newQuote',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T008: QuotesTab no longer renders bare English JSX literals for tabs, table labels, menus, or dialogs', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuotesTab.tsx');
|
|
|
|
const residualPatterns = [
|
|
/text="Loading quotes\.\.\."/,
|
|
/>Quotes</,
|
|
/>New Quote</,
|
|
/>Open</,
|
|
/>Send to Client</,
|
|
/>Resend</,
|
|
/>Send Reminder</,
|
|
/>Download PDF</,
|
|
/>Duplicate</,
|
|
/>Delete</,
|
|
/>Client</,
|
|
/>All clients</,
|
|
/>Approval Queue</,
|
|
/>Send Quote</,
|
|
/>Additional recipients \(comma-separated\)</,
|
|
/>Message \(optional\)</,
|
|
/placeholder="email@example\.com, another@example\.com"/,
|
|
/>Add a personal note for the recipient\.\.\.</,
|
|
];
|
|
|
|
for (const pattern of residualPatterns) {
|
|
expect(source).not.toMatch(pattern);
|
|
}
|
|
});
|
|
|
|
it('T009: QuoteDocumentTemplateEditor uses msp/quotes translation keys for editor chrome, preview pipeline, and footer metadata', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteDocumentTemplateEditor.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'templateEditor.title',
|
|
'templateEditor.headings.newLayout',
|
|
'templateEditor.actions.backToLayouts',
|
|
'templateEditor.actions.saveLayout',
|
|
'templateEditor.fields.layoutDetails',
|
|
'templateEditor.fields.templateName',
|
|
'templateEditor.tabs.visual',
|
|
'templateEditor.tabs.preview',
|
|
'templateEditor.preview.sampleScenario',
|
|
'templateEditor.preview.selectScenarioPrompt',
|
|
'templateEditor.pipeline.shape',
|
|
'templateEditor.actions.rerun',
|
|
'templateEditor.codeReadonly',
|
|
'templateEditor.footer.created',
|
|
'templateEditor.errors.saveFailed',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T010: QuoteDocumentTemplateEditor no longer renders bare English JSX literals for editor chrome or preview status copy', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteDocumentTemplateEditor.tsx');
|
|
|
|
const residualPatterns = [
|
|
/>New Quote Layout</,
|
|
/>Edit Quote Layout</,
|
|
/>Back to Layouts</,
|
|
/>Save Layout</,
|
|
/>Layout Details</,
|
|
/>Template Name</,
|
|
/>Version</,
|
|
/>Visual</,
|
|
/>Code</,
|
|
/>Design</,
|
|
/>Transforms</,
|
|
/>Preview</,
|
|
/>Sample Scenario</,
|
|
/placeholder="Select scenario\.\.\."/,
|
|
/>Shape</,
|
|
/>Render</,
|
|
/>Re-run</,
|
|
/>Select a sample scenario to generate an authoritative preview\.</,
|
|
/>Shaping and rendering preview\.\.\.</,
|
|
/>Code view is generated from the Visual workspace and is read-only\.</,
|
|
/>Created:</,
|
|
/>Last Updated:</,
|
|
];
|
|
|
|
for (const pattern of residualPatterns) {
|
|
expect(source).not.toMatch(pattern);
|
|
}
|
|
});
|
|
|
|
it('T011: QuoteLineItemsEditor uses msp/quotes keys plus shared billing-frequency hooks for line-item chrome', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteLineItemsEditor.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
expectNamedImport(source, '@alga-psa/billing/hooks/useBillingEnumOptions', [
|
|
'useBillingFrequencyOptions',
|
|
'useFormatBillingFrequency',
|
|
]);
|
|
expect(source).toContain('const billingFrequencyOptions = useBillingFrequencyOptions();');
|
|
expect(source).toContain('const formatBillingFrequency = useFormatBillingFrequency();');
|
|
|
|
const keyChecks = [
|
|
'quoteLineItems.title',
|
|
'quoteLineItems.columns.move',
|
|
'quoteLineItems.columns.unitPrice',
|
|
'quoteLineItems.labels.phaseSection',
|
|
'quoteLineItems.labels.optional',
|
|
'quoteLineItems.labels.recurring',
|
|
'quoteLineItems.actions.addDiscount',
|
|
'quoteLineItems.actions.hideDiscount',
|
|
'quoteLineItems.discounts.percentage',
|
|
'quoteLineItems.discounts.fixed',
|
|
'quoteLineItems.discounts.targets.namedItem',
|
|
'quoteLineItems.placeholders.servicePicker',
|
|
'quoteLineItems.empty',
|
|
'quoteLineItems.actions.remove',
|
|
'quoteLineItems.labels.setPrice',
|
|
'quoteLineItems.labels.noPriceInCurrency',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T012: QuoteLineItemsEditor no longer renders bare English JSX literals for line-item tables, controls, or hints', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteLineItemsEditor.tsx');
|
|
|
|
const residualPatterns = [
|
|
/>Line Items</,
|
|
/placeholder="Search or type custom item name\.\.\."/,
|
|
/>Hide Discount</,
|
|
/>Add Discount</,
|
|
/>Percentage discount</,
|
|
/>Fixed discount</,
|
|
/>Whole quote</,
|
|
/>Specific item</,
|
|
/>Specific service</,
|
|
/placeholder="Select item"/,
|
|
/placeholder="Select service"/,
|
|
/>Applies to the full quote subtotal</,
|
|
/>No line items yet\. Use the catalog search above to add your first item\.</,
|
|
/>Move</,
|
|
/>Item</,
|
|
/>Billing</,
|
|
/>Flags</,
|
|
/>Qty</,
|
|
/>Unit Price</,
|
|
/>Total</,
|
|
/>Actions</,
|
|
/label="Optional"/,
|
|
/label="Recurring"/,
|
|
/>Phase \/ Section</,
|
|
/>Set price</,
|
|
/>Remove</,
|
|
/>Expand</,
|
|
/>Collapse</,
|
|
/>Discount</,
|
|
/>Markup unavailable</,
|
|
/Markup can't be calculated because cost is tracked in/,
|
|
/% markup</,
|
|
];
|
|
|
|
for (const pattern of residualPatterns) {
|
|
expect(source).not.toMatch(pattern);
|
|
}
|
|
});
|
|
|
|
it('T013: QuoteDocumentTemplatesPage uses msp/quotes keys for page chrome, table columns, and row actions', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteDocumentTemplatesPage.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'templatesPage.title',
|
|
'templatesPage.description',
|
|
'templatesPage.cards.availableLayouts',
|
|
'templatesPage.actions.openMenu',
|
|
'templatesPage.dialogs.deleteConfirm',
|
|
'templatesPage.labels.custom',
|
|
'templatesPage.errors.load',
|
|
'templatesPage.errors.clone',
|
|
'templatesPage.errors.editCopy',
|
|
'templatesPage.errors.setDefault',
|
|
'templatesPage.errors.delete',
|
|
'common.actions.newLayout',
|
|
'common.actions.edit',
|
|
'common.actions.editAsCopy',
|
|
'common.actions.clone',
|
|
'common.actions.setAsDefault',
|
|
'common.actions.delete',
|
|
'common.columns.name',
|
|
'common.columns.source',
|
|
'common.columns.default',
|
|
'common.columns.actions',
|
|
'common.badges.standard',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T014: QuoteConversionDialog uses msp/quotes keys for dialog copy, mode descriptions, and summary labels', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteConversionDialog.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useFormatters', 'useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'quoteConversion.title',
|
|
'quoteConversion.description',
|
|
'quoteConversion.loading',
|
|
'quoteConversion.errors.title',
|
|
'quoteConversion.errors.load',
|
|
'quoteConversion.errors.convert',
|
|
'quoteConversion.partial.title',
|
|
'quoteConversion.partial.alreadyConverted',
|
|
'quoteConversion.partial.contractCreated',
|
|
'quoteConversion.partial.invoiceCreated',
|
|
'quoteConversion.partial.remainingItems',
|
|
'quoteConversion.mode.contract.label',
|
|
'quoteConversion.mode.contract.description',
|
|
'quoteConversion.mode.invoice.label',
|
|
'quoteConversion.mode.invoice.description',
|
|
'quoteConversion.mode.both.label',
|
|
'quoteConversion.mode.both.description',
|
|
'quoteConversion.sections.conversionMode',
|
|
'quoteConversion.sections.itemMappingPreview',
|
|
'quoteConversion.sections.contractItems',
|
|
'quoteConversion.sections.invoiceItems',
|
|
'quoteConversion.sections.excludedItems',
|
|
'quoteConversion.sections.quoteTotal',
|
|
'quoteConversion.sections.statusAfterConversion',
|
|
'quoteConversion.summary.fixed',
|
|
'quoteConversion.summary.discount',
|
|
'quoteConversion.summary.converted',
|
|
'quoteConversion.actions.converting',
|
|
'quoteConversion.actions.convertQuote',
|
|
'common.actions.cancel',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T015: QuoteApprovalDashboard uses msp/quotes keys for page labels, filters, loading/empty states, and table columns', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteApprovalDashboard.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useFormatters', 'useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
expect(source).toContain('const { formatCurrency, formatDate } = useFormatters();');
|
|
|
|
const keyChecks = [
|
|
'quoteApproval.title',
|
|
'quoteApproval.description',
|
|
'quoteApproval.settings.label',
|
|
'quoteApproval.settings.enabled',
|
|
'quoteApproval.settings.disabled',
|
|
'quoteApproval.filters.status',
|
|
'quoteApproval.filters.pendingApproval',
|
|
'quoteApproval.filters.approved',
|
|
'quoteApproval.actions.backToQuotes',
|
|
'quoteApproval.loading',
|
|
'quoteApproval.empty.title',
|
|
'quoteApproval.empty.pendingApproval',
|
|
'quoteApproval.empty.approved',
|
|
'quoteApproval.errors.load',
|
|
'quoteApproval.errors.settings',
|
|
'common.columns.quoteNumber',
|
|
'common.columns.client',
|
|
'common.columns.title',
|
|
'common.columns.amount',
|
|
'common.columns.status',
|
|
'common.columns.quoteDate',
|
|
'common.columns.validUntil',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T016: QuoteTemplatesList uses msp/quotes keys for list chrome, empty/loading states, and delete confirmation', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteTemplatesList.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useFormatters', 'useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
expect(source).toContain('const { formatCurrency, formatDate } = useFormatters();');
|
|
|
|
const keyChecks = [
|
|
'quoteTemplates.title',
|
|
'quoteTemplates.description',
|
|
'quoteTemplates.loading',
|
|
'quoteTemplates.empty.inline',
|
|
'quoteTemplates.actions.templateActions',
|
|
'quoteTemplates.actions.editTemplate',
|
|
'quoteTemplates.actions.createQuoteFromTemplate',
|
|
'quoteTemplates.actions.delete',
|
|
'quoteTemplates.dialogs.delete.title',
|
|
'quoteTemplates.dialogs.delete.message',
|
|
'quoteTemplates.errors.load',
|
|
'quoteTemplates.errors.delete',
|
|
'common.actions.newTemplate',
|
|
'common.actions.delete',
|
|
'common.actions.cancel',
|
|
'common.columns.title',
|
|
'common.columns.items',
|
|
'common.columns.currency',
|
|
'common.columns.created',
|
|
'common.columns.actions',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T017: QuotePreviewPanel uses msp/quotes keys for panel chrome, actions, and empty/loading/error states', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuotePreviewPanel.tsx');
|
|
const en = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/en/msp/quotes.json',
|
|
);
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
|
|
const keyChecks = [
|
|
'quotePreview.title',
|
|
'quotePreview.empty.selectQuote',
|
|
'quotePreview.empty.unavailable',
|
|
'quotePreview.placeholders.selectLayout',
|
|
'quotePreview.loading',
|
|
'quotePreview.actions.openQuote',
|
|
'quotePreview.errors.downloadPdf',
|
|
'quotePreview.errors.load',
|
|
'common.actions.downloadPdf',
|
|
'common.badges.standard',
|
|
];
|
|
|
|
for (const key of keyChecks) {
|
|
expect(source).toContain(`t('${key}'`);
|
|
expect(getLeaf(en, key)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T018: F012 currency formatter targets use locale-aware formatters instead of en-US helpers', () => {
|
|
const files = [
|
|
'../../src/components/billing-dashboard/quotes/QuotesTab.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteDetail.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteConversionDialog.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteApprovalDashboard.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteTemplatesList.tsx',
|
|
].map(read);
|
|
|
|
for (const source of files) {
|
|
expect(source).toContain('useFormatters');
|
|
expect(source).not.toContain("Intl.NumberFormat('en-US'");
|
|
}
|
|
});
|
|
|
|
it('T019: F013 date formatter targets use locale-aware formatters instead of toLocaleDateString/toLocaleString', () => {
|
|
const files = [
|
|
'../../src/components/billing-dashboard/quotes/QuotesTab.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteDetail.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteApprovalDashboard.tsx',
|
|
'../../src/components/billing-dashboard/quotes/QuoteDocumentTemplateEditor.tsx',
|
|
].map(read);
|
|
|
|
for (const source of files) {
|
|
expect(source).toContain('useFormatters');
|
|
expect(source).not.toContain('.toLocaleDateString(');
|
|
expect(source).not.toContain('.toLocaleString(');
|
|
}
|
|
});
|
|
|
|
it('T023: route namespaces load msp/quotes on billing and standalone quote routes', () => {
|
|
const source = read('../../../../packages/core/src/lib/i18n/config.ts');
|
|
|
|
expect(source).toContain("'/msp/billing': ['common', 'msp/core', 'features/billing', 'msp/quotes'");
|
|
expect(source).toContain("'/msp/quote-approvals': ['common', 'msp/core', 'features/billing', 'msp/quotes']");
|
|
expect(source).toContain("'/msp/quote-document-templates': ['common', 'msp/core', 'features/billing', 'msp/quotes']");
|
|
});
|
|
|
|
it('T025: QuoteLineItemsEditor markup chrome uses translation keys and locale files expose quoteLineItems.markup', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteLineItemsEditor.tsx');
|
|
const locales = ['en', 'de', 'es', 'fr', 'it', 'nl', 'pl', 'xx', 'yy'];
|
|
|
|
expect(source).toContain("t('quoteLineItems.markup.unavailableTooltip'");
|
|
expect(source).toContain("t('quoteLineItems.markup.unavailable'");
|
|
expect(source).toContain("t('quoteLineItems.markup.badge'");
|
|
expect(source).not.toContain("`${sign}${markup.toFixed(1)}% markup`");
|
|
expect(source).not.toContain("content={`Markup can't be calculated because cost is tracked in ${item.cost_currency} and this quote is in ${currencyCode}.`}");
|
|
|
|
for (const locale of locales) {
|
|
const messages = readJson<Record<string, unknown>>(
|
|
`../../../../server/public/locales/${locale}/msp/quotes.json`,
|
|
);
|
|
expect(getLeaf(messages, 'quoteLineItems.markup.badge')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteLineItems.markup.unavailable')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteLineItems.markup.unavailableTooltip')).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T026: QuoteSendRecipientsField uses quoteRecipients keys without raw combobox literals in rendered JSX paths', () => {
|
|
const source = read('../../src/components/billing-dashboard/quotes/QuoteSendRecipientsField.tsx');
|
|
const locales = ['en', 'de', 'es', 'fr', 'it', 'nl', 'pl', 'xx', 'yy'];
|
|
|
|
expectNamedImport(source, '@alga-psa/ui/lib/i18n/client', ['useTranslation']);
|
|
expect(source).toContain("const { t } = useTranslation('msp/quotes');");
|
|
expect(source).toContain("t('quoteRecipients.trigger.noClient'");
|
|
expect(source).toContain("t('quoteRecipients.trigger.noneAvailable'");
|
|
expect(source).toContain("t('quoteRecipients.trigger.add'");
|
|
expect(source).toContain("t('quoteRecipients.searchPlaceholder'");
|
|
expect(source).toContain("t('quoteRecipients.empty.noneAvailable'");
|
|
expect(source).toContain("t('quoteRecipients.empty.noMatches'");
|
|
expect(source).toContain("t('quoteRecipients.kind.internal'");
|
|
expect(source).toContain("t('quoteRecipients.kind.contact'");
|
|
expect(source).toContain("t('quoteRecipients.removeAriaLabel'");
|
|
expect(source).not.toContain("placeholder=\"Search by name or email…\"");
|
|
expect(source).not.toContain("{rows.length === 0 ? 'No recipients available' : 'No matches'}");
|
|
expect(source).not.toContain("{row.kind === 'internal' ? 'Internal' : 'Contact'}");
|
|
expect(source).not.toContain('aria-label={`Remove ${r.email}`}');
|
|
|
|
for (const locale of locales) {
|
|
const messages = readJson<Record<string, unknown>>(
|
|
`../../../../server/public/locales/${locale}/msp/quotes.json`,
|
|
);
|
|
expect(getLeaf(messages, 'quoteRecipients.trigger.noClient')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.trigger.noneAvailable')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.trigger.add')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.searchPlaceholder')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.empty.noneAvailable')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.empty.noMatches')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.kind.internal')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.kind.contact')).toBeDefined();
|
|
expect(getLeaf(messages, 'quoteRecipients.removeAriaLabel')).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('T027: msp/core locales expose quote sidebar keys and pseudo/de values are localized', () => {
|
|
const locales = ['en', 'de', 'es', 'fr', 'it', 'nl', 'pl', 'xx', 'yy'];
|
|
|
|
for (const locale of locales) {
|
|
const core = readJson<Record<string, unknown>>(
|
|
`../../../../server/public/locales/${locale}/msp/core.json`,
|
|
);
|
|
expect(getLeaf(core, 'nav.billing.sections.quotes')).toBeDefined();
|
|
expect(getLeaf(core, 'nav.billing.quotes')).toBeDefined();
|
|
expect(getLeaf(core, 'nav.billing.quoteBusinessTemplates')).toBeDefined();
|
|
expect(getLeaf(core, 'nav.billing.quoteLayouts')).toBeDefined();
|
|
}
|
|
|
|
const de = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/de/msp/core.json',
|
|
);
|
|
const xx = readJson<Record<string, unknown>>(
|
|
'../../../../server/public/locales/xx/msp/core.json',
|
|
);
|
|
|
|
expect(getLeaf(de, 'nav.billing.sections.quotes')).toBe('Angebote');
|
|
expect(getLeaf(de, 'nav.billing.quoteBusinessTemplates')).toBe('Angebotsvorlagen');
|
|
expect(getLeaf(de, 'nav.billing.quoteLayouts')).toBe('Angebotslayouts');
|
|
expect(getLeaf(xx, 'nav.billing.sections.quotes')).toBe('11111');
|
|
expect(getLeaf(xx, 'nav.billing.quotes')).toBe('11111');
|
|
expect(getLeaf(xx, 'nav.billing.quoteBusinessTemplates')).toBe('11111');
|
|
expect(getLeaf(xx, 'nav.billing.quoteLayouts')).toBe('11111');
|
|
});
|
|
|
|
it('T030: follow-on formatter cleanup removes remaining locale-pinned quote form and draft-money formatting', () => {
|
|
const quoteForm = read('../../src/components/billing-dashboard/quotes/QuoteForm.tsx');
|
|
const lineItemsEditor = read('../../src/components/billing-dashboard/quotes/QuoteLineItemsEditor.tsx');
|
|
const draftMoney = read('../../src/components/billing-dashboard/quotes/quoteLineItemDraft.ts');
|
|
|
|
expect(quoteForm).toContain('useFormatters');
|
|
expect(quoteForm).not.toContain("Intl.NumberFormat('en-US'");
|
|
expect(quoteForm).not.toContain('.toLocaleDateString(');
|
|
expect(lineItemsEditor).toContain('useFormatters');
|
|
expect(lineItemsEditor).not.toContain('formatDraftQuoteMoney(');
|
|
expect(draftMoney).toContain('new Intl.NumberFormat(undefined,');
|
|
expect(draftMoney).not.toContain("Intl.NumberFormat('en-US'");
|
|
});
|
|
|
|
it('T029: shared billing-frequency enums expose weekly across constants and all locale files', () => {
|
|
const billingConstants = read('../../src/constants/billing.ts');
|
|
const locales = ['en', 'de', 'es', 'fr', 'it', 'nl', 'pl', 'xx', 'yy'];
|
|
|
|
expect(billingConstants).toContain("export const BILLING_FREQUENCY_VALUES = ['weekly', 'monthly', 'quarterly', 'annually'] as const;");
|
|
expect(billingConstants).toContain("weekly: 'Weekly'");
|
|
|
|
for (const locale of locales) {
|
|
const billing = readJson<Record<string, unknown>>(
|
|
`../../../../server/public/locales/${locale}/features/billing.json`,
|
|
);
|
|
expect(getLeaf(billing, 'enums.billingFrequency.weekly')).toBeDefined();
|
|
}
|
|
});
|
|
});
|