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
427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
buildContactCreatePayload,
|
|
buildContactListQuery,
|
|
buildContactUpdatePayload,
|
|
buildProjectTaskCreatePayload,
|
|
buildProjectTaskListQuery,
|
|
buildProjectTaskUpdatePayload,
|
|
formatAlgaApiError,
|
|
normalizeSuccessResponse,
|
|
parseContactEmailAddresses,
|
|
parseContactPhoneNumbers,
|
|
} from '../nodes/AlgaPsa/helpers';
|
|
import { buildAlgaApiRequestOptions } from '../nodes/AlgaPsa/transport';
|
|
|
|
describe('Transport and normalization helpers', () => {
|
|
it('T004: request helper normalizes base URL and endpoint slashes', () => {
|
|
const options = buildAlgaApiRequestOptions(
|
|
{
|
|
baseUrl: 'https://api.algapsa.test///',
|
|
apiKey: 'secret-key',
|
|
},
|
|
'GET',
|
|
'api/v1/tickets',
|
|
{ page: 1 },
|
|
);
|
|
|
|
expect(options.url).toBe('https://api.algapsa.test/api/v1/tickets');
|
|
expect(options.qs).toEqual({ page: 1 });
|
|
});
|
|
|
|
it('T005: request helper injects x-api-key and does not log credential values', () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
const options = buildAlgaApiRequestOptions(
|
|
{
|
|
baseUrl: 'https://api.algapsa.test',
|
|
apiKey: 'sensitive-key',
|
|
},
|
|
'POST',
|
|
'/api/v1/tickets',
|
|
undefined,
|
|
{ title: 'Example' },
|
|
);
|
|
|
|
expect(options.headers?.['x-api-key']).toBe('sensitive-key');
|
|
expect(options.url).not.toContain('sensitive-key');
|
|
expect(logSpy).not.toHaveBeenCalled();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
expect(errorSpy).not.toHaveBeenCalled();
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('T025: response normalizer unwraps single-object data responses', () => {
|
|
const normalized = normalizeSuccessResponse({ data: { ticket_id: 'abc', title: 'A' } });
|
|
expect(normalized).toEqual({ ticket_id: 'abc', title: 'A' });
|
|
});
|
|
|
|
it('T026: response normalizer unwraps list data and preserves pagination metadata', () => {
|
|
const normalized = normalizeSuccessResponse({
|
|
data: [{ ticket_id: 'abc' }],
|
|
pagination: { page: 1, total: 1 },
|
|
});
|
|
|
|
expect(normalized).toEqual({
|
|
data: [{ ticket_id: 'abc' }],
|
|
pagination: { page: 1, total: 1 },
|
|
});
|
|
});
|
|
|
|
it('T027: maps 401 API response into actionable error shape', () => {
|
|
const parsed = formatAlgaApiError({
|
|
response: {
|
|
status: 401,
|
|
data: {
|
|
error: {
|
|
code: 'UNAUTHORIZED',
|
|
message: 'Invalid API key',
|
|
details: { reason: 'missing key' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed).toEqual({
|
|
statusCode: 401,
|
|
code: 'UNAUTHORIZED',
|
|
message: 'Invalid API key',
|
|
details: { reason: 'missing key' },
|
|
});
|
|
});
|
|
|
|
it('T028: maps 403 API response into actionable error shape', () => {
|
|
const parsed = formatAlgaApiError({
|
|
response: {
|
|
status: 403,
|
|
data: {
|
|
error: {
|
|
code: 'FORBIDDEN',
|
|
message: 'Permission denied',
|
|
details: { permission: 'ticket:update' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.code).toBe('FORBIDDEN');
|
|
expect(parsed.statusCode).toBe(403);
|
|
expect(parsed.details).toEqual({ permission: 'ticket:update' });
|
|
});
|
|
|
|
it('T029: maps 404 API response into actionable error shape', () => {
|
|
const parsed = formatAlgaApiError({
|
|
response: {
|
|
status: 404,
|
|
data: {
|
|
error: {
|
|
code: 'NOT_FOUND',
|
|
message: 'Ticket not found',
|
|
details: { ticketId: 'missing-id' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.code).toBe('NOT_FOUND');
|
|
expect(parsed.message).toBe('Ticket not found');
|
|
expect(parsed.statusCode).toBe(404);
|
|
});
|
|
|
|
it('T030: maps 400 API response into actionable error shape', () => {
|
|
const parsed = formatAlgaApiError({
|
|
response: {
|
|
status: 400,
|
|
data: {
|
|
error: {
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Validation failed',
|
|
details: [{ path: ['status_id'], message: 'Invalid UUID' }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.code).toBe('VALIDATION_ERROR');
|
|
expect(parsed.message).toBe('Validation failed');
|
|
expect(parsed.statusCode).toBe(400);
|
|
expect(parsed.details).toEqual([{ path: ['status_id'], message: 'Invalid UUID' }]);
|
|
});
|
|
|
|
it('T011: contact create payload builder maps full_name and omits absent optional fields', () => {
|
|
const payload = buildContactCreatePayload({
|
|
fullName: 'Ada Lovelace',
|
|
additionalFields: {},
|
|
});
|
|
|
|
expect(payload).toEqual({ full_name: 'Ada Lovelace' });
|
|
});
|
|
|
|
it('T012: contact create payload builder includes scalar optional fields when present', () => {
|
|
const payload = buildContactCreatePayload({
|
|
fullName: 'Ada Lovelace',
|
|
additionalFields: {
|
|
email: 'ada@example.com',
|
|
client_id: '00000000-0000-0000-0000-000000000001',
|
|
role: 'CTO',
|
|
notes: 'Primary automation contact',
|
|
is_inactive: true,
|
|
},
|
|
});
|
|
|
|
expect(payload).toEqual({
|
|
full_name: 'Ada Lovelace',
|
|
email: 'ada@example.com',
|
|
client_id: '00000000-0000-0000-0000-000000000001',
|
|
role: 'CTO',
|
|
notes: 'Primary automation contact',
|
|
is_inactive: true,
|
|
});
|
|
});
|
|
|
|
it('T013: contact update payload builder includes only provided update fields', () => {
|
|
const payload = buildContactUpdatePayload({
|
|
full_name: 'Updated Contact',
|
|
email: '',
|
|
client_id: '00000000-0000-0000-0000-000000000002',
|
|
notes: 'Updated via n8n',
|
|
});
|
|
|
|
expect(payload).toEqual({
|
|
full_name: 'Updated Contact',
|
|
client_id: '00000000-0000-0000-0000-000000000002',
|
|
notes: 'Updated via n8n',
|
|
});
|
|
});
|
|
|
|
it('contact payload builders accept primary email metadata and additional email rows', () => {
|
|
const createPayload = buildContactCreatePayload({
|
|
fullName: 'Ada Lovelace',
|
|
additionalFields: {
|
|
email: 'ada@example.com',
|
|
primary_email_canonical_type: 'billing',
|
|
additional_email_addresses: JSON.stringify([
|
|
{
|
|
email_address: 'ada.personal@example.com',
|
|
canonical_type: 'personal',
|
|
display_order: 0,
|
|
},
|
|
]),
|
|
},
|
|
});
|
|
|
|
expect(createPayload).toEqual({
|
|
full_name: 'Ada Lovelace',
|
|
email: 'ada@example.com',
|
|
primary_email_canonical_type: 'billing',
|
|
additional_email_addresses: [
|
|
{
|
|
email_address: 'ada.personal@example.com',
|
|
canonical_type: 'personal',
|
|
display_order: 0,
|
|
},
|
|
],
|
|
});
|
|
|
|
const updatePayload = buildContactUpdatePayload({
|
|
primary_email_custom_type: 'Escalations',
|
|
additional_email_addresses: JSON.stringify([
|
|
{
|
|
contact_additional_email_address_id: '00000000-0000-0000-0000-000000000010',
|
|
email_address: 'ada.billing@example.com',
|
|
custom_type: 'Billing Alias',
|
|
display_order: 1,
|
|
},
|
|
]),
|
|
});
|
|
|
|
expect(updatePayload).toEqual({
|
|
primary_email_custom_type: 'Escalations',
|
|
additional_email_addresses: [
|
|
{
|
|
contact_additional_email_address_id: '00000000-0000-0000-0000-000000000010',
|
|
email_address: 'ada.billing@example.com',
|
|
custom_type: 'Billing Alias',
|
|
display_order: 1,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('T014: contact list query builder serializes pagination and core filters correctly', () => {
|
|
const query = buildContactListQuery({
|
|
page: 3,
|
|
limit: 50,
|
|
filters: {
|
|
client_id: '00000000-0000-0000-0000-000000000003',
|
|
search_term: 'ada',
|
|
is_inactive: false,
|
|
},
|
|
});
|
|
|
|
expect(query).toEqual({
|
|
page: 3,
|
|
limit: 50,
|
|
client_id: '00000000-0000-0000-0000-000000000003',
|
|
search_term: 'ada',
|
|
is_inactive: false,
|
|
});
|
|
});
|
|
|
|
it('T015: phone_numbers parser accepts a valid JSON array of contact phone-number objects', () => {
|
|
const parsed = parseContactPhoneNumbers(
|
|
JSON.stringify([
|
|
{
|
|
phone_number: '+1-206-555-0100',
|
|
canonical_type: 'mobile',
|
|
is_default: true,
|
|
display_order: 0,
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(parsed).toEqual([
|
|
{
|
|
phone_number: '+1-206-555-0100',
|
|
canonical_type: 'mobile',
|
|
is_default: true,
|
|
display_order: 0,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('T016: phone_numbers parser rejects malformed JSON before any request is sent', () => {
|
|
expect(() => parseContactPhoneNumbers('[{')).toThrow('phone_numbers must be valid JSON');
|
|
});
|
|
|
|
it('T017: phone_numbers parser rejects non-array JSON values before any request is sent', () => {
|
|
expect(() => parseContactPhoneNumbers('{"phone_number":"+1-206-555-0100"}')).toThrow(
|
|
'phone_numbers must be a JSON array',
|
|
);
|
|
});
|
|
|
|
it('T018: phone_numbers parser rejects array entries that are missing phone_number', () => {
|
|
expect(() =>
|
|
parseContactPhoneNumbers(
|
|
JSON.stringify([
|
|
{
|
|
canonical_type: 'mobile',
|
|
},
|
|
]),
|
|
),
|
|
).toThrow('phone_numbers[0].phone_number is required');
|
|
});
|
|
|
|
it('parseContactEmailAddresses accepts JSON arrays of labeled additional email rows', () => {
|
|
const parsed = parseContactEmailAddresses(
|
|
JSON.stringify([
|
|
{
|
|
email_address: 'ada.personal@example.com',
|
|
canonical_type: 'personal',
|
|
display_order: 0,
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(parsed).toEqual([
|
|
{
|
|
email_address: 'ada.personal@example.com',
|
|
canonical_type: 'personal',
|
|
display_order: 0,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('T070: project task create payload maps required fields and omits absent optional fields', () => {
|
|
const payload = buildProjectTaskCreatePayload({
|
|
taskName: 'Write specification',
|
|
statusMappingId: '00000000-0000-0000-0000-000000000301',
|
|
additionalFields: {},
|
|
});
|
|
|
|
expect(payload).toEqual({
|
|
task_name: 'Write specification',
|
|
project_status_mapping_id: '00000000-0000-0000-0000-000000000301',
|
|
});
|
|
});
|
|
|
|
it('T071: project task create payload includes scalar optional fields when present', () => {
|
|
const payload = buildProjectTaskCreatePayload({
|
|
taskName: 'Write specification',
|
|
statusMappingId: '00000000-0000-0000-0000-000000000301',
|
|
additionalFields: {
|
|
description: 'Draft the functional spec',
|
|
assigned_to: '00000000-0000-0000-0000-000000000302',
|
|
estimated_hours: 4.5,
|
|
due_date: '2026-05-01',
|
|
priority_id: '00000000-0000-0000-0000-000000000303',
|
|
task_type_key: 'design',
|
|
wbs_code: '1.1',
|
|
tags: 'backend, planning',
|
|
},
|
|
});
|
|
|
|
expect(payload).toEqual({
|
|
task_name: 'Write specification',
|
|
project_status_mapping_id: '00000000-0000-0000-0000-000000000301',
|
|
description: 'Draft the functional spec',
|
|
assigned_to: '00000000-0000-0000-0000-000000000302',
|
|
estimated_hours: 4.5,
|
|
due_date: '2026-05-01',
|
|
priority_id: '00000000-0000-0000-0000-000000000303',
|
|
task_type_key: 'design',
|
|
wbs_code: '1.1',
|
|
tags: ['backend', 'planning'],
|
|
});
|
|
});
|
|
|
|
it('T072: project task create payload rejects non-UUID assigned_to before request time', () => {
|
|
expect(() =>
|
|
buildProjectTaskCreatePayload({
|
|
taskName: 'Task',
|
|
statusMappingId: '00000000-0000-0000-0000-000000000301',
|
|
additionalFields: { assigned_to: 'not-a-uuid' },
|
|
}),
|
|
).toThrow('assigned_to must be a valid UUID');
|
|
});
|
|
|
|
it('T073: project task create payload rejects negative estimated_hours before request time', () => {
|
|
expect(() =>
|
|
buildProjectTaskCreatePayload({
|
|
taskName: 'Task',
|
|
statusMappingId: '00000000-0000-0000-0000-000000000301',
|
|
additionalFields: { estimated_hours: -1 },
|
|
}),
|
|
).toThrow('estimated_hours must be a non-negative number');
|
|
});
|
|
|
|
it('T074: project task update payload includes only provided update fields', () => {
|
|
const payload = buildProjectTaskUpdatePayload({
|
|
task_name: 'Renamed',
|
|
description: '',
|
|
project_status_mapping_id: '00000000-0000-0000-0000-000000000304',
|
|
tags: 'escalated',
|
|
});
|
|
|
|
expect(payload).toEqual({
|
|
task_name: 'Renamed',
|
|
project_status_mapping_id: '00000000-0000-0000-0000-000000000304',
|
|
tags: ['escalated'],
|
|
});
|
|
});
|
|
|
|
it('T075: project task list query serializes pagination parameters', () => {
|
|
expect(
|
|
buildProjectTaskListQuery({
|
|
page: 3,
|
|
limit: 50,
|
|
}),
|
|
).toEqual({ page: 3, limit: 50 });
|
|
});
|
|
});
|