PSA/packages/billing/tests/accounting/companySyncService.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

200 lines
7.1 KiB
TypeScript

/**
* Unit tests for CompanyAccountingSyncService
* (packages/billing/src/services/companySync/companySyncService.ts).
*
* The service decides whether a company already maps to an external
* accounting entity, finds-or-creates it through the adapter, and persists
* the mapping idempotently. Repository and adapter are injected fakes.
*/
import { describe, expect, it, vi } from 'vitest';
import { CompanyAccountingSyncService } from '../../src/services/companySync/companySyncService';
import type {
AccountingCompanyAdapter,
CompanyMappingLookupResult,
EnsureCompanyMappingParams,
NormalizedCompanyPayload,
} from '../../src/services/companySync/companySync.types';
const payload: NormalizedCompanyPayload = { companyId: 'co-1', name: 'Acme Co' };
function makeParams(overrides: Partial<EnsureCompanyMappingParams> = {}): EnsureCompanyMappingParams {
return {
companyId: 'co-1',
payload,
adapterType: 'quickbooks_online',
tenantId: 'tenant-1',
targetRealm: 'realm-1',
...overrides,
};
}
function makeRepo(initial: CompanyMappingLookupResult | null = null) {
let stored: CompanyMappingLookupResult | null = initial;
return {
stored: () => stored,
findCompanyMapping: vi.fn(async () => stored),
upsertCompanyMapping: vi.fn(async (record: any) => {
stored = { externalCompanyId: record.externalCompanyId, metadata: record.metadata ?? null };
}),
};
}
function makeAdapter(overrides: Partial<AccountingCompanyAdapter> = {}): AccountingCompanyAdapter {
return {
type: 'quickbooks_online',
findExternalCompany: vi.fn(async () => null),
createOrUpdateExternalCompany: vi.fn(async () => ({
externalId: 'qbo-77',
displayName: 'Acme Co',
raw: { Id: 'qbo-77' },
})),
...overrides,
} as AccountingCompanyAdapter;
}
describe('CompanyAccountingSyncService.ensureCompanyMapping', () => {
it('returns an existing mapping without calling the adapter', async () => {
const repo = makeRepo({ externalCompanyId: 'qbo-1', metadata: { Id: 'qbo-1' } });
const adapter = makeAdapter();
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => adapter,
});
const result = await service.ensureCompanyMapping(makeParams());
expect(result).toEqual({ externalCompanyId: 'qbo-1', metadata: { Id: 'qbo-1' } });
expect(adapter.findExternalCompany).not.toHaveBeenCalled();
expect(repo.upsertCompanyMapping).not.toHaveBeenCalled();
});
it('reuses a matching external company instead of creating a duplicate', async () => {
const repo = makeRepo(null);
const adapter = makeAdapter({
findExternalCompany: vi.fn(async () => ({
externalId: 'qbo-existing',
displayName: 'Acme Co',
raw: { Id: 'qbo-existing' },
})),
});
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => adapter,
});
const result = await service.ensureCompanyMapping(makeParams());
expect(adapter.createOrUpdateExternalCompany).not.toHaveBeenCalled();
expect(repo.upsertCompanyMapping).toHaveBeenCalledWith({
tenantId: 'tenant-1',
adapterType: 'quickbooks_online',
algaCompanyId: 'co-1',
externalCompanyId: 'qbo-existing',
targetRealm: 'realm-1',
metadata: { Id: 'qbo-existing' },
});
expect(result.externalCompanyId).toBe('qbo-existing');
});
it('creates the external company when none is found, then persists and returns the mapping', async () => {
const repo = makeRepo(null);
const adapter = makeAdapter();
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => adapter,
});
const result = await service.ensureCompanyMapping(makeParams());
expect(adapter.findExternalCompany).toHaveBeenCalledWith(payload, {
tenantId: 'tenant-1',
targetRealm: 'realm-1',
});
expect(adapter.createOrUpdateExternalCompany).toHaveBeenCalledOnce();
expect(result).toEqual({ externalCompanyId: 'qbo-77', metadata: { Id: 'qbo-77' } });
});
it('caches resolved mappings per tenant/adapter/realm/company within the service instance', async () => {
const repo = makeRepo({ externalCompanyId: 'qbo-1', metadata: null });
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => makeAdapter(),
});
await service.ensureCompanyMapping(makeParams());
await service.ensureCompanyMapping(makeParams());
expect(repo.findCompanyMapping).toHaveBeenCalledTimes(1);
// A different realm must not hit the cache entry.
await service.ensureCompanyMapping(makeParams({ targetRealm: 'realm-2' }));
expect(repo.findCompanyMapping).toHaveBeenCalledTimes(2);
});
it('survives a unique-violation race on upsert by re-reading the winning mapping', async () => {
const winner: CompanyMappingLookupResult = { externalCompanyId: 'qbo-winner', metadata: null };
const findCompanyMapping = vi
.fn()
.mockResolvedValueOnce(null) // initial lookup: no mapping yet
.mockResolvedValueOnce(winner); // re-read after conflict
const upsertError = Object.assign(new Error('duplicate key'), { code: '23505' });
const repo = {
findCompanyMapping,
upsertCompanyMapping: vi.fn(async () => {
throw upsertError;
}),
};
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => makeAdapter(),
});
const result = await service.ensureCompanyMapping(makeParams());
expect(result).toEqual(winner);
});
it('falls back to the adapter-resolved company when the post-conflict re-read finds nothing', async () => {
const upsertError = Object.assign(new Error('duplicate key'), { code: '23505' });
const repo = {
findCompanyMapping: vi.fn(async () => null),
upsertCompanyMapping: vi.fn(async () => {
throw upsertError;
}),
};
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => makeAdapter(),
});
const result = await service.ensureCompanyMapping(makeParams());
expect(result).toEqual({ externalCompanyId: 'qbo-77', metadata: { Id: 'qbo-77' } });
});
it('propagates non-unique-violation persistence errors', async () => {
const repo = {
findCompanyMapping: vi.fn(async () => null),
upsertCompanyMapping: vi.fn(async () => {
throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' });
}),
};
const service = CompanyAccountingSyncService.create({
mappingRepository: repo,
adapterFactory: () => makeAdapter(),
});
await expect(service.ensureCompanyMapping(makeParams())).rejects.toThrow('connection reset');
});
it('throws a clear error when no adapter is registered for the requested type', async () => {
const service = CompanyAccountingSyncService.create({
mappingRepository: makeRepo(null),
adapterFactory: () => null,
});
await expect(service.ensureCompanyMapping(makeParams({ adapterType: 'xero' }))).rejects.toThrow(
'Company adapter xero is not registered'
);
});
});