/** * @alga-psa/billing - Contract Model Tests * * Tests for the Contract model business logic. * These tests verify validation logic and error handling. */ import { describe, it, expect, vi } from 'vitest'; import Contract from '../src/models/contract'; vi.mock('@alga-psa/shared/billingClients', () => ({ checkAndReactivateExpiredContract: vi.fn(), })); // Mock Knex to test validation logic without database const createMockKnex = () => { const mockInsert = vi.fn().mockReturnThis(); const mockWhere = vi.fn().mockReturnThis(); const mockAndWhere = vi.fn().mockReturnThis(); const mockWhereNot = vi.fn().mockReturnThis(); const mockWhereIn = vi.fn().mockReturnThis(); const mockWhereNotNull = vi.fn().mockReturnThis(); const mockWhereNull = vi.fn().mockReturnThis(); const mockOrWhere = vi.fn().mockReturnThis(); const mockFirst = vi.fn(); const mockUpdate = vi.fn().mockReturnThis(); const mockDelete = vi.fn(); const mockDel = vi.fn(); const mockReturning = vi.fn(); const mockSelect = vi.fn().mockReturnThis(); const mockLeftJoin = vi.fn().mockReturnThis(); const mockJoin = vi.fn().mockReturnThis(); const mockCount = vi.fn().mockReturnThis(); const mockPluck = vi.fn(); const mockOrderBy = vi.fn().mockReturnThis(); const mockKnex = vi.fn(() => ({ insert: mockInsert, where: mockWhere, andWhere: mockAndWhere, whereNot: mockWhereNot, whereIn: mockWhereIn, whereNotNull: mockWhereNotNull, whereNull: mockWhereNull, orWhere: mockOrWhere, first: mockFirst, update: mockUpdate, delete: mockDelete, del: mockDel, returning: mockReturning, select: mockSelect, leftJoin: mockLeftJoin, join: mockJoin, count: mockCount, pluck: mockPluck, orderBy: mockOrderBy, })); return { knex: mockKnex as any, mocks: { insert: mockInsert, where: mockWhere, first: mockFirst, update: mockUpdate, delete: mockDelete, del: mockDel, returning: mockReturning, select: mockSelect, count: mockCount, pluck: mockPluck, whereIn: mockWhereIn, whereNotNull: mockWhereNotNull, }, }; }; describe('Contract Model', () => { describe('isInUse', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.isInUse(knex, '', 'contract-123')).rejects.toThrow( 'Tenant context is required for checking contract usage' ); }); }); describe('hasInvoices', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.hasInvoices(knex, '', 'contract-123')).rejects.toThrow( 'Tenant context is required for checking contract invoices' ); }); }); describe('delete', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.delete(knex, '', 'contract-123')).rejects.toThrow( 'Tenant context is required for deleting contracts' ); }); it('deletes recurring service periods linked to contract lines before removing the contract lines', async () => { const { knex, mocks } = createMockKnex(); mocks.first.mockResolvedValueOnce({ count: '0' }); mocks.pluck .mockResolvedValueOnce(['client-contract-1']) .mockResolvedValueOnce(['line-1', 'line-2']) .mockResolvedValueOnce([]); mocks.delete.mockResolvedValue(1); await Contract.delete(knex, 'tenant-1', 'contract-123'); expect(knex).toHaveBeenCalledWith('recurring_service_periods'); expect(mocks.where).toHaveBeenCalledWith({ tenant: 'tenant-1' }); expect(mocks.whereIn).toHaveBeenCalledWith('obligation_id', ['line-1', 'line-2']); }); }); describe('getAll', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.getAll(knex, '')).rejects.toThrow( 'Tenant context is required for fetching contracts' ); }); }); describe('getAllWithClients', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.getAllWithClients(knex, '')).rejects.toThrow( 'Tenant context is required for fetching contracts' ); }); }); describe('getById', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.getById(knex, '', 'contract-123')).rejects.toThrow( 'Tenant context is required for fetching contracts' ); }); }); describe('create', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); const contract = { contract_name: 'Test Contract', owner_client_id: 'client-123', billing_frequency: 'monthly', currency_code: 'USD', is_active: true, status: 'draft' as const, }; await expect(Contract.create(knex, '', contract)).rejects.toThrow( 'Tenant context is required for creating contracts' ); }); it('rejects non-template contract creation without an owner client', async () => { const { knex } = createMockKnex(); await expect( Contract.create(knex, 'tenant-1', { contract_name: 'Shared Contract', billing_frequency: 'monthly', currency_code: 'USD', is_active: true, status: 'draft', is_template: false, }) ).rejects.toThrow('Non-template contracts require an owning client'); }); it('allows template contract creation without an owner client', async () => { const { knex, mocks } = createMockKnex(); mocks.returning.mockResolvedValue([ { contract_id: 'template-1', tenant: 'tenant-1', contract_name: 'Template Contract', billing_frequency: 'monthly', currency_code: 'USD', is_active: true, status: 'published', is_template: true, owner_client_id: null, }, ]); await expect( Contract.create(knex, 'tenant-1', { contract_name: 'Template Contract', billing_frequency: 'monthly', currency_code: 'USD', is_active: true, status: 'published', is_template: true, }) ).resolves.toMatchObject({ contract_id: 'template-1', is_template: true, }); expect(mocks.insert).toHaveBeenCalledWith( expect.objectContaining({ contract_name: 'Template Contract', owner_client_id: null, }) ); }); }); describe('update', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.update(knex, '', 'contract-123', {})).rejects.toThrow( 'Tenant context is required for updating contracts' ); }); }); describe('getContractLines', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.getContractLines(knex, '', 'contract-123')).rejects.toThrow( 'Tenant context is required for fetching contract lines' ); }); }); describe('checkAndReactivateExpiredContract', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect( Contract.checkAndReactivateExpiredContract(knex, '', 'contract-123') ).rejects.toThrow('Tenant context is required for checking contract reactivation'); }); }); describe('checkAndUpdateExpiredStatus', () => { it('should throw error when tenant is not provided', async () => { const { knex } = createMockKnex(); await expect(Contract.checkAndUpdateExpiredStatus(knex, '', 'contract-123')).rejects.toThrow( 'Tenant context is required for checking contract expiration' ); }); }); });