PSA/packages/billing/tests/quote/quoteActions.test.ts
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

1166 lines
39 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import Quote from '../../src/models/quote';
import QuoteActivity from '../../src/models/quoteActivity';
import QuoteItem from '../../src/models/quoteItem';
const USER_ID = '11111111-1111-4111-8111-111111111111';
const TENANT_ID = '22222222-2222-4222-8222-222222222222';
const QUOTE_ID = '33333333-3333-4333-8333-333333333333';
const SERVICE_ID = '44444444-4444-4444-8444-444444444444';
const QUOTE_ITEM_ID = '55555555-5555-4555-8555-555555555555';
const currentUser = {
id: USER_ID,
user_id: USER_ID,
tenant: TENANT_ID,
roles: [],
};
const mockKnex: any = vi.fn();
// Callable so authorization lookups can run table queries on the transaction.
const mockTrx: any = Object.assign((table: string) => mockKnex(table), { scope: 'trx' });
mockKnex.transaction = async (handler: (trx: typeof mockTrx) => Promise<unknown>) => handler(mockTrx);
const createTenantKnex = vi.fn();
const hasPermissionMock = vi.fn();
const sendEmailMock = vi.fn();
const getTenantEmailServiceInstance = vi.fn(() => ({ sendEmail: (...args: any[]) => sendEmailMock(...args) }));
const generatePDFMock = vi.fn();
const generateAndStoreMock = vi.fn();
const approvalSettingsMock = vi.fn();
const documentInsertMock = vi.fn();
const documentAssociationCreateMock = vi.fn();
const makeQuery = (result: any) => {
const chain: any = {};
chain.select = vi.fn(() => chain);
chain.where = vi.fn(() => chain);
chain.first = vi.fn(async () => result);
return chain;
};
// Awaitable list query for the authorization-subject lookups in quoteActions
// (user_roles/team_members/users), which await the chain directly.
const makeListQuery = (rows: any[]) => {
const chain: any = {};
chain.select = vi.fn(() => chain);
chain.where = vi.fn(() => chain);
chain.first = vi.fn(async () => rows[0]);
chain.then = (onFulfilled: any, onRejected: any) => Promise.resolve(rows).then(onFulfilled, onRejected);
chain.catch = (onRejected: any) => Promise.resolve(rows).catch(onRejected);
return chain;
};
vi.mock('@alga-psa/db', () => ({
createTenantKnex: (...args: any[]) => createTenantKnex(...args),
}));
vi.mock('@alga-psa/auth/withAuth', () => ({
withAuth:
(fn: any) =>
(...args: any[]) =>
fn(currentUser, { tenant: TENANT_ID }, ...args),
}));
vi.mock('@alga-psa/auth/rbac', () => ({
hasPermission: (...args: any[]) => hasPermissionMock(...args),
}));
vi.mock('@alga-psa/email', () => ({
TenantEmailService: {
getInstance: (...args: any[]) => getTenantEmailServiceInstance(...args),
},
}));
vi.mock('../../src/lib/quoteApprovalSettings', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/lib/quoteApprovalSettings')>();
return {
...actual,
getQuoteApprovalWorkflowSettings: (...args: any[]) => approvalSettingsMock(...args),
};
});
vi.mock('../../src/services', () => ({
buildQuoteConversionPreview: vi.fn(),
convertQuoteToDraftContract: vi.fn(),
convertQuoteToDraftContractAndInvoice: vi.fn(),
convertQuoteToDraftInvoice: vi.fn(),
createPDFGenerationService: vi.fn(() => ({
generatePDF: (...args: any[]) => generatePDFMock(...args),
generateAndStore: (...args: any[]) => generateAndStoreMock(...args),
})),
}));
vi.mock('@alga-psa/documents/models', () => ({
Document: {
insert: (...args: any[]) => documentInsertMock(...args),
},
DocumentAssociation: {
create: (...args: any[]) => documentAssociationCreateMock(...args),
},
}));
vi.mock('../../src/lib/documentsHelpers', () => ({
getStorageProviderFactoryAsync: vi.fn(),
getFileStoreModelAsync: vi.fn(),
getDocumentModelAsync: vi.fn(async () => ({
insert: (...args: any[]) => documentInsertMock(...args),
})),
getDocumentAssociationModelAsync: vi.fn(async () => ({
create: (...args: any[]) => documentAssociationCreateMock(...args),
})),
}));
const baseQuoteInput = {
client_id: '66666666-6666-4666-8666-666666666666',
title: 'Managed Services Proposal',
quote_date: '2026-03-13T00:00:00.000Z',
valid_until: '2026-03-20T00:00:00.000Z',
subtotal: 0,
discount_total: 0,
tax: 0,
total_amount: 0,
currency_code: 'USD',
is_template: false,
};
const baseItemInput = {
quote_id: QUOTE_ID,
description: 'Endpoint monitoring',
quantity: 2,
unit_price: 1500,
is_optional: false,
is_selected: true,
is_recurring: false,
is_taxable: true,
};
const templateQuote = {
quote_id: '77777777-7777-4777-8777-777777777777',
quote_number: null,
title: 'Template quote',
description: 'Template description',
internal_notes: 'Internal template note',
client_notes: 'Template note',
terms_and_conditions: 'Template terms',
currency_code: 'USD',
is_template: true,
quote_items: [
{
quote_item_id: '88888888-8888-4888-8888-888888888888',
quote_id: '77777777-7777-4777-8777-777777777777',
service_id: SERVICE_ID,
description: 'Managed Endpoint',
quantity: 3,
unit_price: 1200,
total_price: 3600,
net_amount: 3600,
tax_amount: 0,
display_order: 0,
is_optional: true,
is_selected: true,
is_recurring: true,
billing_frequency: 'monthly',
is_taxable: true,
billing_method: 'fixed',
},
{
quote_item_id: '99999999-9999-4999-8999-999999999999',
quote_id: '77777777-7777-4777-8777-777777777777',
service_id: null,
description: 'Onboarding',
quantity: 1,
unit_price: 5000,
total_price: 5000,
net_amount: 5000,
tax_amount: 0,
display_order: 1,
is_optional: false,
is_selected: true,
is_recurring: false,
billing_frequency: null,
is_taxable: true,
billing_method: 'fixed',
},
],
};
describe('quoteActions', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
mockKnex.mockImplementation((table: string) => {
if (table === 'tenants') {
return makeQuery({ client_name: 'Acme MSP' });
}
if (table === 'user_roles' || table === 'team_members' || table === 'users') {
return makeListQuery([]);
}
throw new Error(`Unexpected mockKnex table access: ${table}`);
});
createTenantKnex.mockResolvedValue({ knex: mockKnex, tenant: TENANT_ID });
hasPermissionMock.mockResolvedValue(true);
generatePDFMock.mockResolvedValue(Buffer.from('pdf-content'));
generateAndStoreMock.mockResolvedValue({ file_id: 'stored-file-1', storage_path: 'tenant/pdfs/Q-0001.pdf', file_size: 1024 });
documentInsertMock.mockResolvedValue({ document_id: 'doc-1' });
documentAssociationCreateMock.mockResolvedValue({ association_id: 'assoc-1' });
sendEmailMock.mockResolvedValue({ success: true, messageId: 'message-1' });
approvalSettingsMock.mockResolvedValue({ approvalRequired: false });
vi.spyOn(Quote, 'create').mockResolvedValue({ quote_id: QUOTE_ID } as any);
vi.spyOn(Quote, 'update').mockResolvedValue({ quote_id: QUOTE_ID } as any);
vi.spyOn(Quote, 'getById').mockResolvedValue({ quote_id: QUOTE_ID, quote_number: 'Q-0001' } as any);
vi.spyOn(Quote, 'listByTenant').mockResolvedValue({ data: [], total: 0, page: 1, pageSize: 25, totalPages: 1 } as any);
vi.spyOn(Quote, 'delete').mockResolvedValue(undefined as any);
vi.spyOn(QuoteActivity, 'create').mockResolvedValue({ activity_id: 'activity-1' } as any);
vi.spyOn(QuoteItem, 'create').mockImplementation(async (_knex, _tenant, input) => ({
quote_item_id: QUOTE_ITEM_ID,
...input,
total_price: Number(input.quantity) * Number(input.unit_price),
net_amount: Number(input.quantity) * Number(input.unit_price),
tax_amount: 0,
display_order: input.display_order ?? 0,
}) as any);
vi.spyOn(QuoteItem, 'update').mockImplementation(async (_knex, _tenant, quoteItemId, input) => ({
quote_item_id: quoteItemId,
...baseItemInput,
...input,
total_price: Number(input.quantity ?? baseItemInput.quantity) * Number(input.unit_price ?? baseItemInput.unit_price),
net_amount: Number(input.quantity ?? baseItemInput.quantity) * Number(input.unit_price ?? baseItemInput.unit_price),
tax_amount: 0,
display_order: 0,
}) as any);
vi.spyOn(QuoteItem, 'delete').mockResolvedValue(true);
vi.spyOn(QuoteItem, 'reorder').mockResolvedValue([] as any);
});
it('T042: createQuote requires billing:create permission', async () => {
hasPermissionMock.mockResolvedValue(false);
const { createQuote } = await import('../../src/actions/quoteActions');
const result = await createQuote(baseQuoteInput as any);
expect(result).toEqual({ permissionError: 'Permission denied: Cannot create quotes' });
expect(Quote.create).not.toHaveBeenCalled();
});
it('T043: createQuote returns the created quote with generated number', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValue({ quote_id: QUOTE_ID, quote_number: 'Q-0007', title: baseQuoteInput.title } as any);
const { createQuote } = await import('../../src/actions/quoteActions');
const result = await createQuote(baseQuoteInput as any);
expect(Quote.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({
title: baseQuoteInput.title,
created_by: USER_ID,
})
);
expect(result).toMatchObject({ quote_id: QUOTE_ID, quote_number: 'Q-0007' });
});
it('T044: updateQuote enforces status transition rules', async () => {
vi.spyOn(Quote, 'update').mockRejectedValue(new Error('Invalid quote status transition from draft to accepted'));
const { updateQuote } = await import('../../src/actions/quoteActions');
await expect(updateQuote(QUOTE_ID, { status: 'accepted' } as any)).rejects.toThrow(
'Invalid quote status transition from draft to accepted'
);
});
it('T045: deleteQuote rejects deletion of sent or accepted quotes', async () => {
vi.spyOn(Quote, 'delete').mockRejectedValue(new Error('Quote with business history cannot be deleted; archive it instead'));
const { deleteQuote } = await import('../../src/actions/quoteActions');
await expect(deleteQuote(QUOTE_ID)).rejects.toThrow(
'Quote with business history cannot be deleted; archive it instead'
);
});
it('T046: addQuoteItem with service_id returns catalog-populated defaults', async () => {
vi.spyOn(QuoteItem, 'create').mockResolvedValue({
quote_item_id: QUOTE_ITEM_ID,
...baseItemInput,
service_id: SERVICE_ID,
service_name: 'Managed Endpoint',
service_sku: 'ME-100',
billing_method: 'fixed',
unit_of_measure: 'device',
total_price: 3000,
net_amount: 3000,
tax_amount: 0,
display_order: 0,
});
const { addQuoteItem } = await import('../../src/actions/quoteActions');
const result = await addQuoteItem({ ...baseItemInput, service_id: SERVICE_ID } as any);
expect(QuoteItem.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({ service_id: SERVICE_ID, created_by: USER_ID })
);
expect(result).toMatchObject({
service_name: 'Managed Endpoint',
service_sku: 'ME-100',
unit_price: 1500,
billing_method: 'fixed',
});
});
it('T047: addQuoteItem accepts all billing methods', async () => {
const { addQuoteItem } = await import('../../src/actions/quoteActions');
const billingMethods = ['fixed', 'hourly', 'usage'] as const;
for (const billingMethod of billingMethods) {
const result = await addQuoteItem({
...baseItemInput,
billing_method: billingMethod,
} as any);
expect(result).toMatchObject({ billing_method: billingMethod });
}
});
it('T048: addQuoteItem allows rate overrides different from catalog default', async () => {
const { addQuoteItem } = await import('../../src/actions/quoteActions');
const result = await addQuoteItem({
...baseItemInput,
service_id: SERVICE_ID,
unit_price: 2750,
} as any);
expect(QuoteItem.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({ service_id: SERVICE_ID, unit_price: 2750 })
);
expect(result).toMatchObject({ unit_price: 2750 });
});
it('T049: addQuoteItem stores is_optional when flagged', async () => {
const { addQuoteItem } = await import('../../src/actions/quoteActions');
const result = await addQuoteItem({
...baseItemInput,
is_optional: true,
} as any);
expect(QuoteItem.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({ is_optional: true })
);
expect(result).toMatchObject({ is_optional: true });
});
it('T050: addQuoteItem stores recurring billing metadata', async () => {
const { addQuoteItem } = await import('../../src/actions/quoteActions');
const result = await addQuoteItem({
...baseItemInput,
is_recurring: true,
billing_frequency: 'monthly',
} as any);
expect(QuoteItem.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({ is_recurring: true, billing_frequency: 'monthly' })
);
expect(result).toMatchObject({ is_recurring: true, billing_frequency: 'monthly' });
});
it('T050a: creating a quote template keeps is_template=true and omits numbering', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValue({
quote_id: QUOTE_ID,
is_template: true,
quote_number: null,
title: 'Template quote',
} as any);
const { createQuote } = await import('../../src/actions/quoteActions');
const result = await createQuote({
...baseQuoteInput,
client_id: null,
is_template: true,
} as any);
expect(Quote.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({ is_template: true })
);
expect(result).toMatchObject({ is_template: true, quote_number: null });
});
it('T050b: createQuoteFromTemplate copies all template items into a new draft quote', async () => {
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(templateQuote as any)
.mockResolvedValueOnce({
quote_id: QUOTE_ID,
quote_number: 'Q-0099',
is_template: false,
quote_items: templateQuote.quote_items,
} as any);
const { createQuoteFromTemplate } = await import('../../src/actions/quoteActions');
const result = await createQuoteFromTemplate(templateQuote.quote_id, {
client_id: baseQuoteInput.client_id,
quote_date: baseQuoteInput.quote_date,
valid_until: baseQuoteInput.valid_until,
} as any);
expect(QuoteItem.create).toHaveBeenCalledTimes(2);
expect(QuoteItem.create).toHaveBeenNthCalledWith(
1,
mockTrx,
TENANT_ID,
expect.objectContaining({
quote_id: QUOTE_ID,
description: 'Managed Endpoint',
is_optional: true,
is_recurring: true,
billing_frequency: 'monthly',
})
);
expect(QuoteItem.create).toHaveBeenNthCalledWith(
2,
mockTrx,
TENANT_ID,
expect.objectContaining({
quote_id: QUOTE_ID,
description: 'Onboarding',
is_optional: false,
is_recurring: false,
})
);
expect(result).toMatchObject({ quote_id: QUOTE_ID, quote_items: templateQuote.quote_items });
});
it('T050c: createQuoteFromTemplate returns a newly numbered draft quote', async () => {
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(templateQuote as any)
.mockResolvedValueOnce({
quote_id: QUOTE_ID,
quote_number: 'Q-0042',
is_template: false,
quote_items: [],
} as any);
const { createQuoteFromTemplate } = await import('../../src/actions/quoteActions');
const result = await createQuoteFromTemplate(templateQuote.quote_id, {
client_id: baseQuoteInput.client_id,
quote_date: baseQuoteInput.quote_date,
valid_until: baseQuoteInput.valid_until,
} as any);
expect(result).toMatchObject({ quote_number: 'Q-0042', is_template: false });
});
it('T050d: listQuotes separates template and non-template views with is_template filtering', async () => {
const { listQuotes } = await import('../../src/actions/quoteActions');
await listQuotes({ is_template: true });
await listQuotes();
expect(Quote.listByTenant).toHaveBeenNthCalledWith(
1,
mockKnex,
TENANT_ID,
expect.objectContaining({ is_template: true })
);
expect(Quote.listByTenant).toHaveBeenNthCalledWith(
2,
mockKnex,
TENANT_ID,
expect.not.objectContaining({ is_template: expect.anything() })
);
});
it('T050e: quote templates are excluded from the normal status lifecycle', async () => {
vi.spyOn(Quote, 'update').mockRejectedValue(new Error('Quote templates do not participate in status transitions'));
const { updateQuote } = await import('../../src/actions/quoteActions');
await expect(updateQuote(QUOTE_ID, { status: 'sent' } as any)).rejects.toThrow(
'Quote templates do not participate in status transitions'
);
});
it('T119: submitQuoteForApproval changes status from draft to pending_approval', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValueOnce({
quote_id: QUOTE_ID,
status: 'draft',
is_template: false,
} as any);
vi.spyOn(Quote, 'update').mockResolvedValueOnce({
quote_id: QUOTE_ID,
status: 'pending_approval',
} as any);
const { submitQuoteForApproval } = await import('../../src/actions/quoteActions');
const result = await submitQuoteForApproval(QUOTE_ID);
expect(Quote.update).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
QUOTE_ID,
expect.objectContaining({
status: 'pending_approval',
updated_by: USER_ID,
})
);
expect(result).toMatchObject({ quote_id: QUOTE_ID, status: 'pending_approval' });
});
it('T120: approveQuote changes status from pending_approval to approved', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValueOnce({
quote_id: QUOTE_ID,
status: 'pending_approval',
is_template: false,
} as any);
vi.spyOn(Quote, 'update').mockResolvedValueOnce({
quote_id: QUOTE_ID,
status: 'approved',
} as any);
const { approveQuote } = await import('../../src/actions/quoteActions');
const result = await approveQuote(QUOTE_ID, 'Looks good');
expect(Quote.update).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
QUOTE_ID,
expect.objectContaining({
status: 'approved',
updated_by: USER_ID,
})
);
expect(QuoteActivity.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({
quote_id: QUOTE_ID,
activity_type: 'approved',
description: 'Quote approved: Looks good',
metadata: { comment: 'Looks good' },
})
);
expect(result).toMatchObject({ quote_id: QUOTE_ID, status: 'approved' });
});
it('T121: requestQuoteApprovalChanges returns a pending quote to draft with comment', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValueOnce({
quote_id: QUOTE_ID,
status: 'pending_approval',
is_template: false,
} as any);
vi.spyOn(Quote, 'update').mockResolvedValueOnce({
quote_id: QUOTE_ID,
status: 'draft',
} as any);
const { requestQuoteApprovalChanges } = await import('../../src/actions/quoteActions');
const result = await requestQuoteApprovalChanges(QUOTE_ID, 'Please revise pricing');
expect(Quote.update).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
QUOTE_ID,
expect.objectContaining({
status: 'draft',
updated_by: USER_ID,
})
);
expect(QuoteActivity.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({
quote_id: QUOTE_ID,
activity_type: 'approval_changes_requested',
description: 'Approval changes requested: Please revise pricing',
metadata: { comment: 'Please revise pricing' },
})
);
expect(result).toMatchObject({ quote_id: QUOTE_ID, status: 'draft' });
});
it('T122: approveQuote requires quotes:approve permission', async () => {
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => (
!(resource === 'quotes' && action === 'approve')
));
const { approveQuote } = await import('../../src/actions/quoteActions');
const result = await approveQuote(QUOTE_ID, 'Denied');
expect(result).toEqual({ permissionError: 'Permission denied: Cannot approve quotes' });
expect(Quote.update).not.toHaveBeenCalled();
expect(QuoteActivity.create).not.toHaveBeenCalled();
});
it('T123: sendQuote allows draft quotes to be sent directly when approval is disabled', async () => {
approvalSettingsMock.mockResolvedValueOnce({ approvalRequired: false });
const draftQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(draftQuote as any)
.mockResolvedValueOnce({ ...draftQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
const result = await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
expect(approvalSettingsMock).toHaveBeenCalledWith(mockKnex, TENANT_ID);
expect(Quote.update).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
QUOTE_ID,
expect.objectContaining({ status: 'sent' })
);
expect(result).toMatchObject({ status: 'sent' });
});
it('T124: duplicateQuote creates a draft copy with a new quote number and copied items', async () => {
const duplicatedQuoteId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const duplicatedQuote = {
quote_id: duplicatedQuoteId,
quote_number: 'Q-0099',
status: 'draft',
is_template: false,
quote_items: [
{ quote_item_id: 'dup-item-1', description: templateQuote.quote_items[0].description },
{ quote_item_id: 'dup-item-2', description: templateQuote.quote_items[1].description },
],
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce({
...templateQuote,
quote_id: QUOTE_ID,
quote_number: 'Q-0007',
is_template: false,
client_id: baseQuoteInput.client_id,
contact_id: 'contact-1',
quote_date: baseQuoteInput.quote_date,
valid_until: baseQuoteInput.valid_until,
} as any)
.mockResolvedValueOnce(duplicatedQuote as any);
vi.spyOn(Quote, 'create').mockResolvedValueOnce({ quote_id: duplicatedQuoteId } as any);
const { duplicateQuote } = await import('../../src/actions/quoteActions');
const result = await duplicateQuote(QUOTE_ID);
expect(Quote.create).toHaveBeenCalledWith(
mockTrx,
TENANT_ID,
expect.objectContaining({
title: templateQuote.title,
is_template: false,
created_by: USER_ID,
})
);
expect(QuoteItem.create).toHaveBeenCalledTimes(templateQuote.quote_items.length);
expect(result).toMatchObject({
quote_id: duplicatedQuoteId,
quote_number: 'Q-0099',
status: 'draft',
quote_items: expect.arrayContaining([
expect.objectContaining({ description: 'Managed Endpoint' }),
expect.objectContaining({ description: 'Onboarding' }),
]),
});
});
it('T125: duplicateQuote gives duplicated items fresh item IDs', async () => {
const duplicatedQuoteId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const duplicatedItemIds = ['new-item-1', 'new-item-2'];
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce({
...templateQuote,
quote_id: QUOTE_ID,
quote_number: 'Q-0008',
is_template: false,
} as any)
.mockResolvedValueOnce({
quote_id: duplicatedQuoteId,
quote_number: 'Q-0100',
status: 'draft',
is_template: false,
quote_items: [
{ ...templateQuote.quote_items[0], quote_item_id: duplicatedItemIds[0] },
{ ...templateQuote.quote_items[1], quote_item_id: duplicatedItemIds[1] },
],
} as any);
vi.spyOn(Quote, 'create').mockResolvedValueOnce({ quote_id: duplicatedQuoteId } as any);
vi.spyOn(QuoteItem, 'create')
.mockResolvedValueOnce({ ...templateQuote.quote_items[0], quote_item_id: duplicatedItemIds[0] } as any)
.mockResolvedValueOnce({ ...templateQuote.quote_items[1], quote_item_id: duplicatedItemIds[1] } as any);
const { duplicateQuote } = await import('../../src/actions/quoteActions');
const result = await duplicateQuote(QUOTE_ID);
expect(result).toMatchObject({
quote_items: [
expect.objectContaining({ quote_item_id: duplicatedItemIds[0] }),
expect.objectContaining({ quote_item_id: duplicatedItemIds[1] }),
],
});
expect(result.quote_items?.map((item: any) => item.quote_item_id)).not.toEqual(
templateQuote.quote_items.map((item) => item.quote_item_id)
);
});
it('T127: saveQuoteAsTemplate creates a business template and copies items', async () => {
const templateQuoteId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce({
...templateQuote,
quote_id: QUOTE_ID,
quote_number: 'Q-0012',
is_template: false,
client_id: baseQuoteInput.client_id,
contact_id: 'contact-1',
quote_date: baseQuoteInput.quote_date,
valid_until: baseQuoteInput.valid_until,
status: 'sent',
} as any)
.mockResolvedValueOnce({
quote_id: templateQuoteId,
quote_number: null,
title: 'Template quote Template',
is_template: true,
quote_items: templateQuote.quote_items,
} as any);
vi.spyOn(Quote, 'create').mockResolvedValueOnce({ quote_id: templateQuoteId } as any);
const { saveQuoteAsTemplate } = await import('../../src/actions/quoteActions');
const result = await saveQuoteAsTemplate(QUOTE_ID);
expect(Quote.create).toHaveBeenCalledWith(
mockTrx,
TENANT_ID,
expect.objectContaining({
is_template: true,
title: 'Template quote Template',
created_by: USER_ID,
})
);
expect(QuoteItem.create).toHaveBeenCalledTimes(templateQuote.quote_items.length);
expect(result).toMatchObject({
quote_id: templateQuoteId,
quote_number: null,
is_template: true,
});
});
it('T128: saveQuoteAsTemplate strips client-specific fields from the new template', async () => {
const templateQuoteId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
vi.spyOn(Quote, 'getById').mockResolvedValueOnce({
...templateQuote,
quote_id: QUOTE_ID,
quote_number: 'Q-0013',
is_template: false,
client_id: baseQuoteInput.client_id,
contact_id: 'contact-2',
quote_date: baseQuoteInput.quote_date,
valid_until: baseQuoteInput.valid_until,
status: 'accepted',
} as any);
vi.spyOn(Quote, 'create').mockResolvedValueOnce({ quote_id: templateQuoteId } as any);
vi.spyOn(Quote, 'getById').mockResolvedValueOnce({
quote_id: templateQuoteId,
quote_number: null,
is_template: true,
client_id: null,
contact_id: null,
quote_date: null,
valid_until: null,
status: 'draft',
} as any);
const { saveQuoteAsTemplate } = await import('../../src/actions/quoteActions');
const result = await saveQuoteAsTemplate(QUOTE_ID);
expect(Quote.create).toHaveBeenCalledWith(
mockTrx,
TENANT_ID,
expect.objectContaining({
client_id: null,
contact_id: null,
quote_date: null,
valid_until: null,
is_template: true,
})
);
expect(result).toMatchObject({
client_id: null,
contact_id: null,
quote_date: null,
valid_until: null,
is_template: true,
});
});
it('T089: sendQuote rejects quotes not in draft or approved status', async () => {
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce({
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'sent',
is_template: false,
client_id: null,
contact_id: null,
} as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
await expect(sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] })).rejects.toThrow(
'Only draft or approved quotes can be sent'
);
});
it('T090: sendQuote generates PDF, sends email, and updates status to sent', async () => {
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent', sent_at: '2026-03-13T12:00:00.000Z' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
const result = await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
expect(generatePDFMock).toHaveBeenCalledWith({ quoteId: QUOTE_ID, userId: USER_ID });
expect(sendEmailMock).toHaveBeenCalled();
expect(Quote.update).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
QUOTE_ID,
expect.objectContaining({ status: 'sent', updated_by: USER_ID, sent_at: expect.any(String) })
);
expect(result).toMatchObject({ status: 'sent' });
});
it('T090a: sendQuote sends to all provided email addresses', async () => {
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
await sendQuote(QUOTE_ID, { email_addresses: ['one@example.com', 'two@example.com'] });
expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({
to: ['one@example.com', 'two@example.com'],
}));
});
it('T091: sendQuote logs a sent activity', async () => {
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
expect(QuoteActivity.create).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
expect.objectContaining({
quote_id: QUOTE_ID,
activity_type: 'sent',
metadata: expect.objectContaining({ recipients: ['client@example.com'], message_id: 'message-1' }),
})
);
});
it('T092: quote sent email includes summary details and PDF attachment', async () => {
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Quote Q-0001 from Acme MSP',
html: expect.stringContaining('Q-0001'),
text: expect.stringContaining('Valid Until:'),
attachments: [expect.objectContaining({ filename: 'Quote_Q-0001.pdf', content: Buffer.from('pdf-content') })],
}));
});
it('T093: sendQuote passes quote entity metadata for email logging', async () => {
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({
entityType: 'quote',
entityId: QUOTE_ID,
}));
});
it('T129: sendQuote stores a PDF document and creates a quote association', async () => {
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
expect(generateAndStoreMock).toHaveBeenCalledWith(
expect.objectContaining({
quoteId: QUOTE_ID,
quoteNumber: 'Q-0001',
userId: USER_ID,
})
);
expect(documentInsertMock).toHaveBeenCalledWith(
mockKnex,
expect.objectContaining({
document_name: 'Quote_Q-0001.pdf',
mime_type: 'application/pdf',
file_id: 'stored-file-1',
folder_path: '/Quotes/Generated',
is_client_visible: true,
tenant: TENANT_ID,
})
);
expect(documentAssociationCreateMock).toHaveBeenCalledWith(
mockKnex,
expect.objectContaining({
entity_id: QUOTE_ID,
entity_type: 'quote',
tenant: TENANT_ID,
})
);
});
it('T130: sendQuote succeeds even if PDF storage fails', async () => {
generateAndStoreMock.mockRejectedValueOnce(new Error('Storage unavailable'));
const sendableQuote = {
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
title: 'Quote',
total_amount: 5000,
currency_code: 'USD',
valid_until: '2026-03-20T00:00:00.000Z',
status: 'draft',
is_template: false,
client_id: null,
contact_id: null,
};
vi.spyOn(Quote, 'getById')
.mockResolvedValueOnce(sendableQuote as any)
.mockResolvedValueOnce({ ...sendableQuote, status: 'sent' } as any);
const { sendQuote } = await import('../../src/actions/quoteActions');
const result = await sendQuote(QUOTE_ID, { email_addresses: ['client@example.com'] });
// Email was sent successfully
expect(sendEmailMock).toHaveBeenCalled();
// Status was updated to sent despite storage failure
expect(Quote.update).toHaveBeenCalledWith(
mockKnex,
TENANT_ID,
QUOTE_ID,
expect.objectContaining({ status: 'sent' })
);
expect(result).toMatchObject({ status: 'sent' });
});
it('T131: getQuotePdfFileId returns the stored file_id for a quote with a PDF', async () => {
const docQuery = makeQuery({ file_id: 'stored-file-42' });
docQuery.join = vi.fn(() => docQuery);
docQuery.whereNotNull = vi.fn(() => docQuery);
docQuery.orderBy = vi.fn(() => docQuery);
mockKnex.mockImplementation((table: string) => {
if (table === 'document_associations as da') {
return docQuery;
}
if (table === 'tenants') {
return makeQuery({ client_name: 'Acme MSP' });
}
if (table === 'user_roles' || table === 'team_members' || table === 'users') {
return makeListQuery([]);
}
throw new Error(`Unexpected mockKnex table access: ${table}`);
});
const { getQuotePdfFileId } = await import('../../src/actions/quoteActions');
const result = await getQuotePdfFileId(QUOTE_ID);
expect(result).toBe('stored-file-42');
expect(docQuery.where).toHaveBeenCalledWith(
expect.objectContaining({
'da.entity_id': QUOTE_ID,
'da.entity_type': 'quote',
'da.tenant': TENANT_ID,
})
);
});
it('T132: getQuotePdfFileId returns null when no PDF is stored', async () => {
const docQuery = makeQuery(undefined);
docQuery.join = vi.fn(() => docQuery);
docQuery.whereNotNull = vi.fn(() => docQuery);
docQuery.orderBy = vi.fn(() => docQuery);
mockKnex.mockImplementation((table: string) => {
if (table === 'document_associations as da') {
return docQuery;
}
if (table === 'tenants') {
return makeQuery({ client_name: 'Acme MSP' });
}
if (table === 'user_roles' || table === 'team_members' || table === 'users') {
return makeListQuery([]);
}
throw new Error(`Unexpected mockKnex table access: ${table}`);
});
const { getQuotePdfFileId } = await import('../../src/actions/quoteActions');
const result = await getQuotePdfFileId(QUOTE_ID);
expect(result).toBeNull();
});
it('T133: regenerateQuotePdf generates a new PDF and stores it as a document', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValueOnce({
quote_id: QUOTE_ID,
quote_number: 'Q-0001',
created_by: USER_ID,
} as any);
const { regenerateQuotePdf } = await import('../../src/actions/quoteActions');
const result = await regenerateQuotePdf(QUOTE_ID);
expect(generateAndStoreMock).toHaveBeenCalledWith(
expect.objectContaining({
quoteId: QUOTE_ID,
quoteNumber: 'Q-0001',
userId: USER_ID,
})
);
expect(documentInsertMock).toHaveBeenCalled();
expect(documentAssociationCreateMock).toHaveBeenCalled();
expect(result).toBe('stored-file-1');
});
it('T134: regenerateQuotePdf throws when quote does not exist', async () => {
vi.spyOn(Quote, 'getById').mockResolvedValueOnce(null);
const { regenerateQuotePdf } = await import('../../src/actions/quoteActions');
await expect(regenerateQuotePdf(QUOTE_ID)).rejects.toThrow('Quote 33333333-3333-4333-8333-333333333333 not found');
});
it('T135: regenerateQuotePdf requires billing:update permission', async () => {
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => (
!(resource === 'billing' && action === 'update')
));
const { regenerateQuotePdf } = await import('../../src/actions/quoteActions');
const result = await regenerateQuotePdf(QUOTE_ID);
expect(result).toEqual({ permissionError: 'Permission denied: Cannot update quotes' });
expect(generateAndStoreMock).not.toHaveBeenCalled();
});
});