From 1bd849840af0f73a816955f70f019c104d162c0e Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:07:05 -0400 Subject: [PATCH] fix(resources): accept bare object/array responses on create/update paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HaloPSA returns created/updated entities in three shapes depending on endpoint and version: enveloped ({ actions: [...] }), bare object, or bare array. All 41 create/update/me/addAction/addAttachment sites read response.[0] unguarded, throwing "Cannot read properties of undefined (reading '0')" on unwrapped responses — after the 2xx write had already landed, so callers that retried duplicated records. Route every write path through unwrapSingle (already used on GET paths) and extend it to also handle bare-array responses. Refs wyre-technology/halopsa-mcp#76 --- src/resources/actions.ts | 8 +-- src/resources/agents.ts | 12 ++-- src/resources/appointments.ts | 8 +-- src/resources/assets.ts | 8 +-- src/resources/clients.ts | 8 +-- src/resources/contacts.ts | 8 +-- src/resources/contracts.ts | 8 +-- src/resources/invoices.ts | 8 +-- src/resources/items.ts | 8 +-- src/resources/opportunities.ts | 8 +-- src/resources/projects.ts | 8 +-- src/resources/quotes.ts | 8 +-- src/resources/reference.ts | 24 ++++---- src/resources/sites.ts | 8 +-- src/resources/suppliers.ts | 8 +-- src/resources/teams.ts | 8 +-- src/resources/tickets.ts | 16 ++--- src/resources/utils.ts | 14 +++-- tests/unit/resources.test.ts | 109 +++++++++++++++++++++++++++++++++ tests/unit/utils.test.ts | 9 +++ 20 files changed, 209 insertions(+), 87 deletions(-) create mode 100644 tests/unit/resources.test.ts diff --git a/src/resources/actions.ts b/src/resources/actions.ts index 2dd8667..1e7ef92 100644 --- a/src/resources/actions.ts +++ b/src/resources/actions.ts @@ -61,11 +61,11 @@ export class ActionsResource { * Create a new action */ async create(data: ActionCreateData): Promise { - const response = await this.httpClient.request<{ actions: Action[] }>('/Actions', { + const response = await this.httpClient.request('/Actions', { method: 'POST', body: [data], }); - const action = response.actions[0]; + const action = unwrapSingle(response, 'actions'); if (!action) { throw new Error('Failed to create action'); } @@ -76,11 +76,11 @@ export class ActionsResource { * Update an existing action */ async update(id: number, data: ActionUpdateData): Promise { - const response = await this.httpClient.request<{ actions: Action[] }>('/Actions', { + const response = await this.httpClient.request('/Actions', { method: 'POST', body: [{ id, ...data }], }); - const action = response.actions[0]; + const action = unwrapSingle(response, 'actions'); if (!action) { throw new Error('Failed to update action'); } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 921663d..40b74ab 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -66,8 +66,8 @@ export class AgentsResource { * Get the current authenticated agent */ async me(): Promise { - const response = await this.httpClient.request<{ agents: Agent[] }>('/Agent/me'); - const agent = response.agents[0]; + const response = await this.httpClient.request('/Agent/me'); + const agent = unwrapSingle(response, 'agents'); if (!agent) { throw new Error('Failed to get current agent'); } @@ -78,11 +78,11 @@ export class AgentsResource { * Create a new agent */ async create(data: AgentCreateData): Promise { - const response = await this.httpClient.request<{ agents: Agent[] }>('/Agent', { + const response = await this.httpClient.request('/Agent', { method: 'POST', body: [data], }); - const agent = response.agents[0]; + const agent = unwrapSingle(response, 'agents'); if (!agent) { throw new Error('Failed to create agent'); } @@ -93,11 +93,11 @@ export class AgentsResource { * Update an existing agent */ async update(id: number, data: AgentUpdateData): Promise { - const response = await this.httpClient.request<{ agents: Agent[] }>('/Agent', { + const response = await this.httpClient.request('/Agent', { method: 'POST', body: [{ id, ...data }], }); - const agent = response.agents[0]; + const agent = unwrapSingle(response, 'agents'); if (!agent) { throw new Error('Failed to update agent'); } diff --git a/src/resources/appointments.ts b/src/resources/appointments.ts index 37e599b..0ff9197 100644 --- a/src/resources/appointments.ts +++ b/src/resources/appointments.ts @@ -66,11 +66,11 @@ export class AppointmentsResource { * Create a new appointment */ async create(data: AppointmentCreateData): Promise { - const response = await this.httpClient.request<{ appointments: Appointment[] }>('/Appointment', { + const response = await this.httpClient.request('/Appointment', { method: 'POST', body: [data], }); - const appointment = response.appointments[0]; + const appointment = unwrapSingle(response, 'appointments'); if (!appointment) { throw new Error('Failed to create appointment'); } @@ -81,11 +81,11 @@ export class AppointmentsResource { * Update an existing appointment */ async update(id: number, data: AppointmentUpdateData): Promise { - const response = await this.httpClient.request<{ appointments: Appointment[] }>('/Appointment', { + const response = await this.httpClient.request('/Appointment', { method: 'POST', body: [{ id, ...data }], }); - const appointment = response.appointments[0]; + const appointment = unwrapSingle(response, 'appointments'); if (!appointment) { throw new Error('Failed to update appointment'); } diff --git a/src/resources/assets.ts b/src/resources/assets.ts index 00794eb..238aff7 100644 --- a/src/resources/assets.ts +++ b/src/resources/assets.ts @@ -69,11 +69,11 @@ export class AssetsResource { * Create a new asset */ async create(data: AssetCreateData): Promise { - const response = await this.httpClient.request<{ assets: Asset[] }>('/Asset', { + const response = await this.httpClient.request('/Asset', { method: 'POST', body: [data], }); - const asset = response.assets[0]; + const asset = unwrapSingle(response, 'assets'); if (!asset) { throw new Error('Failed to create asset'); } @@ -84,11 +84,11 @@ export class AssetsResource { * Update an existing asset */ async update(id: number, data: AssetUpdateData): Promise { - const response = await this.httpClient.request<{ assets: Asset[] }>('/Asset', { + const response = await this.httpClient.request('/Asset', { method: 'POST', body: [{ id, ...data }], }); - const asset = response.assets[0]; + const asset = unwrapSingle(response, 'assets'); if (!asset) { throw new Error('Failed to update asset'); } diff --git a/src/resources/clients.ts b/src/resources/clients.ts index 9294286..108d9f3 100644 --- a/src/resources/clients.ts +++ b/src/resources/clients.ts @@ -66,11 +66,11 @@ export class ClientsResource { * Create a new client */ async create(data: ClientCreateData): Promise { - const response = await this.httpClient.request<{ clients: Client[] }>('/Client', { + const response = await this.httpClient.request('/Client', { method: 'POST', body: [data], }); - const client = response.clients[0]; + const client = unwrapSingle(response, 'clients'); if (!client) { throw new Error('Failed to create client'); } @@ -81,11 +81,11 @@ export class ClientsResource { * Update an existing client */ async update(id: number, data: ClientUpdateData): Promise { - const response = await this.httpClient.request<{ clients: Client[] }>('/Client', { + const response = await this.httpClient.request('/Client', { method: 'POST', body: [{ id, ...data }], }); - const client = response.clients[0]; + const client = unwrapSingle(response, 'clients'); if (!client) { throw new Error('Failed to update client'); } diff --git a/src/resources/contacts.ts b/src/resources/contacts.ts index 01a7000..90c6a14 100644 --- a/src/resources/contacts.ts +++ b/src/resources/contacts.ts @@ -66,11 +66,11 @@ export class ContactsResource { * Create a new contact */ async create(data: ContactCreateData): Promise { - const response = await this.httpClient.request<{ users: Contact[] }>('/Users', { + const response = await this.httpClient.request('/Users', { method: 'POST', body: [data], }); - const contact = response.users[0]; + const contact = unwrapSingle(response, 'users'); if (!contact) { throw new Error('Failed to create contact'); } @@ -81,11 +81,11 @@ export class ContactsResource { * Update an existing contact */ async update(id: number, data: ContactUpdateData): Promise { - const response = await this.httpClient.request<{ users: Contact[] }>('/Users', { + const response = await this.httpClient.request('/Users', { method: 'POST', body: [{ id, ...data }], }); - const contact = response.users[0]; + const contact = unwrapSingle(response, 'users'); if (!contact) { throw new Error('Failed to update contact'); } diff --git a/src/resources/contracts.ts b/src/resources/contracts.ts index 74a81c9..b101da3 100644 --- a/src/resources/contracts.ts +++ b/src/resources/contracts.ts @@ -66,11 +66,11 @@ export class ContractsResource { * Create a new contract */ async create(data: ContractCreateData): Promise { - const response = await this.httpClient.request<{ contracts: Contract[] }>('/ClientContract', { + const response = await this.httpClient.request('/ClientContract', { method: 'POST', body: [data], }); - const contract = response.contracts[0]; + const contract = unwrapSingle(response, 'contracts'); if (!contract) { throw new Error('Failed to create contract'); } @@ -81,11 +81,11 @@ export class ContractsResource { * Update an existing contract */ async update(id: number, data: ContractUpdateData): Promise { - const response = await this.httpClient.request<{ contracts: Contract[] }>('/ClientContract', { + const response = await this.httpClient.request('/ClientContract', { method: 'POST', body: [{ id, ...data }], }); - const contract = response.contracts[0]; + const contract = unwrapSingle(response, 'contracts'); if (!contract) { throw new Error('Failed to update contract'); } diff --git a/src/resources/invoices.ts b/src/resources/invoices.ts index 40a6876..f47df48 100644 --- a/src/resources/invoices.ts +++ b/src/resources/invoices.ts @@ -66,11 +66,11 @@ export class InvoicesResource { * Create a new invoice */ async create(data: InvoiceCreateData): Promise { - const response = await this.httpClient.request<{ invoices: Invoice[] }>('/Invoice', { + const response = await this.httpClient.request('/Invoice', { method: 'POST', body: [data], }); - const invoice = response.invoices[0]; + const invoice = unwrapSingle(response, 'invoices'); if (!invoice) { throw new Error('Failed to create invoice'); } @@ -81,11 +81,11 @@ export class InvoicesResource { * Update an existing invoice */ async update(id: number, data: InvoiceUpdateData): Promise { - const response = await this.httpClient.request<{ invoices: Invoice[] }>('/Invoice', { + const response = await this.httpClient.request('/Invoice', { method: 'POST', body: [{ id, ...data }], }); - const invoice = response.invoices[0]; + const invoice = unwrapSingle(response, 'invoices'); if (!invoice) { throw new Error('Failed to update invoice'); } diff --git a/src/resources/items.ts b/src/resources/items.ts index b47ec1b..84774ba 100644 --- a/src/resources/items.ts +++ b/src/resources/items.ts @@ -66,11 +66,11 @@ export class ItemsResource { * Create a new item */ async create(data: ItemCreateData): Promise { - const response = await this.httpClient.request<{ items: Item[] }>('/Item', { + const response = await this.httpClient.request('/Item', { method: 'POST', body: [data], }); - const item = response.items[0]; + const item = unwrapSingle(response, 'items'); if (!item) { throw new Error('Failed to create item'); } @@ -81,11 +81,11 @@ export class ItemsResource { * Update an existing item */ async update(id: number, data: ItemUpdateData): Promise { - const response = await this.httpClient.request<{ items: Item[] }>('/Item', { + const response = await this.httpClient.request('/Item', { method: 'POST', body: [{ id, ...data }], }); - const item = response.items[0]; + const item = unwrapSingle(response, 'items'); if (!item) { throw new Error('Failed to update item'); } diff --git a/src/resources/opportunities.ts b/src/resources/opportunities.ts index 499f367..d43925f 100644 --- a/src/resources/opportunities.ts +++ b/src/resources/opportunities.ts @@ -66,11 +66,11 @@ export class OpportunitiesResource { * Create a new opportunity */ async create(data: OpportunityCreateData): Promise { - const response = await this.httpClient.request<{ opportunities: Opportunity[] }>('/Opportunities', { + const response = await this.httpClient.request('/Opportunities', { method: 'POST', body: [data], }); - const opportunity = response.opportunities[0]; + const opportunity = unwrapSingle(response, 'opportunities'); if (!opportunity) { throw new Error('Failed to create opportunity'); } @@ -81,11 +81,11 @@ export class OpportunitiesResource { * Update an existing opportunity */ async update(id: number, data: OpportunityUpdateData): Promise { - const response = await this.httpClient.request<{ opportunities: Opportunity[] }>('/Opportunities', { + const response = await this.httpClient.request('/Opportunities', { method: 'POST', body: [{ id, ...data }], }); - const opportunity = response.opportunities[0]; + const opportunity = unwrapSingle(response, 'opportunities'); if (!opportunity) { throw new Error('Failed to update opportunity'); } diff --git a/src/resources/projects.ts b/src/resources/projects.ts index a4ec418..26ff515 100644 --- a/src/resources/projects.ts +++ b/src/resources/projects.ts @@ -68,11 +68,11 @@ export class ProjectsResource { * Create a new project */ async create(data: ProjectCreateData): Promise { - const response = await this.httpClient.request<{ projects: Project[] }>('/Projects', { + const response = await this.httpClient.request('/Projects', { method: 'POST', body: [data], }); - const project = response.projects[0]; + const project = unwrapSingle(response, 'projects'); if (!project) { throw new Error('Failed to create project'); } @@ -83,11 +83,11 @@ export class ProjectsResource { * Update an existing project */ async update(id: number, data: ProjectUpdateData): Promise { - const response = await this.httpClient.request<{ projects: Project[] }>('/Projects', { + const response = await this.httpClient.request('/Projects', { method: 'POST', body: [{ id, ...data }], }); - const project = response.projects[0]; + const project = unwrapSingle(response, 'projects'); if (!project) { throw new Error('Failed to update project'); } diff --git a/src/resources/quotes.ts b/src/resources/quotes.ts index 8ea914b..f49ca4e 100644 --- a/src/resources/quotes.ts +++ b/src/resources/quotes.ts @@ -67,11 +67,11 @@ export class QuotesResource { * Create a new quote */ async create(data: QuoteCreateData): Promise { - const response = await this.httpClient.request<{ quotations: Quote[] }>('/Quotation', { + const response = await this.httpClient.request('/Quotation', { method: 'POST', body: [data], }); - const quote = response.quotations[0]; + const quote = unwrapSingle(response, 'quotations'); if (!quote) { throw new Error('Failed to create quote'); } @@ -82,11 +82,11 @@ export class QuotesResource { * Update an existing quote */ async update(id: number, data: QuoteUpdateData): Promise { - const response = await this.httpClient.request<{ quotations: Quote[] }>('/Quotation', { + const response = await this.httpClient.request('/Quotation', { method: 'POST', body: [{ id, ...data }], }); - const quote = response.quotations[0]; + const quote = unwrapSingle(response, 'quotations'); if (!quote) { throw new Error('Failed to update quote'); } diff --git a/src/resources/reference.ts b/src/resources/reference.ts index 47ace27..7f413eb 100644 --- a/src/resources/reference.ts +++ b/src/resources/reference.ts @@ -224,21 +224,21 @@ export class KnowledgeBaseResource { } async create(data: KBArticleCreateData): Promise { - const response = await this.httpClient.request<{ articles: KBArticle[] }>('/KBArticle', { + const response = await this.httpClient.request('/KBArticle', { method: 'POST', body: [data], }); - const article = response.articles[0]; + const article = unwrapSingle(response, 'articles'); if (!article) throw new Error('Failed to create KB article'); return article; } async update(id: number, data: KBArticleUpdateData): Promise { - const response = await this.httpClient.request<{ articles: KBArticle[] }>('/KBArticle', { + const response = await this.httpClient.request('/KBArticle', { method: 'POST', body: [{ id, ...data }], }); - const article = response.articles[0]; + const article = unwrapSingle(response, 'articles'); if (!article) throw new Error('Failed to update KB article'); return article; } @@ -269,21 +269,21 @@ export class RecurringInvoicesResource { } async create(data: RecurringInvoiceCreateData): Promise { - const response = await this.httpClient.request<{ recurring_invoices: RecurringInvoice[] }>('/RecurringInvoice', { + const response = await this.httpClient.request('/RecurringInvoice', { method: 'POST', body: [data], }); - const invoice = response.recurring_invoices[0]; + const invoice = unwrapSingle(response, 'recurring_invoices'); if (!invoice) throw new Error('Failed to create recurring invoice'); return invoice; } async update(id: number, data: RecurringInvoiceUpdateData): Promise { - const response = await this.httpClient.request<{ recurring_invoices: RecurringInvoice[] }>('/RecurringInvoice', { + const response = await this.httpClient.request('/RecurringInvoice', { method: 'POST', body: [{ id, ...data }], }); - const invoice = response.recurring_invoices[0]; + const invoice = unwrapSingle(response, 'recurring_invoices'); if (!invoice) throw new Error('Failed to update recurring invoice'); return invoice; } @@ -342,21 +342,21 @@ export class SoftwareLicencesResource { } async create(data: SoftwareLicenceCreateData): Promise { - const response = await this.httpClient.request<{ software_licences: SoftwareLicence[] }>('/SoftwareLicence', { + const response = await this.httpClient.request('/SoftwareLicence', { method: 'POST', body: [data], }); - const licence = response.software_licences[0]; + const licence = unwrapSingle(response, 'software_licences'); if (!licence) throw new Error('Failed to create software licence'); return licence; } async update(id: number, data: SoftwareLicenceUpdateData): Promise { - const response = await this.httpClient.request<{ software_licences: SoftwareLicence[] }>('/SoftwareLicence', { + const response = await this.httpClient.request('/SoftwareLicence', { method: 'POST', body: [{ id, ...data }], }); - const licence = response.software_licences[0]; + const licence = unwrapSingle(response, 'software_licences'); if (!licence) throw new Error('Failed to update software licence'); return licence; } diff --git a/src/resources/sites.ts b/src/resources/sites.ts index d9f9599..b4eebba 100644 --- a/src/resources/sites.ts +++ b/src/resources/sites.ts @@ -66,11 +66,11 @@ export class SitesResource { * Create a new site */ async create(data: SiteCreateData): Promise { - const response = await this.httpClient.request<{ sites: Site[] }>('/Site', { + const response = await this.httpClient.request('/Site', { method: 'POST', body: [data], }); - const site = response.sites[0]; + const site = unwrapSingle(response, 'sites'); if (!site) { throw new Error('Failed to create site'); } @@ -81,11 +81,11 @@ export class SitesResource { * Update an existing site */ async update(id: number, data: SiteUpdateData): Promise { - const response = await this.httpClient.request<{ sites: Site[] }>('/Site', { + const response = await this.httpClient.request('/Site', { method: 'POST', body: [{ id, ...data }], }); - const site = response.sites[0]; + const site = unwrapSingle(response, 'sites'); if (!site) { throw new Error('Failed to update site'); } diff --git a/src/resources/suppliers.ts b/src/resources/suppliers.ts index 3f96ba3..272b38c 100644 --- a/src/resources/suppliers.ts +++ b/src/resources/suppliers.ts @@ -66,11 +66,11 @@ export class SuppliersResource { * Create a new supplier */ async create(data: SupplierCreateData): Promise { - const response = await this.httpClient.request<{ suppliers: Supplier[] }>('/Supplier', { + const response = await this.httpClient.request('/Supplier', { method: 'POST', body: [data], }); - const supplier = response.suppliers[0]; + const supplier = unwrapSingle(response, 'suppliers'); if (!supplier) { throw new Error('Failed to create supplier'); } @@ -81,11 +81,11 @@ export class SuppliersResource { * Update an existing supplier */ async update(id: number, data: SupplierUpdateData): Promise { - const response = await this.httpClient.request<{ suppliers: Supplier[] }>('/Supplier', { + const response = await this.httpClient.request('/Supplier', { method: 'POST', body: [{ id, ...data }], }); - const supplier = response.suppliers[0]; + const supplier = unwrapSingle(response, 'suppliers'); if (!supplier) { throw new Error('Failed to update supplier'); } diff --git a/src/resources/teams.ts b/src/resources/teams.ts index 98b30ec..e0e76a8 100644 --- a/src/resources/teams.ts +++ b/src/resources/teams.ts @@ -66,11 +66,11 @@ export class TeamsResource { * Create a new team */ async create(data: TeamCreateData): Promise { - const response = await this.httpClient.request<{ teams: Team[] }>('/Team', { + const response = await this.httpClient.request('/Team', { method: 'POST', body: [data], }); - const team = response.teams[0]; + const team = unwrapSingle(response, 'teams'); if (!team) { throw new Error('Failed to create team'); } @@ -81,11 +81,11 @@ export class TeamsResource { * Update an existing team */ async update(id: number, data: TeamUpdateData): Promise { - const response = await this.httpClient.request<{ teams: Team[] }>('/Team', { + const response = await this.httpClient.request('/Team', { method: 'POST', body: [{ id, ...data }], }); - const team = response.teams[0]; + const team = unwrapSingle(response, 'teams'); if (!team) { throw new Error('Failed to update team'); } diff --git a/src/resources/tickets.ts b/src/resources/tickets.ts index d9c6113..b5644d8 100644 --- a/src/resources/tickets.ts +++ b/src/resources/tickets.ts @@ -68,11 +68,11 @@ export class TicketsResource { * Create a new ticket */ async create(data: TicketCreateData): Promise { - const response = await this.httpClient.request<{ tickets: Ticket[] }>('/Tickets', { + const response = await this.httpClient.request('/Tickets', { method: 'POST', body: [data], }); - const ticket = response.tickets[0]; + const ticket = unwrapSingle(response, 'tickets'); if (!ticket) { throw new Error('Failed to create ticket'); } @@ -83,11 +83,11 @@ export class TicketsResource { * Update an existing ticket */ async update(id: number, data: TicketUpdateData): Promise { - const response = await this.httpClient.request<{ tickets: Ticket[] }>('/Tickets', { + const response = await this.httpClient.request('/Tickets', { method: 'POST', body: [{ id, ...data }], }); - const ticket = response.tickets[0]; + const ticket = unwrapSingle(response, 'tickets'); if (!ticket) { throw new Error('Failed to update ticket'); } @@ -116,11 +116,11 @@ export class TicketsResource { * Add an action to a ticket */ async addAction(id: number, data: ActionCreateData): Promise { - const response = await this.httpClient.request<{ actions: TicketAction[] }>('/Actions', { + const response = await this.httpClient.request('/Actions', { method: 'POST', body: [{ ticket_id: id, ...data }], }); - const action = response.actions[0]; + const action = unwrapSingle(response, 'actions'); if (!action) { throw new Error('Failed to create action'); } @@ -138,11 +138,11 @@ export class TicketsResource { * Add an attachment to a ticket */ async addAttachment(id: number, data: AttachmentCreateData): Promise { - const response = await this.httpClient.request<{ attachments: TicketAttachment[] }>(`/Tickets/${id}/Attachments`, { + const response = await this.httpClient.request(`/Tickets/${id}/Attachments`, { method: 'POST', body: [data], }); - const attachment = response.attachments[0]; + const attachment = unwrapSingle(response, 'attachments'); if (!attachment) { throw new Error('Failed to create attachment'); } diff --git a/src/resources/utils.ts b/src/resources/utils.ts index abcc607..cb40ccc 100644 --- a/src/resources/utils.ts +++ b/src/resources/utils.ts @@ -3,18 +3,22 @@ */ /** - * HaloPSA's `GET //{id}` endpoints sometimes return the entity bare - * (`{ id: 1, ... }`) and sometimes wrap it in a list-style envelope - * (`{ entities: [{...}] }`). The shape is endpoint- and version-dependent. - * This helper accepts either form and returns the entity (or undefined). + * HaloPSA's single-entity endpoints (`GET //{id}` and `POST /` + * create/update) return the entity bare (`{ id: 1, ... }`), wrapped in a + * list-style envelope (`{ entities: [{...}] }`), or as a bare array + * (`[{ id: 1, ... }]`). The shape is endpoint- and version-dependent. + * This helper accepts all three forms and returns the entity (or undefined). */ export function unwrapSingle( - response: T | Record | undefined | null, + response: T | T[] | Record | undefined | null, listKey: string ): T | undefined { if (!response || typeof response !== 'object') { return undefined; } + if (Array.isArray(response)) { + return response[0] as T | undefined; + } const wrapped = (response as Record)[listKey]; if (Array.isArray(wrapped)) { return wrapped[0] as T | undefined; diff --git a/tests/unit/resources.test.ts b/tests/unit/resources.test.ts new file mode 100644 index 0000000..979dec2 --- /dev/null +++ b/tests/unit/resources.test.ts @@ -0,0 +1,109 @@ +/** + * Resource write-path response shape tests. + * + * HaloPSA returns created/updated entities in three shapes depending on + * endpoint and version: enveloped ({ actions: [{...}] }), bare object + * ({ id: 1, ... }), or bare array ([{ id: 1, ... }]). Every create/update + * path must accept all three — see halopsa-mcp#76. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { HttpClient } from '../../src/http.js'; +import { ActionsResource } from '../../src/resources/actions.js'; +import { AgentsResource } from '../../src/resources/agents.js'; +import { AppointmentsResource } from '../../src/resources/appointments.js'; +import { AssetsResource } from '../../src/resources/assets.js'; +import { ClientsResource } from '../../src/resources/clients.js'; +import { ContactsResource } from '../../src/resources/contacts.js'; +import { ContractsResource } from '../../src/resources/contracts.js'; +import { InvoicesResource } from '../../src/resources/invoices.js'; +import { ItemsResource } from '../../src/resources/items.js'; +import { OpportunitiesResource } from '../../src/resources/opportunities.js'; +import { ProjectsResource } from '../../src/resources/projects.js'; +import { QuotesResource } from '../../src/resources/quotes.js'; +import { + KnowledgeBaseResource, + RecurringInvoicesResource, + SoftwareLicencesResource, +} from '../../src/resources/reference.js'; +import { SitesResource } from '../../src/resources/sites.js'; +import { SuppliersResource } from '../../src/resources/suppliers.js'; +import { TeamsResource } from '../../src/resources/teams.js'; +import { TicketsResource } from '../../src/resources/tickets.js'; + +const entity = { id: 42, name: 'test-entity' }; +const data = {} as never; + +const mockHttpClient = (response: unknown): HttpClient => + ({ request: vi.fn().mockResolvedValue(response) }) as unknown as HttpClient; + +interface WriteCase { + name: string; + listKey: string; + invoke: (httpClient: HttpClient) => Promise; +} + +const cases: WriteCase[] = [ + { name: 'actions.create', listKey: 'actions', invoke: (hc) => new ActionsResource(hc).create(data) }, + { name: 'actions.update', listKey: 'actions', invoke: (hc) => new ActionsResource(hc).update(1, data) }, + { name: 'agents.me', listKey: 'agents', invoke: (hc) => new AgentsResource(hc).me() }, + { name: 'agents.create', listKey: 'agents', invoke: (hc) => new AgentsResource(hc).create(data) }, + { name: 'agents.update', listKey: 'agents', invoke: (hc) => new AgentsResource(hc).update(1, data) }, + { name: 'appointments.create', listKey: 'appointments', invoke: (hc) => new AppointmentsResource(hc).create(data) }, + { name: 'appointments.update', listKey: 'appointments', invoke: (hc) => new AppointmentsResource(hc).update(1, data) }, + { name: 'assets.create', listKey: 'assets', invoke: (hc) => new AssetsResource(hc).create(data) }, + { name: 'assets.update', listKey: 'assets', invoke: (hc) => new AssetsResource(hc).update(1, data) }, + { name: 'clients.create', listKey: 'clients', invoke: (hc) => new ClientsResource(hc).create(data) }, + { name: 'clients.update', listKey: 'clients', invoke: (hc) => new ClientsResource(hc).update(1, data) }, + { name: 'contacts.create', listKey: 'users', invoke: (hc) => new ContactsResource(hc).create(data) }, + { name: 'contacts.update', listKey: 'users', invoke: (hc) => new ContactsResource(hc).update(1, data) }, + { name: 'contracts.create', listKey: 'contracts', invoke: (hc) => new ContractsResource(hc).create(data) }, + { name: 'contracts.update', listKey: 'contracts', invoke: (hc) => new ContractsResource(hc).update(1, data) }, + { name: 'invoices.create', listKey: 'invoices', invoke: (hc) => new InvoicesResource(hc).create(data) }, + { name: 'invoices.update', listKey: 'invoices', invoke: (hc) => new InvoicesResource(hc).update(1, data) }, + { name: 'items.create', listKey: 'items', invoke: (hc) => new ItemsResource(hc).create(data) }, + { name: 'items.update', listKey: 'items', invoke: (hc) => new ItemsResource(hc).update(1, data) }, + { name: 'opportunities.create', listKey: 'opportunities', invoke: (hc) => new OpportunitiesResource(hc).create(data) }, + { name: 'opportunities.update', listKey: 'opportunities', invoke: (hc) => new OpportunitiesResource(hc).update(1, data) }, + { name: 'projects.create', listKey: 'projects', invoke: (hc) => new ProjectsResource(hc).create(data) }, + { name: 'projects.update', listKey: 'projects', invoke: (hc) => new ProjectsResource(hc).update(1, data) }, + { name: 'quotes.create', listKey: 'quotations', invoke: (hc) => new QuotesResource(hc).create(data) }, + { name: 'quotes.update', listKey: 'quotations', invoke: (hc) => new QuotesResource(hc).update(1, data) }, + { name: 'knowledgeBase.create', listKey: 'articles', invoke: (hc) => new KnowledgeBaseResource(hc).create(data) }, + { name: 'knowledgeBase.update', listKey: 'articles', invoke: (hc) => new KnowledgeBaseResource(hc).update(1, data) }, + { name: 'recurringInvoices.create', listKey: 'recurring_invoices', invoke: (hc) => new RecurringInvoicesResource(hc).create(data) }, + { name: 'recurringInvoices.update', listKey: 'recurring_invoices', invoke: (hc) => new RecurringInvoicesResource(hc).update(1, data) }, + { name: 'softwareLicences.create', listKey: 'software_licences', invoke: (hc) => new SoftwareLicencesResource(hc).create(data) }, + { name: 'softwareLicences.update', listKey: 'software_licences', invoke: (hc) => new SoftwareLicencesResource(hc).update(1, data) }, + { name: 'sites.create', listKey: 'sites', invoke: (hc) => new SitesResource(hc).create(data) }, + { name: 'sites.update', listKey: 'sites', invoke: (hc) => new SitesResource(hc).update(1, data) }, + { name: 'suppliers.create', listKey: 'suppliers', invoke: (hc) => new SuppliersResource(hc).create(data) }, + { name: 'suppliers.update', listKey: 'suppliers', invoke: (hc) => new SuppliersResource(hc).update(1, data) }, + { name: 'teams.create', listKey: 'teams', invoke: (hc) => new TeamsResource(hc).create(data) }, + { name: 'teams.update', listKey: 'teams', invoke: (hc) => new TeamsResource(hc).update(1, data) }, + { name: 'tickets.create', listKey: 'tickets', invoke: (hc) => new TicketsResource(hc).create(data) }, + { name: 'tickets.update', listKey: 'tickets', invoke: (hc) => new TicketsResource(hc).update(1, data) }, + { name: 'tickets.addAction', listKey: 'actions', invoke: (hc) => new TicketsResource(hc).addAction(1, data) }, + { name: 'tickets.addAttachment', listKey: 'attachments', invoke: (hc) => new TicketsResource(hc).addAttachment(1, data) }, +]; + +describe.each(cases)('$name', ({ listKey, invoke }) => { + it('unwraps an enveloped response', async () => { + const result = await invoke(mockHttpClient({ [listKey]: [entity] })); + expect(result).toEqual(entity); + }); + + it('accepts a bare object response', async () => { + const result = await invoke(mockHttpClient(entity)); + expect(result).toEqual(entity); + }); + + it('accepts a bare array response', async () => { + const result = await invoke(mockHttpClient([entity])); + expect(result).toEqual(entity); + }); + + it('throws when the response contains no entity', async () => { + await expect(invoke(mockHttpClient({ [listKey]: [] }))).rejects.toThrow(); + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 3c64304..37bd33f 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -16,10 +16,19 @@ describe('unwrapSingle', () => { expect(unwrapSingle<{ id: number }>(bare, 'tickets')).toEqual(bare); }); + it('returns first element of bare array response', () => { + const bareArray = [{ id: 4, summary: 'bare array' }, { id: 5, summary: 'second' }]; + expect(unwrapSingle<{ id: number }>(bareArray, 'tickets')).toEqual({ id: 4, summary: 'bare array' }); + }); + it('returns undefined when wrapped list is empty', () => { expect(unwrapSingle({ tickets: [] }, 'tickets')).toBeUndefined(); }); + it('returns undefined when bare array is empty', () => { + expect(unwrapSingle([], 'tickets')).toBeUndefined(); + }); + it('returns undefined for null/undefined response', () => { expect(unwrapSingle(null, 'tickets')).toBeUndefined(); expect(unwrapSingle(undefined, 'tickets')).toBeUndefined();