PSA/shared/workflow/actions/__tests__/emailWorkflowActions.findContactByEmail.context.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

317 lines
9.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const withAdminTransactionMock = vi.fn();
type Scenario = {
contactRows: any[];
internalUserRow: any | null;
ticketRow: { client_id?: string | null; contact_name_id?: string | null } | null;
contactsQueryUsedAdditionalEmailMatch: boolean;
};
const scenario: Scenario = {
contactRows: [],
internalUserRow: null,
ticketRow: null,
contactsQueryUsedAdditionalEmailMatch: false,
};
function makeChainable(base: Record<string, any> = {}) {
const query: any = {
select: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
whereRaw: vi.fn().mockReturnThis(),
andWhereRaw: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
...base,
};
return query;
}
function makeContactsQuery(rows: any[]) {
const query = makeChainable({
andWhere: vi.fn().mockImplementation((clause: unknown) => {
if (typeof clause === 'function') {
const nestedQuery: any = {
where: vi.fn().mockReturnThis(),
orWhereExists: vi.fn().mockImplementation((callback: (this: any) => void) => {
scenario.contactsQueryUsedAdditionalEmailMatch = true;
const existsQuery: any = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
whereRaw: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
};
callback.call(existsQuery);
return nestedQuery;
}),
};
clause.call(nestedQuery);
}
return query;
}),
});
query.then = (resolve: (value: any[]) => any, reject?: (error: unknown) => any) =>
Promise.resolve(rows).then(resolve, reject);
return query;
}
function makePhoneNumbersQuery(rows: any[]) {
const query = makeChainable({
whereIn: vi.fn().mockReturnThis(),
});
query.then = (resolve: (value: any[]) => any, reject?: (error: unknown) => any) =>
Promise.resolve(rows).then(resolve, reject);
return query;
}
function makeTicketsQuery(row: Scenario['ticketRow']) {
return makeChainable({
first: vi.fn().mockResolvedValue(row),
});
}
function makeUsersQuery(row: Scenario['internalUserRow']) {
return makeChainable({
first: vi.fn().mockResolvedValue(row),
as: vi.fn().mockReturnValue('user_id_subquery'),
});
}
vi.mock('@alga-psa/db', () => ({
withAdminTransaction: (callback: (trx: any) => Promise<any>) => withAdminTransactionMock(callback),
}));
vi.mock('@alga-psa/event-bus/publishers', () => ({
publishWorkflowEvent: vi.fn(),
}));
describe('findContactByEmail context-aware resolution', () => {
beforeEach(() => {
vi.clearAllMocks();
scenario.contactRows = [];
scenario.internalUserRow = null;
scenario.ticketRow = null;
scenario.contactsQueryUsedAdditionalEmailMatch = false;
withAdminTransactionMock.mockImplementation(async (callback: (trx: any) => Promise<any>) => {
const trx: any = Object.assign(
vi.fn((table: string) => {
if (table === 'contacts') {
return makeContactsQuery(scenario.contactRows);
}
if (table === 'tickets') {
return makeTicketsQuery(scenario.ticketRow);
}
if (table === 'contact_phone_numbers as cpn') {
return makePhoneNumbersQuery([]);
}
if (table === 'contact_additional_email_addresses as cea') {
return makePhoneNumbersQuery([]);
}
if (table === 'users') {
return makeUsersQuery(scenario.internalUserRow);
}
throw new Error(`Unexpected table in test: ${table}`);
}),
{
raw: vi.fn((value: string) => value),
}
);
return callback(trx);
});
});
it('returns null for ambiguous multi-client matches without context', async () => {
scenario.contactRows = [
{
contact_name_id: 'contact-a',
contact_id: 'contact-a',
name: 'A',
email: 'sender@example.com',
client_id: 'client-a',
user_id: null,
client_name: 'Client A',
},
{
contact_name_id: 'contact-b',
contact_id: 'contact-b',
name: 'B',
email: 'sender@example.com',
client_id: 'client-b',
user_id: null,
client_name: 'Client B',
},
];
const { findContactByEmail } = await import('../emailWorkflowActions');
const result = await findContactByEmail('sender@example.com', 'tenant-1');
expect(result).toBeNull();
});
it('prefers an exact internal user email match before contact resolution', async () => {
scenario.internalUserRow = {
user_id: 'internal-user-1',
first_name: 'Robert',
last_name: 'Isaacs',
email: 'robert@nineminds.com',
};
scenario.contactRows = [
{
contact_name_id: 'contact-a',
contact_id: 'contact-a',
name: 'External Robert',
email: 'robert@nineminds.com',
client_id: 'client-a',
user_id: null,
client_name: 'Client A',
},
];
const { findContactByEmail } = await import('../emailWorkflowActions');
const result = await findContactByEmail('ROBERT@NINEMINDS.COM', 'tenant-1');
expect(result).toEqual({
contact_id: '',
name: 'Robert Isaacs',
email: 'robert@nineminds.com',
matched_email: 'robert@nineminds.com',
client_id: '',
user_id: 'internal-user-1',
user_type: 'internal',
client_name: '',
});
});
it('scopes reply matching to ticket client and avoids cross-client attribution', async () => {
scenario.contactRows = [
{
contact_name_id: 'contact-a',
contact_id: 'contact-a',
name: 'A',
email: 'sender@example.com',
client_id: 'client-a',
user_id: null,
client_name: 'Client A',
},
{
contact_name_id: 'contact-b',
contact_id: 'contact-b',
name: 'B',
email: 'sender@example.com',
client_id: 'client-b',
user_id: null,
client_name: 'Client B',
},
];
scenario.ticketRow = {
client_id: 'client-b',
contact_name_id: null,
};
const { findContactByEmail } = await import('../emailWorkflowActions');
const result = await findContactByEmail('sender@example.com', 'tenant-1', {
ticketId: 'ticket-1',
});
expect(result?.contact_id).toBe('contact-b');
expect(result?.client_id).toBe('client-b');
});
it('prefers direct ticket contact match when available', async () => {
scenario.contactRows = [
{
contact_name_id: 'contact-a',
contact_id: 'contact-a',
name: 'A',
email: 'sender@example.com',
client_id: 'client-a',
user_id: null,
client_name: 'Client A',
},
{
contact_name_id: 'contact-b',
contact_id: 'contact-b',
name: 'B',
email: 'sender@example.com',
client_id: 'client-a',
user_id: null,
client_name: 'Client A',
},
];
scenario.ticketRow = {
client_id: 'client-a',
contact_name_id: 'contact-b',
};
const { findContactByEmail } = await import('../emailWorkflowActions');
const result = await findContactByEmail('sender@example.com', 'tenant-1', {
ticketId: 'ticket-2',
});
expect(result?.contact_id).toBe('contact-b');
});
it('uses default client context for new-ticket disambiguation and normalizes null user_id', async () => {
scenario.contactRows = [
{
contact_name_id: 'contact-a',
contact_id: 'contact-a',
name: 'A',
email: 'sender@example.com',
client_id: 'default-client',
user_id: null,
client_name: 'Default Client',
},
{
contact_name_id: 'contact-b',
contact_id: 'contact-b',
name: 'B',
email: 'sender@example.com',
client_id: 'other-client',
user_id: 'client-user-b',
client_name: 'Other Client',
},
];
const { findContactByEmail } = await import('../emailWorkflowActions');
const result = await findContactByEmail('sender@example.com', 'tenant-1', {
defaultClientId: 'default-client',
});
expect(result?.contact_id).toBe('contact-a');
expect(result?.user_id).toBeUndefined();
});
it('T035: resolves a contact when the sender matches an additional email row', async () => {
scenario.contactRows = [
{
contact_name_id: 'contact-a',
contact_id: 'contact-a',
name: 'Primary Contact',
email: 'primary@example.com',
client_id: 'client-a',
user_id: null,
client_name: 'Client A',
},
];
const { findContactByEmail } = await import('../emailWorkflowActions');
const result = await findContactByEmail('billing@example.com', 'tenant-1');
expect(scenario.contactsQueryUsedAdditionalEmailMatch).toBe(true);
expect(result).toMatchObject({
contact_id: 'contact-a',
email: 'primary@example.com',
client_id: 'client-a',
client_name: 'Client A',
});
});
});