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
266 lines
8.1 KiB
TypeScript
266 lines
8.1 KiB
TypeScript
/**
|
|
* @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'
|
|
);
|
|
});
|
|
});
|
|
});
|