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

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 });
});
});