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) => 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(); 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(); }); });