PSA/shared/workflow/runtime/actions/__tests__/businessOperations.contacts.db.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

990 lines
35 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { knex, type Knex } from 'knex';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { getSecret } from '@alga-psa/core/secrets';
import { v4 as uuidv4 } from 'uuid';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../../../..');
const TEST_DB_NAME = 'test_database';
const PRODUCTION_DB_NAMES = new Set(['sebastian_prod', 'production', 'prod', 'server']);
function verifyTestDatabase(dbName: string): void {
if (PRODUCTION_DB_NAMES.has(dbName.toLowerCase())) {
throw new Error(`Attempting to use production database (${dbName}) for testing`);
}
}
async function recreateDatabase(
databaseName: string,
dbHost: string,
dbPort: number,
adminUser: string,
adminPassword: string,
appUser: string,
appPassword: string
): Promise<void> {
const adminConnection = knex({
client: 'pg',
connection: {
host: dbHost,
port: dbPort,
user: adminUser,
password: adminPassword,
database: 'postgres',
},
pool: { min: 1, max: 2 },
});
try {
const safeDbName = databaseName.replace(/"/g, '""');
await adminConnection.raw(
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ? AND pid <> pg_backend_pid()',
[databaseName]
);
await adminConnection.raw(`DROP DATABASE IF EXISTS "${safeDbName}"`);
await adminConnection.raw(`CREATE DATABASE "${safeDbName}"`);
await adminConnection.raw(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${appUser}') THEN
CREATE ROLE ${appUser} WITH LOGIN PASSWORD '${appPassword}';
ELSE
ALTER ROLE ${appUser} WITH LOGIN PASSWORD '${appPassword}';
END IF;
END;
$$;`);
await adminConnection.raw(`ALTER DATABASE "${safeDbName}" OWNER TO ${appUser}`);
await adminConnection.raw(`GRANT ALL PRIVILEGES ON DATABASE "${safeDbName}" TO ${appUser}`);
} finally {
await adminConnection.destroy().catch(() => undefined);
}
}
async function createTestDbConnection(): Promise<Knex> {
const databaseName = process.env.DB_NAME_SERVER || TEST_DB_NAME;
verifyTestDatabase(databaseName);
const dbHost = process.env.DB_HOST || 'localhost';
const dbPort = Number.parseInt(process.env.DB_PORT || '5432', 10);
const adminUser = process.env.DB_USER_ADMIN || 'postgres';
const adminPassword = await getSecret('postgres_password', 'DB_PASSWORD_ADMIN', 'postpass123');
const appUser = process.env.DB_USER_SERVER || 'app_user';
const appPassword = await getSecret('db_password_server', 'DB_PASSWORD_SERVER', 'postpass123');
await recreateDatabase(databaseName, dbHost, dbPort, adminUser, adminPassword, appUser, appPassword);
process.env.DB_HOST = dbHost;
process.env.DB_PORT = String(dbPort);
process.env.DB_NAME_SERVER = databaseName;
process.env.DB_USER_SERVER = appUser;
process.env.DB_USER_ADMIN = adminUser;
const adminKnex = knex({
client: 'pg',
connection: {
host: dbHost,
port: dbPort,
user: adminUser,
password: adminPassword,
database: databaseName,
},
migrations: { directory: path.join(repoRoot, 'server', 'migrations') },
seeds: { directory: path.join(repoRoot, 'server', 'seeds', 'dev') },
});
await adminKnex.migrate.latest();
await adminKnex.seed.run();
await adminKnex.destroy();
return knex({
client: 'pg',
connection: {
host: dbHost,
port: dbPort,
user: appUser,
password: appPassword,
database: databaseName,
},
asyncStackTraces: true,
pool: { min: 2, max: 20 },
});
}
async function createTenant(db: Knex, name = 'Test Tenant'): Promise<string> {
const tenantId = uuidv4();
const now = new Date().toISOString();
await db('tenants').insert({
tenant: tenantId,
client_name: name,
phone_number: '555-0100',
email: `test-${tenantId.substring(0, 8)}@example.com`,
created_at: now,
updated_at: now,
payment_platform_id: `test-platform-${tenantId.substring(0, 8)}`,
payment_method_id: `test-method-${tenantId.substring(0, 8)}`,
auth_service_id: `test-auth-${tenantId.substring(0, 8)}`,
plan: 'pro',
});
return tenantId;
}
async function createUser(
db: Knex,
tenantId: string,
options: {
email?: string;
username?: string;
first_name?: string;
last_name?: string;
user_type?: 'client' | 'internal';
is_inactive?: boolean;
contact_id?: string;
} = {}
): Promise<string> {
const userId = uuidv4();
await db('users').insert({
user_id: userId,
tenant: tenantId,
username: options.username || `test.user.${userId}`,
first_name: options.first_name || 'Test',
last_name: options.last_name || 'User',
email: options.email || `test.user.${userId}@example.com`,
hashed_password: 'hashed_password_here',
created_at: new Date(),
two_factor_enabled: false,
is_google_user: false,
is_inactive: options.is_inactive ?? false,
user_type: options.user_type || 'internal',
contact_id: options.contact_id,
});
return userId;
}
async function createClient(db: Knex, tenantId: string, name = 'Test Client'): Promise<string> {
const clientId = uuidv4();
const now = new Date().toISOString();
await db('clients').insert({
client_id: clientId,
client_name: name,
tenant: tenantId,
billing_cycle: 'monthly',
is_tax_exempt: false,
url: '',
created_at: now,
updated_at: now,
is_inactive: false,
credit_balance: 0,
});
return clientId;
}
async function createContactRaw(
db: Knex,
tenantId: string,
options: {
full_name?: string;
email?: string;
client_id?: string | null;
is_inactive?: boolean;
notes_document_id?: string | null;
} = {}
): Promise<string> {
const contactId = uuidv4();
const now = new Date().toISOString();
await db('contacts').insert({
tenant: tenantId,
contact_name_id: contactId,
full_name: options.full_name ?? `Contact ${contactId.slice(0, 6)}`,
email: options.email ?? `${contactId.slice(0, 8)}@example.com`,
client_id: options.client_id ?? null,
is_inactive: options.is_inactive ?? false,
notes_document_id: options.notes_document_id ?? null,
primary_email_canonical_type: 'work',
created_at: now,
updated_at: now,
});
return contactId;
}
async function createTicketStatusId(db: Knex, tenantId: string, actorUserId: string): Promise<string> {
const existing = await db('statuses')
.where({ tenant: tenantId, status_type: 'ticket' })
.orderBy('order_number', 'asc')
.first();
if (existing?.status_id) return existing.status_id;
const [inserted] = await db('statuses')
.insert({
tenant: tenantId,
name: 'Open',
status_type: 'ticket',
order_number: 1,
created_by: actorUserId,
is_closed: false,
is_default: true,
})
.returning('status_id');
return inserted.status_id;
}
async function createTicket(
db: Knex,
params: {
tenantId: string;
actorUserId: string;
clientId?: string | null;
contactId?: string | null;
title?: string;
}
): Promise<string> {
const ticketId = uuidv4();
const statusId = await createTicketStatusId(db, params.tenantId, params.actorUserId);
await db('tickets').insert({
ticket_id: ticketId,
tenant: params.tenantId,
ticket_number: `WF-${Date.now()}-${Math.floor(Math.random() * 10000)}`,
title: params.title ?? 'Workflow Test Ticket',
status_id: statusId,
client_id: params.clientId ?? null,
entered_by: params.actorUserId,
contact_name_id: params.contactId ?? null,
});
return ticketId;
}
async function getDefaultInteractionStatusId(db: Knex, tenantId: string, actorUserId: string): Promise<string> {
const existing = await db('statuses').where({ tenant: tenantId, status_type: 'interaction', is_default: true }).first();
if (existing?.status_id) return existing.status_id;
const [created] = await db('statuses')
.insert({
tenant: tenantId,
name: 'Logged',
status_type: 'interaction',
order_number: 1,
created_by: actorUserId,
is_closed: false,
is_default: true,
})
.returning('status_id');
return created.status_id;
}
async function getAnyInteractionTypeId(db: Knex, tenantId: string): Promise<string> {
const tenantType = await db('interaction_types').where({ tenant: tenantId }).first();
if (tenantType?.type_id) return tenantType.type_id;
const systemType = await db('system_interaction_types').first();
if (!systemType?.type_id) {
throw new Error('Expected at least one system_interaction_types row in seeded DB');
}
return systemType.type_id;
}
const runtimeState = vi.hoisted(() => ({
db: null as Knex | null,
tenantId: '',
actorUserId: '',
deniedPermissions: new Set<string>(),
publishedEvents: [] as Array<{ eventType: string; payload: Record<string, unknown>; idempotencyKey: string }>,
}));
vi.mock('@alga-psa/event-bus/publishers', () => ({
publishWorkflowEvent: vi.fn(async (event: { eventType: string; payload: Record<string, unknown>; idempotencyKey: string }) => {
runtimeState.publishedEvents.push(event);
}),
}));
vi.mock('../businessOperations/shared', async (importOriginal) => {
const actual = await importOriginal<typeof import('../businessOperations/shared')>();
return {
...actual,
withTenantTransaction: async (_ctx: any, fn: any) => {
if (!runtimeState.db) {
throw new Error('DB unavailable for test runtime state');
}
return runtimeState.db.transaction(async (trx) => {
await trx.raw(`select set_config('app.current_tenant', ?, true)`, [runtimeState.tenantId]);
return fn({
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
trx,
});
});
},
requirePermission: async (ctx: any, _tx: any, permission: { resource: string; action: string }) => {
const key = `${permission.resource}:${permission.action}`;
if (!runtimeState.deniedPermissions.has(key)) return;
throw {
category: 'ActionError',
code: 'PERMISSION_DENIED',
message: `Missing permission ${key}`,
details: { permission: key },
nodePath: ctx?.stepPath ?? 'steps.contact-action',
at: new Date().toISOString(),
};
},
};
});
import { getActionRegistryV2 } from '../../registries/actionRegistry';
import { registerContactActions } from '../businessOperations/contacts';
function getAction(actionId: string) {
const action = getActionRegistryV2().get(actionId, 1);
if (!action) throw new Error(`Missing action ${actionId}@1`);
return action;
}
function actionCtx(overrides: Partial<Record<string, unknown>> = {}) {
return {
runId: uuidv4(),
stepPath: 'steps.contact-action',
idempotencyKey: uuidv4(),
attempt: 1,
nowIso: () => new Date().toISOString(),
env: {},
tenantId: runtimeState.tenantId,
...overrides,
};
}
async function invokeAction(actionId: string, input: Record<string, unknown>, ctxOverrides: Record<string, unknown> = {}) {
const action = getAction(actionId);
const parsedInput = action.inputSchema.parse(input);
return action.handler(parsedInput, actionCtx(ctxOverrides) as any);
}
describe('contact workflow runtime DB-backed action handlers', () => {
let db: Knex;
beforeAll(async () => {
if (!getActionRegistryV2().get('contacts.move_to_client', 1)) {
registerContactActions();
}
db = await createTestDbConnection();
runtimeState.db = db;
}, 180000);
beforeEach(async () => {
const tenantId = await createTenant(db, `Workflow Contact Runtime Test ${Date.now()}`);
const actorUserId = await createUser(db, tenantId, {
user_type: 'internal',
first_name: 'Workflow',
last_name: 'Actor',
});
runtimeState.tenantId = tenantId;
runtimeState.actorUserId = actorUserId;
runtimeState.deniedPermissions.clear();
runtimeState.publishedEvents.length = 0;
});
afterAll(async () => {
await db?.destroy();
runtimeState.db = null;
});
it('T003: contacts.create creates scoped contact rows and returns compact output; duplicate email conflicts', async () => {
const clientId = await createClient(db, runtimeState.tenantId, 'Create Client');
const action = getAction('contacts.create');
const keyFromContext = action.idempotency.mode === 'actionProvided'
? action.idempotency.key({}, actionCtx({ runId: 'run-fixed', stepPath: 'steps.fixed' }) as any)
: '';
expect(action.idempotency.mode).toBe('actionProvided');
expect(keyFromContext).toBe('run:run-fixed:steps.fixed');
const created = await invokeAction('contacts.create', {
full_name: 'Created Contact',
email: 'created.contact@example.com',
client_id: clientId,
role: 'Manager',
notes: 'Created by workflow',
phone_numbers: [{ phone_number: '555-0101', canonical_type: 'work', is_default: true }],
additional_email_addresses: [{ email_address: 'created.contact+alt@example.com', canonical_type: 'personal' }],
});
expect(created.created).toBe(true);
expect(created.contact.full_name).toBe('Created Contact');
expect(created.contact.email).toBe('created.contact@example.com');
expect(created.contact.client_id).toBe(clientId);
const dbContact = await db('contacts')
.where({ tenant: runtimeState.tenantId, contact_name_id: created.contact.contact_name_id })
.first();
expect(dbContact).toBeTruthy();
const phones = await db('contact_phone_numbers')
.where({ tenant: runtimeState.tenantId, contact_name_id: created.contact.contact_name_id });
expect(phones.length).toBe(1);
const additionalEmails = await db('contact_additional_email_addresses')
.where({ tenant: runtimeState.tenantId, contact_name_id: created.contact.contact_name_id });
expect(additionalEmails.length).toBe(1);
await expect(
invokeAction('contacts.create', {
full_name: 'Duplicate Contact',
email: 'created.contact@example.com',
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('T004: contacts.update patches supplied fields, preserves omitted fields, returns before/after and enforces model email rules', async () => {
const clientId = await createClient(db, runtimeState.tenantId, 'Update Client');
const contactId = await invokeAction('contacts.create', {
full_name: 'Update Target',
email: 'update.target@example.com',
client_id: clientId,
role: 'Initial',
notes: 'Initial Notes',
phone_numbers: [{ phone_number: '555-0102', canonical_type: 'work', is_default: true }],
additional_email_addresses: [{ email_address: 'update.target+alt@example.com', canonical_type: 'personal' }],
}).then((result) => result.contact.contact_name_id as string);
const updated = await invokeAction('contacts.update', {
contact_id: contactId,
patch: {
full_name: 'Updated Name',
role: 'Updated Role',
},
});
expect(updated.contact_before.contact_name_id).toBe(contactId);
expect(updated.contact_after.full_name).toBe('Updated Name');
expect(updated.changed_fields).toEqual(expect.arrayContaining(['full_name', 'role']));
const stored = await db('contacts').where({ tenant: runtimeState.tenantId, contact_name_id: contactId }).first();
expect(stored.email).toBe('update.target@example.com');
expect(stored.notes).toBe('Initial Notes');
const cleared = await invokeAction('contacts.update', {
contact_id: contactId,
patch: {
role: null,
notes: null,
},
});
expect(cleared.changed_fields).toEqual(expect.arrayContaining(['role', 'notes']));
const clearedStored = await db('contacts').where({ tenant: runtimeState.tenantId, contact_name_id: contactId }).first();
expect(clearedStored.role).toBeNull();
expect(clearedStored.notes).toBeNull();
await expect(
invokeAction('contacts.update', {
contact_id: contactId,
patch: {
email: null,
},
})
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
await expect(
invokeAction('contacts.update', {
contact_id: contactId,
patch: {
email: 'new.primary@example.com',
},
})
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
});
it('T005: contacts.add_to_client assigns unassigned contacts, no-ops on same client, and conflicts on other client', async () => {
const clientA = await createClient(db, runtimeState.tenantId, 'Client A');
const clientB = await createClient(db, runtimeState.tenantId, 'Client B');
const contactId = await createContactRaw(db, runtimeState.tenantId, { client_id: null });
const assigned = await invokeAction('contacts.add_to_client', {
contact_id: contactId,
client_id: clientA,
});
expect(assigned.previous_client_id).toBeNull();
expect(assigned.current_client_id).toBe(clientA);
expect(assigned.noop).toBe(false);
const noop = await invokeAction('contacts.add_to_client', {
contact_id: contactId,
client_id: clientA,
});
expect(noop.noop).toBe(true);
await expect(
invokeAction('contacts.add_to_client', {
contact_id: contactId,
client_id: clientB,
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('T006: contacts.move_to_client moves contact, no-ops when repeated, and enforces expected_current_client_id', async () => {
const clientA = await createClient(db, runtimeState.tenantId, 'Client A');
const clientB = await createClient(db, runtimeState.tenantId, 'Client B');
const contactId = await createContactRaw(db, runtimeState.tenantId, { client_id: clientA });
const moved = await invokeAction('contacts.move_to_client', {
contact_id: contactId,
target_client_id: clientB,
expected_current_client_id: clientA,
});
expect(moved.previous_client_id).toBe(clientA);
expect(moved.current_client_id).toBe(clientB);
expect(moved.noop).toBe(false);
const noop = await invokeAction('contacts.move_to_client', {
contact_id: contactId,
target_client_id: clientB,
});
expect(noop.noop).toBe(true);
await expect(
invokeAction('contacts.move_to_client', {
contact_id: contactId,
target_client_id: clientA,
expected_current_client_id: clientA,
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('T007: contacts.assign_to_ticket updates only contact_name_id and enforces client relationship', async () => {
const clientA = await createClient(db, runtimeState.tenantId, 'Ticket Client A');
const clientB = await createClient(db, runtimeState.tenantId, 'Ticket Client B');
const contactA = await createContactRaw(db, runtimeState.tenantId, { client_id: clientA });
const contactB = await createContactRaw(db, runtimeState.tenantId, { client_id: clientB });
const ticketWithClient = await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: clientA,
contactId: null,
});
const assigned = await invokeAction('contacts.assign_to_ticket', {
ticket_id: ticketWithClient,
contact_id: contactA,
});
expect(assigned.previous_contact_id).toBeNull();
expect(assigned.current_contact_id).toBe(contactA);
await expect(
invokeAction('contacts.assign_to_ticket', {
ticket_id: ticketWithClient,
contact_id: contactB,
})
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
const ticketAfter = await db('tickets').where({ tenant: runtimeState.tenantId, ticket_id: ticketWithClient }).first();
expect(ticketAfter.contact_name_id).toBe(contactA);
expect(ticketAfter.client_id).toBe(clientA);
});
it('T008: contacts.add_tag creates/reuses definitions and mappings and remains idempotent', async () => {
const contactId = await createContactRaw(db, runtimeState.tenantId, { client_id: null });
const first = await invokeAction('contacts.add_tag', {
contact_id: contactId,
tags: ['priority', 'managed'],
});
expect(first.added_count).toBe(2);
expect(first.existing_count).toBe(0);
const second = await invokeAction('contacts.add_tag', {
contact_id: contactId,
tags: ['priority'],
});
expect(second.added_count).toBe(0);
expect(second.existing_count).toBe(1);
const mappings = await db('tag_mappings as tm')
.join('tag_definitions as td', function joinTagDefs() {
this.on('tm.tenant', 'td.tenant').andOn('tm.tag_id', 'td.tag_id');
})
.where({
'tm.tenant': runtimeState.tenantId,
'tm.tagged_type': 'contact',
'tm.tagged_id': contactId,
})
.select('td.tag_text');
expect(mappings.map((row: { tag_text: string }) => row.tag_text).sort()).toEqual(['managed', 'priority']);
expect(mappings.length).toBe(2);
});
it('T009: contacts.duplicate requires new email, supports overrides and tag copy, and excludes historical relationships', async () => {
const sourceClient = await createClient(db, runtimeState.tenantId, 'Source Client');
const targetClient = await createClient(db, runtimeState.tenantId, 'Target Client');
const sourceContact = await invokeAction('contacts.create', {
full_name: 'Source Contact',
email: 'source.contact@example.com',
client_id: sourceClient,
role: 'Source Role',
notes: 'Source Notes',
phone_numbers: [{ phone_number: '555-0110', canonical_type: 'work', is_default: true }],
additional_email_addresses: [{ email_address: 'source.contact+alt@example.com', canonical_type: 'personal' }],
}).then((result) => result.contact.contact_name_id as string);
await invokeAction('contacts.add_tag', {
contact_id: sourceContact,
tags: ['vip', 'imported'],
});
const sourceTicket = await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: sourceClient,
contactId: sourceContact,
});
await db('interactions').insert({
tenant: runtimeState.tenantId,
interaction_id: uuidv4(),
type_id: await getAnyInteractionTypeId(db, runtimeState.tenantId),
contact_name_id: sourceContact,
client_id: sourceClient,
user_id: runtimeState.actorUserId,
ticket_id: sourceTicket,
title: 'Source Interaction',
notes: 'Source interaction notes',
interaction_date: new Date().toISOString(),
start_time: new Date().toISOString(),
end_time: new Date().toISOString(),
duration: 0,
status_id: await getDefaultInteractionStatusId(db, runtimeState.tenantId, runtimeState.actorUserId),
});
const duplicate = await invokeAction('contacts.duplicate', {
source_contact_id: sourceContact,
email: 'duplicate.contact@example.com',
full_name: 'Duplicate Contact',
target_client_id: targetClient,
copy_tags: true,
});
expect(duplicate.source_contact.contact_name_id).toBe(sourceContact);
expect(duplicate.duplicate_contact.full_name).toBe('Duplicate Contact');
expect(duplicate.duplicate_contact.client_id).toBe(targetClient);
expect(duplicate.copied_tags).toBeGreaterThanOrEqual(2);
const duplicatedId = duplicate.duplicate_contact.contact_name_id;
const duplicatedTickets = await db('tickets').where({ tenant: runtimeState.tenantId, contact_name_id: duplicatedId });
const duplicatedInteractions = await db('interactions').where({ tenant: runtimeState.tenantId, contact_name_id: duplicatedId });
expect(duplicatedTickets.length).toBe(0);
expect(duplicatedInteractions.length).toBe(0);
await expect(
invokeAction('contacts.duplicate', {
source_contact_id: sourceContact,
email: 'source.contact@example.com',
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('T010: contacts.add_note creates and appends notes document without creating interaction rows', async () => {
const contactId = await createContactRaw(db, runtimeState.tenantId, { client_id: null });
const first = await invokeAction('contacts.add_note', {
contact_id: contactId,
body: 'First workflow note',
});
expect(first.created_document).toBe(true);
const second = await invokeAction('contacts.add_note', {
contact_id: contactId,
body: 'Second workflow note',
});
expect(second.created_document).toBe(false);
expect(second.document_id).toBe(first.document_id);
const contentRow = await db('document_block_content')
.where({ tenant: runtimeState.tenantId, document_id: first.document_id })
.first();
const blocks = typeof contentRow?.block_data === 'string' ? JSON.parse(contentRow.block_data) : contentRow?.block_data;
expect(Array.isArray(blocks)).toBe(true);
expect(blocks.length).toBeGreaterThanOrEqual(2);
const interactions = await db('interactions')
.where({ tenant: runtimeState.tenantId, contact_name_id: contactId })
.select('interaction_id');
expect(interactions.length).toBe(0);
});
it('T011: contacts.add_interaction uses contact-derived client, default status, actor ownership, and ticket validation', async () => {
const clientA = await createClient(db, runtimeState.tenantId, 'Interaction A');
const clientB = await createClient(db, runtimeState.tenantId, 'Interaction B');
const contact = await createContactRaw(db, runtimeState.tenantId, { client_id: clientA });
const ticketA = await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: clientA,
contactId: contact,
});
const ticketB = await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: clientB,
});
const typeId = await getAnyInteractionTypeId(db, runtimeState.tenantId);
const defaultStatusId = await getDefaultInteractionStatusId(db, runtimeState.tenantId, runtimeState.actorUserId);
const result = await invokeAction('contacts.add_interaction', {
contact_id: contact,
interaction_type_id: typeId,
title: 'Workflow Interaction',
ticket_id: ticketA,
notes: 'Logged from workflow',
});
expect(result.contact_id).toBe(contact);
expect(result.client_id).toBe(clientA);
expect(result.ticket_id).toBe(ticketA);
expect(result.status_id).toBe(defaultStatusId);
expect(result.user_id).toBe(runtimeState.actorUserId);
await expect(
invokeAction('contacts.add_interaction', {
contact_id: contact,
interaction_type_id: typeId,
title: 'Invalid Ticket',
ticket_id: ticketB,
})
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
});
it('T012: contacts.delete requires confirm, supports return_false, hard-deletes eligible contacts, and returns dependency conflicts', async () => {
const deletableContact = await createContactRaw(db, runtimeState.tenantId, { client_id: null });
await expect(
invokeAction('contacts.delete', {
contact_id: deletableContact,
confirm: false,
})
).rejects.toThrow();
const deleted = await invokeAction('contacts.delete', {
contact_id: deletableContact,
confirm: true,
});
expect(deleted).toEqual({ deleted: true, contact_id: deletableContact });
const deletedRow = await db('contacts').where({ tenant: runtimeState.tenantId, contact_name_id: deletableContact }).first();
expect(deletedRow).toBeFalsy();
const missingResult = await invokeAction('contacts.delete', {
contact_id: uuidv4(),
confirm: true,
on_not_found: 'return_false',
});
expect(missingResult.deleted).toBe(false);
const blockedClient = await createClient(db, runtimeState.tenantId, 'Blocked Client');
const blockedContact = await createContactRaw(db, runtimeState.tenantId, { client_id: blockedClient });
await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: blockedClient,
contactId: blockedContact,
});
await expect(
invokeAction('contacts.delete', {
contact_id: blockedContact,
confirm: true,
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('T013: each mutating contacts action enforces required permissions', async () => {
const clientA = await createClient(db, runtimeState.tenantId, 'Perm Client A');
const clientB = await createClient(db, runtimeState.tenantId, 'Perm Client B');
const contactA = await createContactRaw(db, runtimeState.tenantId, { client_id: clientA });
const ticketA = await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: clientA,
contactId: contactA,
});
const interactionTypeId = await getAnyInteractionTypeId(db, runtimeState.tenantId);
const checks: Array<{ actionId: string; denied: string; input: Record<string, unknown> }> = [
{ actionId: 'contacts.create', denied: 'contact:create', input: { full_name: 'Denied', email: 'denied@example.com' } },
{ actionId: 'contacts.update', denied: 'contact:update', input: { contact_id: contactA, patch: { full_name: 'Denied' } } },
{ actionId: 'contacts.deactivate', denied: 'contact:update', input: { contact_id: contactA } },
{ actionId: 'contacts.delete', denied: 'contact:delete', input: { contact_id: contactA, confirm: true } },
{
actionId: 'contacts.duplicate',
denied: 'contact:read',
input: { source_contact_id: contactA, email: 'dup.denied@example.com' },
},
{ actionId: 'contacts.add_tag', denied: 'contact:update', input: { contact_id: contactA, tags: ['x'] } },
{
actionId: 'contacts.assign_to_ticket',
denied: 'ticket:update',
input: { contact_id: contactA, ticket_id: ticketA },
},
{ actionId: 'contacts.add_note', denied: 'contact:update', input: { contact_id: contactA, body: 'Denied note' } },
{
actionId: 'contacts.add_interaction',
denied: 'contact:update',
input: { contact_id: contactA, interaction_type_id: interactionTypeId, title: 'Denied interaction' },
},
{
actionId: 'contacts.add_to_client',
denied: 'contact:update',
input: { contact_id: contactA, client_id: clientB },
},
{
actionId: 'contacts.move_to_client',
denied: 'contact:update',
input: { contact_id: contactA, target_client_id: clientB },
},
];
for (const check of checks) {
runtimeState.deniedPermissions.clear();
runtimeState.deniedPermissions.add(check.denied);
await expect(invokeAction(check.actionId, check.input)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
}
runtimeState.deniedPermissions.clear();
});
it('T014: side-effectful contacts actions write audits and create/update/deactivate publish contact events', async () => {
const clientA = await createClient(db, runtimeState.tenantId, 'Audit Client A');
const clientB = await createClient(db, runtimeState.tenantId, 'Audit Client B');
const created = await invokeAction('contacts.create', {
full_name: 'Audit Contact',
email: 'audit.contact@example.com',
client_id: clientA,
});
const contactId = created.contact.contact_name_id as string;
await invokeAction('contacts.update', {
contact_id: contactId,
patch: { full_name: 'Audit Contact Updated' },
});
await invokeAction('contacts.add_tag', { contact_id: contactId, tags: ['audit-tag'] });
const ticket = await createTicket(db, {
tenantId: runtimeState.tenantId,
actorUserId: runtimeState.actorUserId,
clientId: clientA,
contactId: null,
});
await invokeAction('contacts.assign_to_ticket', { contact_id: contactId, ticket_id: ticket });
await invokeAction('contacts.add_note', { contact_id: contactId, body: 'Audit note body' });
await getDefaultInteractionStatusId(db, runtimeState.tenantId, runtimeState.actorUserId);
await invokeAction('contacts.add_interaction', {
contact_id: contactId,
interaction_type_id: await getAnyInteractionTypeId(db, runtimeState.tenantId),
title: 'Audit interaction',
});
await invokeAction('contacts.move_to_client', {
contact_id: contactId,
target_client_id: clientB,
expected_current_client_id: clientA,
});
await invokeAction('contacts.deactivate', { contact_id: contactId });
const auditRows = await db('audit_logs')
.where({ tenant: runtimeState.tenantId, table_name: 'workflow_runs', user_id: runtimeState.actorUserId })
.whereIn('operation', [
'workflow_action:contacts.create',
'workflow_action:contacts.update',
'workflow_action:contacts.add_tag',
'workflow_action:contacts.assign_to_ticket',
'workflow_action:contacts.add_note',
'workflow_action:contacts.add_interaction',
'workflow_action:contacts.move_to_client',
'workflow_action:contacts.deactivate',
]);
const operations = new Set(auditRows.map((row: { operation: string }) => row.operation));
expect(operations.has('workflow_action:contacts.create')).toBe(true);
expect(operations.has('workflow_action:contacts.update')).toBe(true);
expect(operations.has('workflow_action:contacts.deactivate')).toBe(true);
const eventTypes = runtimeState.publishedEvents.map((event) => event.eventType);
expect(eventTypes).toContain('CONTACT_CREATED');
expect(eventTypes).toContain('CONTACT_UPDATED');
expect(eventTypes).toContain('CONTACT_ARCHIVED');
});
it('T014b: contact create/update/deactivate events are published for unassigned contacts', async () => {
const created = await invokeAction('contacts.create', {
full_name: 'Unassigned Event Contact',
email: 'unassigned.event@example.com',
});
const contactId = created.contact.contact_name_id as string;
await invokeAction('contacts.update', {
contact_id: contactId,
patch: { full_name: 'Unassigned Event Contact Updated' },
});
await invokeAction('contacts.deactivate', { contact_id: contactId });
const contactEvents = runtimeState.publishedEvents.filter((event) =>
['CONTACT_CREATED', 'CONTACT_UPDATED', 'CONTACT_ARCHIVED'].includes(event.eventType)
);
expect(contactEvents.map((event) => event.eventType)).toEqual([
'CONTACT_CREATED',
'CONTACT_UPDATED',
'CONTACT_ARCHIVED',
]);
for (const event of contactEvents) {
expect(event.payload.contactId).toBe(contactId);
expect(event.payload).not.toHaveProperty('clientId');
}
});
it('T015: contacts.deactivate sets inactive and is idempotent on repeated calls', async () => {
const clientId = await createClient(db, runtimeState.tenantId, 'Deactivate Client');
const contactId = await createContactRaw(db, runtimeState.tenantId, { client_id: clientId, is_inactive: false });
const first = await invokeAction('contacts.deactivate', { contact_id: contactId });
expect(first.deactivated).toBe(true);
expect(first.noop).toBe(false);
expect(first.previous_is_inactive).toBe(false);
expect(first.current_is_inactive).toBe(true);
const second = await invokeAction('contacts.deactivate', { contact_id: contactId });
expect(second.deactivated).toBe(false);
expect(second.noop).toBe(true);
expect(second.previous_is_inactive).toBe(true);
expect(second.current_is_inactive).toBe(true);
});
});