import { describe, expect, it } from 'vitest'; import { NodeOperationError } from 'n8n-workflow'; import { AlgaPsa } from '../nodes/AlgaPsa/AlgaPsa.node'; import { createApiError, createExecuteHarness } from './testUtils'; async function executeNode( items: Array>, requestHandler: (options: any, index: number) => unknown, continueOnFail = false, ) { const node = new AlgaPsa(); const harness = createExecuteHarness({ items, continueOnFail, requestHandler, }); const output = await node.execute.call(harness.context); return { output, requests: harness.requests, }; } async function executeNodeExpectFailure( items: Array>, requestHandler: (options: any, index: number) => unknown = () => ({ data: {} }), ) { const node = new AlgaPsa(); const harness = createExecuteHarness({ items, continueOnFail: false, requestHandler, }); try { await node.execute.call(harness.context); } catch (error) { return { error, requests: harness.requests, }; } throw new Error('Expected execute to fail'); } const baseCreateParams = { resource: 'ticket', ticketOperation: 'create', title: 'Example Ticket', client_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000001' }, board_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000002' }, status_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000003' }, priority_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000004' }, }; const baseContactCreateParams = { resource: 'contact', contactOperation: 'create', full_name: 'Ada Lovelace', }; describe('Node execute operations', () => { it('T010: Ticket Create builds POST payload with required field mappings', async () => { const { requests } = await executeNode( [ { ...baseCreateParams, createAdditionalFields: {}, }, ], () => ({ data: { ticket_id: 'ticket-1' } }), ); expect(requests[0]?.method).toBe('POST'); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/tickets'); expect(requests[0]?.body).toMatchObject({ title: 'Example Ticket', client_id: '00000000-0000-0000-0000-000000000001', board_id: '00000000-0000-0000-0000-000000000002', status_id: '00000000-0000-0000-0000-000000000003', priority_id: '00000000-0000-0000-0000-000000000004', }); }); it('T011: Ticket Create includes optional fields only when provided', async () => { const { requests } = await executeNode( [ { ...baseCreateParams, createAdditionalFields: { url: 'https://example.test/ticket', tags: 'urgent,automation', assigned_to: '00000000-0000-0000-0000-000000000005', }, }, ], () => ({ data: { ticket_id: 'ticket-1' } }), ); expect(requests[0]?.body).toMatchObject({ url: 'https://example.test/ticket', tags: ['urgent', 'automation'], assigned_to: '00000000-0000-0000-0000-000000000005', }); expect(requests[0]?.body).not.toHaveProperty('location_id'); expect(requests[0]?.body).not.toHaveProperty('category_id'); }); it('T012: Ticket Create unwraps created ticket object from API data wrapper', async () => { const { output } = await executeNode( [ { ...baseCreateParams, createAdditionalFields: {}, }, ], () => ({ data: { ticket_id: 'ticket-123', title: 'Created' } }), ); expect(output[0][0].json).toEqual({ ticket_id: 'ticket-123', title: 'Created' }); }); it('T013: Ticket Get sends GET by id and rejects empty id before request', async () => { const success = await executeNode( [ { resource: 'ticket', ticketOperation: 'get', ticketId: '00000000-0000-0000-0000-000000000010', }, ], () => ({ data: { ticket_id: '00000000-0000-0000-0000-000000000010' } }), ); expect(success.requests[0]?.method).toBe('GET'); expect(success.requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/tickets/00000000-0000-0000-0000-000000000010', ); await expect( executeNode( [ { resource: 'ticket', ticketOperation: 'get', ticketId: '', }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); }); it('T014: Ticket Get returns expected ticket object', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'get', ticketId: '00000000-0000-0000-0000-000000000011', }, ], () => ({ data: { ticket_id: '00000000-0000-0000-0000-000000000011', title: 'A' } }), ); expect(output[0][0].json).toEqual({ ticket_id: '00000000-0000-0000-0000-000000000011', title: 'A', }); }); it('T015: Ticket List serializes pagination/sort/order/filter query parameters', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'list', page: 2, limit: 50, sort: 'ticket_number', order: 'asc', listFilters: { client_id: '00000000-0000-0000-0000-000000000111', is_open: true, }, }, ], () => ({ data: [], pagination: { page: 2, total: 0 } }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/tickets'); expect(requests[0]?.qs).toMatchObject({ page: 2, limit: 50, sort: 'ticket_number', order: 'asc', client_id: '00000000-0000-0000-0000-000000000111', is_open: true, }); }); it('T016: Ticket List keeps pagination metadata in node output', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'list', page: 1, limit: 25, sort: 'entered_at', order: 'desc', listFilters: {}, }, ], () => ({ data: [{ ticket_id: '1' }], pagination: { page: 1, total: 1, totalPages: 1 } }), ); expect(output[0][0].json).toEqual({ data: [{ ticket_id: '1' }], pagination: { page: 1, total: 1, totalPages: 1 }, }); }); it('T047: Ticket List Comments sends GET with optional query parameters', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'listComments', ticketId: '00000000-0000-0000-0000-000000000112', commentListOptions: { limit: 10, offset: 20, order: 'desc', }, }, ], () => ({ data: [] }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/tickets/00000000-0000-0000-0000-000000000112/comments', ); expect(requests[0]?.qs).toMatchObject({ limit: 10, offset: 20, order: 'desc', }); }); it('T048: Ticket List Comments preserves array output from API data wrapper', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'listComments', ticketId: '00000000-0000-0000-0000-000000000113', commentListOptions: {}, }, ], () => ({ data: [ { comment_id: 'comment-1', comment_text: 'First' }, { comment_id: 'comment-2', comment_text: 'Second' }, ], }), ); expect(output[0][0].json).toEqual({ data: [ { comment_id: 'comment-1', comment_text: 'First' }, { comment_id: 'comment-2', comment_text: 'Second' }, ], }); }); it('T017: Ticket Search serializes query and optional search filters', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'search', query: 'outage', searchLimit: 15, searchAdditionalFields: { include_closed: true, fields: ['title', 'ticket_number'], status_ids: 'status-a,status-b', priority_ids: 'priority-a', client_ids: 'client-a', assigned_to_ids: 'user-a,user-b', }, }, ], () => ({ data: [] }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/tickets/search'); expect(requests[0]?.qs).toMatchObject({ query: 'outage', limit: 15, include_closed: true, fields: 'title,ticket_number', status_ids: 'status-a,status-b', priority_ids: 'priority-a', client_ids: 'client-a', assigned_to_ids: 'user-a,user-b', }); }); it('T018: Ticket Search returns result set and handles empty data array', async () => { const populated = await executeNode( [ { resource: 'ticket', ticketOperation: 'search', query: 'vpn', searchLimit: 10, searchAdditionalFields: {}, }, ], () => ({ data: [{ ticket_id: 'a' }, { ticket_id: 'b' }] }), ); expect(populated.output[0][0].json).toEqual({ data: [{ ticket_id: 'a' }, { ticket_id: 'b' }] }); const empty = await executeNode( [ { resource: 'ticket', ticketOperation: 'search', query: 'no-match', searchLimit: 10, searchAdditionalFields: {}, }, ], () => ({ data: [] }), ); expect(empty.output[0][0].json).toEqual({ data: [] }); }); it('T019: Ticket Update sends PUT with only provided mutable fields', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'update', ticketId: '00000000-0000-0000-0000-000000000020', updateAdditionalFields: { title: 'Updated title', url: '', client_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000021' }, board_id: { mode: 'id', value: '' }, }, }, ], () => ({ data: { ticket_id: '00000000-0000-0000-0000-000000000020' } }), ); expect(requests[0]?.method).toBe('PUT'); expect(requests[0]?.body).toEqual({ title: 'Updated title', client_id: '00000000-0000-0000-0000-000000000021', }); }); it('T020: Ticket Update returns updated ticket object', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'update', ticketId: '00000000-0000-0000-0000-000000000022', updateAdditionalFields: { title: 'Updated', }, }, ], () => ({ data: { ticket_id: '00000000-0000-0000-0000-000000000022', title: 'Updated' } }), ); expect(output[0][0].json).toEqual({ ticket_id: '00000000-0000-0000-0000-000000000022', title: 'Updated', }); }); it('T049: Ticket Add Comment sends POST with supported payload fields only', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'addComment', ticketId: '00000000-0000-0000-0000-000000000114', commentText: 'Automation note', commentAdditionalFields: { is_internal: true, }, }, ], () => ({ data: { comment_id: 'comment-3' } }), ); expect(requests[0]?.method).toBe('POST'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/tickets/00000000-0000-0000-0000-000000000114/comments', ); expect(requests[0]?.body).toEqual({ comment_text: 'Automation note', is_internal: true, }); expect(requests[0]?.body).not.toHaveProperty('time_spent'); }); it('T050: Ticket Add Comment unwraps the created comment object', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'addComment', ticketId: '00000000-0000-0000-0000-000000000115', commentText: 'Customer update', commentAdditionalFields: {}, }, ], () => ({ data: { comment_id: 'comment-4', ticket_id: '00000000-0000-0000-0000-000000000115', comment_text: 'Customer update', is_internal: false, }, }), ); expect(output[0][0].json).toEqual({ comment_id: 'comment-4', ticket_id: '00000000-0000-0000-0000-000000000115', comment_text: 'Customer update', is_internal: false, }); }); it('T021: Ticket Update Status sends PUT with status_id payload', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'updateStatus', ticketId: '00000000-0000-0000-0000-000000000023', status_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000024' }, }, ], () => ({ data: { ticket_id: '00000000-0000-0000-0000-000000000023' } }), ); expect(requests[0]?.method).toBe('PUT'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/tickets/00000000-0000-0000-0000-000000000023/status', ); expect(requests[0]?.body).toEqual({ status_id: '00000000-0000-0000-0000-000000000024', }); }); it('T022: Ticket Update Assignment sends PUT with assigned_to payload', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'updateAssignment', ticketId: '00000000-0000-0000-0000-000000000025', assignmentAction: 'assign', assigned_to: '00000000-0000-0000-0000-000000000026', }, ], () => ({ data: { ticket_id: '00000000-0000-0000-0000-000000000025' } }), ); expect(requests[0]?.method).toBe('PUT'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/tickets/00000000-0000-0000-0000-000000000025/assignment', ); expect(requests[0]?.body).toEqual({ assigned_to: '00000000-0000-0000-0000-000000000026', }); }); it('T023: Ticket Delete sends DELETE to ticket endpoint', async () => { const { requests } = await executeNode( [ { resource: 'ticket', ticketOperation: 'delete', ticketId: '00000000-0000-0000-0000-000000000027', }, ], () => undefined, ); expect(requests[0]?.method).toBe('DELETE'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/tickets/00000000-0000-0000-0000-000000000027', ); }); it('T024: Ticket Delete converts 204-style response to success object', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'delete', ticketId: '00000000-0000-0000-0000-000000000028', }, ], () => undefined, ); expect(output[0][0].json).toEqual({ success: true, id: '00000000-0000-0000-0000-000000000028', deleted: true, }); }); it('T051: Ticket comment operations reject empty or invalid ticketId before request', async () => { await expect( executeNode( [ { resource: 'ticket', ticketOperation: 'listComments', ticketId: '', commentListOptions: {}, }, ], () => ({ data: [] }), ), ).rejects.toBeInstanceOf(NodeOperationError); await expect( executeNode( [ { resource: 'ticket', ticketOperation: 'addComment', ticketId: 'not-a-uuid', commentText: 'Hello', commentAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); }); it('T052: Ticket Add Comment rejects empty comment text before request', async () => { await expect( executeNode( [ { resource: 'ticket', ticketOperation: 'addComment', ticketId: '00000000-0000-0000-0000-000000000116', commentText: ' ', commentAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); }); it('T019: contact create sends POST /api/v1/contacts', async () => { const { requests } = await executeNode( [ { ...baseContactCreateParams, contactCreateAdditionalFields: {}, }, ], () => ({ data: { contact_name_id: 'contact-1' } }), ); expect(requests[0]?.method).toBe('POST'); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/contacts'); expect(requests[0]?.body).toEqual({ full_name: 'Ada Lovelace' }); }); it('T020: contact create request body includes parsed phone_numbers when provided', async () => { const { requests } = await executeNode( [ { ...baseContactCreateParams, contactCreateAdditionalFields: { phone_numbers: JSON.stringify([ { phone_number: '+1-206-555-0100', canonical_type: 'mobile', is_default: true, }, ]), }, }, ], () => ({ data: { contact_name_id: 'contact-1' } }), ); expect(requests[0]?.body).toMatchObject({ full_name: 'Ada Lovelace', phone_numbers: [ { phone_number: '+1-206-555-0100', canonical_type: 'mobile', is_default: true, }, ], }); }); it('contact create request body includes primary email metadata and additional email rows when provided', async () => { const { requests } = await executeNode( [ { ...baseContactCreateParams, contactCreateAdditionalFields: { 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, }, ]), }, }, ], () => ({ data: { contact_name_id: 'contact-1' } }), ); expect(requests[0]?.body).toMatchObject({ 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, }, ], }); }); it('T021: contact create unwraps a successful data wrapper into the created contact object', async () => { const { output } = await executeNode( [ { ...baseContactCreateParams, contactCreateAdditionalFields: {}, }, ], () => ({ data: { contact_name_id: 'contact-123', full_name: 'Ada Lovelace' } }), ); expect(output[0][0].json).toEqual({ contact_name_id: 'contact-123', full_name: 'Ada Lovelace', }); }); it('T022: contact get sends GET /api/v1/contacts/{id}', async () => { const { requests } = await executeNode( [ { resource: 'contact', contactOperation: 'get', contactId: '00000000-0000-0000-0000-000000000101', }, ], () => ({ data: { contact_name_id: '00000000-0000-0000-0000-000000000101' } }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/contacts/00000000-0000-0000-0000-000000000101', ); }); it('T023: contact get returns the normalized contact object', async () => { const { output } = await executeNode( [ { resource: 'contact', contactOperation: 'get', contactId: '00000000-0000-0000-0000-000000000102', }, ], () => ({ data: { contact_name_id: '00000000-0000-0000-0000-000000000102', full_name: 'Grace Hopper', }, }), ); expect(output[0][0].json).toEqual({ contact_name_id: '00000000-0000-0000-0000-000000000102', full_name: 'Grace Hopper', }); }); it('T024: contact list sends GET /api/v1/contacts with selected pagination and filter query parameters', async () => { const { requests } = await executeNode( [ { resource: 'contact', contactOperation: 'list', contactPage: 2, contactLimit: 50, contactListFilters: { client_id: '00000000-0000-0000-0000-000000000103', search_term: 'ada', is_inactive: true, }, }, ], () => ({ data: [], pagination: { page: 2, total: 0 } }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/contacts'); expect(requests[0]?.qs).toEqual({ page: 2, limit: 50, client_id: '00000000-0000-0000-0000-000000000103', search_term: 'ada', is_inactive: true, }); }); it('T025: contact list preserves pagination metadata in node output', async () => { const { output } = await executeNode( [ { resource: 'contact', contactOperation: 'list', contactPage: 1, contactLimit: 25, contactListFilters: {}, }, ], () => ({ data: [{ contact_name_id: 'contact-1' }], pagination: { page: 1, total: 1, totalPages: 1 }, }), ); expect(output[0][0].json).toEqual({ data: [{ contact_name_id: 'contact-1' }], pagination: { page: 1, total: 1, totalPages: 1 }, }); }); it('T026: contact update sends PUT /api/v1/contacts/{id} with only changed fields', async () => { const { requests } = await executeNode( [ { resource: 'contact', contactOperation: 'update', contactId: '00000000-0000-0000-0000-000000000104', contactUpdateAdditionalFields: { email: 'ada@example.com', notes: '', client_id: { mode: 'id', value: '00000000-0000-0000-0000-000000000105' }, }, }, ], () => ({ data: { contact_name_id: '00000000-0000-0000-0000-000000000104' } }), ); expect(requests[0]?.method).toBe('PUT'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/contacts/00000000-0000-0000-0000-000000000104', ); expect(requests[0]?.body).toEqual({ email: 'ada@example.com', client_id: '00000000-0000-0000-0000-000000000105', }); }); it('T027: contact update returns the normalized updated contact object', async () => { const { output } = await executeNode( [ { resource: 'contact', contactOperation: 'update', contactId: '00000000-0000-0000-0000-000000000106', contactUpdateAdditionalFields: { role: 'Director of Automation', }, }, ], () => ({ data: { contact_name_id: '00000000-0000-0000-0000-000000000106', role: 'Director of Automation', }, }), ); expect(output[0][0].json).toEqual({ contact_name_id: '00000000-0000-0000-0000-000000000106', role: 'Director of Automation', }); }); it('T028: contact delete sends DELETE /api/v1/contacts/{id}', async () => { const { requests } = await executeNode( [ { resource: 'contact', contactOperation: 'delete', contactId: '00000000-0000-0000-0000-000000000107', }, ], () => undefined, ); expect(requests[0]?.method).toBe('DELETE'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/contacts/00000000-0000-0000-0000-000000000107', ); }); it('T029: contact delete returns a non-empty normalized success object', async () => { const { output } = await executeNode( [ { resource: 'contact', contactOperation: 'delete', contactId: '00000000-0000-0000-0000-000000000108', }, ], () => undefined, ); expect(output[0][0].json).toEqual({ success: true, id: '00000000-0000-0000-0000-000000000108', deleted: true, }); }); it('T030: contact get rejects an empty contactId before making a request', async () => { const result = await executeNodeExpectFailure([ { resource: 'contact', contactOperation: 'get', contactId: '', }, ]); expect(result.error).toBeInstanceOf(NodeOperationError); expect(result.requests).toHaveLength(0); }); it('T031: contact update rejects an invalid UUID contactId before making a request', async () => { const result = await executeNodeExpectFailure([ { resource: 'contact', contactOperation: 'update', contactId: 'not-a-uuid', contactUpdateAdditionalFields: { role: 'Operations', }, }, ]); expect(result.error).toBeInstanceOf(NodeOperationError); expect(result.requests).toHaveLength(0); }); it('T032: contact delete rejects an invalid UUID contactId before making a request', async () => { const result = await executeNodeExpectFailure([ { resource: 'contact', contactOperation: 'delete', contactId: 'still-not-a-uuid', }, ]); expect(result.error).toBeInstanceOf(NodeOperationError); expect(result.requests).toHaveLength(0); }); it('T033: contact continue-on-fail returns item-level error objects while later items still execute', async () => { const { output } = await executeNode( [ { resource: 'contact', contactOperation: 'get', contactId: '00000000-0000-0000-0000-000000000109', }, { resource: 'contact', contactOperation: 'get', contactId: '00000000-0000-0000-0000-000000000110', }, ], (_options, index) => { if (index === 0) { throw createApiError(404, 'NOT_FOUND', 'Contact not found', { contactId: 'missing' }); } return { data: { contact_name_id: '00000000-0000-0000-0000-000000000110' } }; }, true, ); expect(output[0]).toHaveLength(2); expect(output[0][0].json).toEqual({ error: { code: 'NOT_FOUND', message: 'Contact not found', details: { contactId: 'missing' }, statusCode: 404, }, }); expect(output[0][1].json).toEqual({ contact_name_id: '00000000-0000-0000-0000-000000000110', }); }); it('T031: Continue On Fail emits item-level errors and continues remaining items', async () => { const { output } = await executeNode( [ { resource: 'ticket', ticketOperation: 'get', ticketId: '00000000-0000-0000-0000-000000000029', }, { resource: 'ticket', ticketOperation: 'get', ticketId: '00000000-0000-0000-0000-000000000030', }, ], (_options, index) => { if (index === 0) { throw createApiError(404, 'NOT_FOUND', 'Ticket not found', { ticketId: 'missing' }); } return { data: { ticket_id: '00000000-0000-0000-0000-000000000030' } }; }, true, ); expect(output[0]).toHaveLength(2); expect(output[0][0].json).toEqual({ error: { code: 'NOT_FOUND', message: 'Ticket not found', details: { ticketId: 'missing' }, statusCode: 404, }, }); expect(output[0][1].json).toEqual({ ticket_id: '00000000-0000-0000-0000-000000000030' }); }); it('T032: Client helper list maps to GET /api/v1/clients and returns list output', async () => { const { requests, output } = await executeNode( [ { resource: 'client', clientOperation: 'list', helperPage: 1, helperLimit: 25, helperSearch: 'acme', }, ], () => ({ data: [{ client_id: '1', client_name: 'Acme' }] }), ); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/clients'); expect(output[0][0].json).toEqual({ data: [{ client_id: '1', client_name: 'Acme' }] }); }); it('T033: Board helper list maps to GET /api/v1/boards and returns list output', async () => { const { requests, output } = await executeNode( [ { resource: 'board', boardOperation: 'list', helperPage: 1, helperLimit: 25, helperSearch: '', }, ], () => ({ data: [{ board_id: '1', board_name: 'Help Desk' }] }), ); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/boards'); expect(output[0][0].json).toEqual({ data: [{ board_id: '1', board_name: 'Help Desk' }] }); }); it('T034: Status helper list maps to GET /api/v1/statuses and returns list output', async () => { const { requests, output } = await executeNode( [ { resource: 'status', statusOperation: 'list', helperStatusType: 'project_task', helperPage: 1, helperLimit: 25, helperSearch: '', }, ], () => ({ data: [{ status_id: '1', name: 'New' }] }), ); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/statuses'); expect(requests[0]?.qs).toMatchObject({ page: 1, limit: 25, type: 'project_task', }); expect(requests[0]?.qs).not.toHaveProperty('board_id'); expect(output[0][0].json).toEqual({ data: [{ status_id: '1', name: 'New' }] }); }); it('T034b: Status helper list with ticket type requires board_id and passes it to the API', async () => { const { requests, output } = await executeNode( [ { resource: 'status', statusOperation: 'list', helperStatusType: 'ticket', helperBoardId: { mode: 'id', value: '00000000-0000-0000-0000-000000000200' }, helperPage: 1, helperLimit: 25, helperSearch: '', }, ], () => ({ data: [{ status_id: 'status-1', name: 'Open', board_id: '00000000-0000-0000-0000-000000000200' }] }), ); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/statuses'); expect(requests[0]?.qs).toMatchObject({ page: 1, limit: 25, type: 'ticket', board_id: '00000000-0000-0000-0000-000000000200', }); expect(output[0][0].json).toEqual({ data: [{ status_id: 'status-1', name: 'Open', board_id: '00000000-0000-0000-0000-000000000200' }], }); }); it('T034c: Status helper list with ticket type rejects missing board_id before the API call', async () => { const result = await executeNodeExpectFailure([ { resource: 'status', statusOperation: 'list', helperStatusType: 'ticket', helperBoardId: { mode: 'id', value: '' }, helperPage: 1, helperLimit: 25, helperSearch: '', }, ]); expect(result.error).toBeInstanceOf(NodeOperationError); expect(result.requests).toHaveLength(0); }); it('T035: Priority helper list maps to GET /api/v1/priorities and returns list output', async () => { const { requests, output } = await executeNode( [ { resource: 'priority', priorityOperation: 'list', helperPage: 1, helperLimit: 25, helperSearch: '', }, ], () => ({ data: [{ priority_id: '1', priority_name: 'High' }] }), ); expect(requests[0]?.url).toBe('https://api.algapsa.test/api/v1/priorities'); expect(output[0][0].json).toEqual({ data: [{ priority_id: '1', priority_name: 'High' }], }); }); it('T040: manual UUID fallback path still executes operations when lookup lists fail', async () => { const { requests } = await executeNode( [ { ...baseCreateParams, createAdditionalFields: {}, }, ], () => ({ data: { ticket_id: 'created' } }), ); expect(requests[0]?.body).toMatchObject({ client_id: '00000000-0000-0000-0000-000000000001', board_id: '00000000-0000-0000-0000-000000000002', status_id: '00000000-0000-0000-0000-000000000003', priority_id: '00000000-0000-0000-0000-000000000004', }); }); it('T042: validation blocks outbound calls for missing required IDs and search query', async () => { await expect( executeNode( [ { ...baseCreateParams, client_id: { mode: 'id', value: '' }, createAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); await expect( executeNode( [ { resource: 'ticket', ticketOperation: 'search', query: '', searchLimit: 25, searchAdditionalFields: {}, }, ], () => ({ data: [] }), ), ).rejects.toBeInstanceOf(NodeOperationError); }); const baseProjectTaskCreateParams = { resource: 'projectTask', projectTaskOperation: 'create', task_name: 'Draft proposal', projectTaskProjectId: { mode: 'id', value: '00000000-0000-0000-0000-000000000201' }, projectTaskPhaseId: { mode: 'id', value: '00000000-0000-0000-0000-000000000202' }, projectTaskStatusMappingId: { mode: 'id', value: '00000000-0000-0000-0000-000000000203' }, }; it('T080: project task create sends POST /api/v1/projects/{projectId}/phases/{phaseId}/tasks', async () => { const { requests } = await executeNode( [ { ...baseProjectTaskCreateParams, projectTaskCreateAdditionalFields: {}, }, ], () => ({ data: { task_id: '00000000-0000-0000-0000-000000000204' } }), ); expect(requests[0]?.method).toBe('POST'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/projects/00000000-0000-0000-0000-000000000201/phases/00000000-0000-0000-0000-000000000202/tasks', ); expect(requests[0]?.body).toEqual({ task_name: 'Draft proposal', project_status_mapping_id: '00000000-0000-0000-0000-000000000203', }); }); it('T081: project task create includes optional fields only when provided', async () => { const { requests } = await executeNode( [ { ...baseProjectTaskCreateParams, projectTaskCreateAdditionalFields: { description: 'Write RFC for new billing pipeline', assigned_to: '00000000-0000-0000-0000-000000000205', estimated_hours: 6, due_date: '2026-05-15', priority_id: '00000000-0000-0000-0000-000000000206', task_type_key: 'design', wbs_code: '2.1', tags: 'billing,design', }, }, ], () => ({ data: { task_id: '00000000-0000-0000-0000-000000000204' } }), ); expect(requests[0]?.body).toEqual({ task_name: 'Draft proposal', project_status_mapping_id: '00000000-0000-0000-0000-000000000203', description: 'Write RFC for new billing pipeline', assigned_to: '00000000-0000-0000-0000-000000000205', estimated_hours: 6, due_date: '2026-05-15', priority_id: '00000000-0000-0000-0000-000000000206', task_type_key: 'design', wbs_code: '2.1', tags: ['billing', 'design'], }); }); it('T082: project task create unwraps the created task object from the data wrapper', async () => { const { output } = await executeNode( [ { ...baseProjectTaskCreateParams, projectTaskCreateAdditionalFields: {}, }, ], () => ({ data: { task_id: '00000000-0000-0000-0000-000000000204', task_name: 'Draft proposal', }, }), ); expect(output[0][0].json).toEqual({ task_id: '00000000-0000-0000-0000-000000000204', task_name: 'Draft proposal', }); }); it('T083: project task create rejects missing project/phase/status mapping IDs before request', async () => { await expect( executeNode( [ { ...baseProjectTaskCreateParams, projectTaskProjectId: { mode: 'id', value: '' }, projectTaskCreateAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); await expect( executeNode( [ { ...baseProjectTaskCreateParams, projectTaskPhaseId: { mode: 'id', value: '' }, projectTaskCreateAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); await expect( executeNode( [ { ...baseProjectTaskCreateParams, projectTaskStatusMappingId: { mode: 'id', value: '' }, projectTaskCreateAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); }); it('T084: project task get sends GET /api/v1/projects/tasks/{taskId}', async () => { const { requests, output } = await executeNode( [ { resource: 'projectTask', projectTaskOperation: 'get', projectTaskId: '00000000-0000-0000-0000-000000000210', }, ], () => ({ data: { task_id: '00000000-0000-0000-0000-000000000210', task_name: 'Existing task', }, }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/projects/tasks/00000000-0000-0000-0000-000000000210', ); expect(output[0][0].json).toEqual({ task_id: '00000000-0000-0000-0000-000000000210', task_name: 'Existing task', }); }); it('T085: project task list sends GET /api/v1/projects/{projectId}/tasks with pagination', async () => { const { requests, output } = await executeNode( [ { resource: 'projectTask', projectTaskOperation: 'list', projectTaskProjectId: { mode: 'id', value: '00000000-0000-0000-0000-000000000220', }, projectTaskPage: 2, projectTaskLimit: 50, }, ], () => ({ data: [{ task_id: '00000000-0000-0000-0000-000000000221' }], pagination: { page: 2, total: 1 }, }), ); expect(requests[0]?.method).toBe('GET'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/projects/00000000-0000-0000-0000-000000000220/tasks', ); expect(requests[0]?.qs).toEqual({ page: 2, limit: 50 }); expect(output[0][0].json).toEqual({ data: [{ task_id: '00000000-0000-0000-0000-000000000221' }], pagination: { page: 2, total: 1 }, }); }); it('T086: project task update sends PUT with only provided fields', async () => { const { requests } = await executeNode( [ { resource: 'projectTask', projectTaskOperation: 'update', projectTaskId: '00000000-0000-0000-0000-000000000230', projectTaskUpdateAdditionalFields: { task_name: 'Renamed task', description: '', project_status_mapping_id: '00000000-0000-0000-0000-000000000231', }, }, ], () => ({ data: { task_id: '00000000-0000-0000-0000-000000000230' } }), ); expect(requests[0]?.method).toBe('PUT'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/projects/tasks/00000000-0000-0000-0000-000000000230', ); expect(requests[0]?.body).toEqual({ task_name: 'Renamed task', project_status_mapping_id: '00000000-0000-0000-0000-000000000231', }); }); it('T087: project task update rejects an empty update collection before request', async () => { await expect( executeNode( [ { resource: 'projectTask', projectTaskOperation: 'update', projectTaskId: '00000000-0000-0000-0000-000000000232', projectTaskUpdateAdditionalFields: {}, }, ], () => ({ data: {} }), ), ).rejects.toBeInstanceOf(NodeOperationError); }); it('T088: project task delete sends DELETE and returns a success envelope', async () => { const { requests, output } = await executeNode( [ { resource: 'projectTask', projectTaskOperation: 'delete', projectTaskId: '00000000-0000-0000-0000-000000000240', }, ], () => undefined, ); expect(requests[0]?.method).toBe('DELETE'); expect(requests[0]?.url).toBe( 'https://api.algapsa.test/api/v1/projects/tasks/00000000-0000-0000-0000-000000000240', ); expect(output[0][0].json).toEqual({ success: true, id: '00000000-0000-0000-0000-000000000240', deleted: true, }); }); it('T089: project task get/update/delete reject invalid UUIDs before request', async () => { const getResult = await executeNodeExpectFailure([ { resource: 'projectTask', projectTaskOperation: 'get', projectTaskId: 'not-a-uuid', }, ]); expect(getResult.error).toBeInstanceOf(NodeOperationError); expect(getResult.requests).toHaveLength(0); const deleteResult = await executeNodeExpectFailure([ { resource: 'projectTask', projectTaskOperation: 'delete', projectTaskId: '', }, ]); expect(deleteResult.error).toBeInstanceOf(NodeOperationError); }); it('T090: project task continue-on-fail emits item-level errors while later items still execute', async () => { const { output } = await executeNode( [ { resource: 'projectTask', projectTaskOperation: 'get', projectTaskId: '00000000-0000-0000-0000-000000000250', }, { resource: 'projectTask', projectTaskOperation: 'get', projectTaskId: '00000000-0000-0000-0000-000000000251', }, ], (_options, index) => { if (index === 0) { throw createApiError(404, 'NOT_FOUND', 'Task not found', { taskId: 'missing' }); } return { data: { task_id: '00000000-0000-0000-0000-000000000251' } }; }, true, ); expect(output[0]).toHaveLength(2); expect(output[0][0].json).toEqual({ error: { code: 'NOT_FOUND', message: 'Task not found', details: { taskId: 'missing' }, statusCode: 404, }, }); expect(output[0][1].json).toEqual({ task_id: '00000000-0000-0000-0000-000000000251', }); }); });