From 2131ed3288b48a42d0151b411a3ee9adccdd2613 Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Wed, 11 Mar 2026 14:40:17 +0200 Subject: [PATCH 1/8] feat: migrate ticket editing endpoint to V2 API Update TicketService.edit() from V1 (POST /v1/tickets/{id}/edit, form-encoded) to V2 (PATCH /tickets/{id}, JSON body with X-Qminder-API-Version header). Changes: - ApiBase: preserve PATCH method with body, handle empty response bodies - edit(): use PATCH + JSON body + version header, return void - TicketEditingParameters: remove extra/user, add languageCode, allow null --- .../lib/services/api-base/api-base.spec.ts | 49 ++++- .../src/lib/services/api-base/api-base.ts | 5 +- .../services/ticket/ticket.service.spec.ts | 185 +++++++----------- .../src/lib/services/ticket/ticket.service.ts | 10 +- .../src/lib/services/ticket/ticket.ts | 43 ++-- 5 files changed, 139 insertions(+), 153 deletions(-) diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts index a0edf77b..dc156b7e 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts @@ -48,6 +48,9 @@ const FAKE_RESPONSE = { json() { return {}; }, + text() { + return Promise.resolve('{}'); + }, }; /** @@ -63,11 +66,19 @@ const FAKE_RESPONSE = { * the object passed as parameter. */ class MockResponse { - constructor(private data: any) {} + ok: boolean; + + constructor(private data: any) { + this.ok = data.ok !== undefined ? data.ok : true; + } json() { return this.data; } + + text() { + return Promise.resolve(JSON.stringify(this.data)); + } } describe('ApiBase', () => { @@ -135,15 +146,15 @@ describe('ApiBase', () => { }); }); - it('calls the Response.json() function to resolve the JSON', (done) => { + it('parses the response text as JSON', (done) => { Qminder.setKey(API_KEY); - const jsonSpy = sinon.stub(FAKE_RESPONSE, 'json'); - jsonSpy.onCall(0).resolves({ message: 'Worked' }); + const textSpy = sinon.stub(FAKE_RESPONSE, 'text'); + textSpy.onCall(0).resolves(JSON.stringify({ message: 'Worked' })); Qminder.ApiBase.request('TEST').then((response) => { - expect(jsonSpy.called).toBe(true); + expect(textSpy.called).toBe(true); expect((response as any).message).toBe('Worked'); - jsonSpy.restore(); + textSpy.restore(); done(); }); }); @@ -272,6 +283,32 @@ describe('ApiBase', () => { ); }); + it('preserves PATCH method when body is present', (done) => { + Qminder.setKey(API_KEY); + + Qminder.ApiBase.request('TEST', { + method: 'PATCH', + body: JSON.stringify({ firstName: 'Jane' }), + }).then(() => { + expect(fetchSpy.mock.calls[0][1].method).toEqual('PATCH'); + done(); + }); + }); + + it('handles empty response body gracefully', async () => { + Qminder.setKey(API_KEY); + + fetchSpy.mockReturnValue({ + ok: true, + text() { + return Promise.resolve(''); + }, + }); + + const result = await Qminder.ApiBase.request('TEST'); + expect(result).toEqual({}); + }); + it('sends objects as www-form-urlencoded', (done) => { Qminder.setKey(API_KEY); const url = 'https://api.qminder.com/TEST'; diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.ts b/packages/javascript-api/src/lib/services/api-base/api-base.ts index 181b7239..cf39adcf 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.ts @@ -86,7 +86,7 @@ export class ApiBase { }; if (options?.body) { - if (options?.method !== 'PUT') { + if (!options?.method || options.method === 'GET') { init.method = 'POST'; } @@ -105,7 +105,8 @@ export class ApiBase { } const response = await fetch(`https://${this.apiServer}/${url}`, init); - const parsedResponse = await response.json(); + const text = await response.text(); + const parsedResponse = text ? JSON.parse(text) : {}; if (!response.ok) { throw this.extractError(parsedResponse); diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts index 47477b1c..4079c6ff 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts @@ -880,163 +880,124 @@ describe('Ticket service', function () { }; beforeEach(function () { - requestStub.onCall(0).resolves({ - result: 'success', - }); + requestStub.onCall(0).resolves({}); }); - it('calls the right URL when ticket is passed as ID', function (done) { - TicketService.edit(12345, editedFields).then((response) => { - console.log(requestStub.firstCall.args); - expect( - requestStub.calledWith('v1/tickets/12345/edit', { - body: editedFields, - }), - ).toBeTruthy(); - expect(response).toBe('success'); - done(); - }); + it('calls the right URL with PATCH when ticket is passed as ID', async function () { + await TicketService.edit(12345, editedFields); + expect( + requestStub.calledWith('tickets/12345', sinon.match({ + method: 'PATCH', + body: JSON.stringify(editedFields), + headers: { 'X-Qminder-API-Version': '2020-09-01' }, + })), + ).toBeTruthy(); }); - it('calls the right URL when ticket is passed as a Ticket object', function (done) { + it('calls the right URL when ticket is passed as a Ticket object', async function () { const ticket = { id: 12345 }; - TicketService.edit(ticket, editedFields).then((response) => { - console.log(requestStub.firstCall.args); - expect( - requestStub.calledWith('v1/tickets/12345/edit', { - body: editedFields, - }), - ).toBeTruthy(); - expect(response).toBe('success'); - done(); - }); + await TicketService.edit(ticket, editedFields); + expect( + requestStub.calledWith('tickets/12345', sinon.match({ + method: 'PATCH', + body: JSON.stringify(editedFields), + headers: { 'X-Qminder-API-Version': '2020-09-01' }, + })), + ).toBeTruthy(); }); - it('throws when ticket is missing', function () { - expect(function () { - (TicketService.edit as any)(undefined); - }).toThrow(); - expect(function () { - (TicketService.edit as any)(undefined, { lastName: 'wow' }); - }).toThrow(); + it('returns void on success', async function () { + const result = await TicketService.edit(12345, editedFields); + expect(result).toBeUndefined(); }); - it("throws when there's no changes", function () { - expect(function () { - TicketService.edit({ id: 12345 }, undefined as any); - }).toThrow(); + it('throws when ticket is missing', async function () { + await expect( + (TicketService.edit as any)(undefined), + ).rejects.toThrow(); + await expect( + (TicketService.edit as any)(undefined, { lastName: 'wow' }), + ).rejects.toThrow(); }); - it('throws when ticket is invalid', function () { - expect(() => (TicketService.edit as any)('wheeee')).toThrow(); + it("throws when there's no changes", async function () { + await expect( + TicketService.edit({ id: 12345 }, undefined as any), + ).rejects.toThrow(); }); - it('throws when ticket is a Ticket object but id is undefined', function () { - expect(() => (TicketService.edit as any)({} as any)).toThrow(); + it('throws when ticket is invalid', async function () { + await expect( + (TicketService.edit as any)('wheeee'), + ).rejects.toThrow(); }); - it('allows resetting first name to empty with empty string', function () { - TicketService.edit(12345, { firstName: '' } as any); - expect( - requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { firstName: '' } }), - ), - ).toBeTruthy(); + it('throws when ticket is a Ticket object but id is undefined', async function () { + await expect( + (TicketService.edit as any)({} as any), + ).rejects.toThrow(); }); - it('allows resetting last name to empty with empty string', function () { - TicketService.edit(12345, { lastName: '' } as any); + it('allows resetting first name to empty with empty string', async function () { + await TicketService.edit(12345, { firstName: '' }); expect( requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { lastName: '' } }), + 'tickets/12345', + sinon.match({ body: JSON.stringify({ firstName: '' }) }), ), ).toBeTruthy(); }); - it('allows resetting email to empty with empty string', function () { - TicketService.edit(12345, { email: '' } as any); + it('allows resetting last name to empty with empty string', async function () { + await TicketService.edit(12345, { lastName: '' }); expect( requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { email: '' } }), + 'tickets/12345', + sinon.match({ body: JSON.stringify({ lastName: '' }) }), ), ).toBeTruthy(); }); - it('allows resetting first name to empty with null', function () { - TicketService.edit(12345, { firstName: null } as any); + it('allows resetting email to empty with empty string', async function () { + await TicketService.edit(12345, { email: '' }); expect( requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { firstName: null } }), + 'tickets/12345', + sinon.match({ body: JSON.stringify({ email: '' }) }), ), ).toBeTruthy(); }); - it('allows resetting last name to empty with null', function () { - TicketService.edit(12345, { lastName: null } as any); - expect( - requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { lastName: null } }), - ), - ).toBeTruthy(); + it('preserves null values in JSON body to clear fields', async function () { + await TicketService.edit(12345, { firstName: null }); + const body = JSON.parse(requestStub.firstCall.args[1].body); + expect(body.firstName).toBeNull(); }); - it('allows resetting phone number to empty with null', function () { - TicketService.edit(12345, { phoneNumber: null } as any); - expect( - requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { phoneNumber: null } }), - ), - ).toBeTruthy(); + it('allows resetting last name with null', async function () { + await TicketService.edit(12345, { lastName: null }); + const body = JSON.parse(requestStub.firstCall.args[1].body); + expect(body.lastName).toBeNull(); }); - it('allows resetting email to empty with null', function () { - TicketService.edit(12345, { email: null } as any); - expect( - requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { email: null } }), - ), - ).toBeTruthy(); + it('allows resetting phone number with null', async function () { + await TicketService.edit(12345, { phoneNumber: null }); + const body = JSON.parse(requestStub.firstCall.args[1].body); + expect(body.phoneNumber).toBeNull(); }); - it('sends the User ID if provided', function () { - TicketService.edit(12345, { user: 14141, email: null } as any); - - expect( - requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ body: { email: null, user: '14141' } }), - ), - ).toBeTruthy(); + it('allows resetting email with null', async function () { + await TicketService.edit(12345, { email: null }); + const body = JSON.parse(requestStub.firstCall.args[1].body); + expect(body.email).toBeNull(); }); - it('Sends the extras as a JSON array', function () { - const changes: any = { - phoneNumber: 3185551234, - extra: [ - { - title: 'Favorite soup', - value: 'Borscht', - }, - ], - }; - - TicketService.edit(12345, changes); - console.log(requestStub.firstCall.args); + it('sends languageCode when provided', async function () { + await TicketService.edit(12345, { languageCode: 'et' }); expect( requestStub.calledWith( - 'v1/tickets/12345/edit', - sinon.match({ - body: { - extra: JSON.stringify(changes.extra), - }, - }), + 'tickets/12345', + sinon.match({ body: JSON.stringify({ languageCode: 'et' }) }), ), ).toBeTruthy(); }); diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.ts index a98161ca..2a7579f6 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.ts @@ -208,8 +208,11 @@ export const TicketService = { /** * Edits the ticket. * + * Calls the following HTTP API: `PATCH /tickets/` + * * To edit a ticket, pass the ticket ID to edit, and an object that only includes the keys - * that need to be changed. + * that need to be changed. Supported fields: `firstName`, `lastName`, `email`, `phoneNumber`, + * `line`, and `languageCode`. Set a field to `null` to clear it. * * ```javascript * import { Qminder } from 'qminder-api'; @@ -217,12 +220,11 @@ export const TicketService = { * // Edit a ticket's first name * const ticket = { id: 12345, firstName: "John", lastName: "Smith" }; * const changes = { firstName: "Jane" }; - * const successMessage = await Qminder.Ticket.edit(ticket, changes); - * console.log(successMessage === "success"); // true if it worked + * await Qminder.Ticket.edit(ticket, changes); * ``` * @param ticket the ticket to edit, either the Ticket object or the ticket's ID * @param changes an object only including changed properties of the ticket - * @returns a Promise that resolves to "success" when editing the ticket worked + * @returns a Promise that resolves when editing the ticket succeeded * @throws ERROR_NO_TICKET_ID when the ticket ID was undefined or not a number * @throws ERROR_NO_TICKET_CHANGES when the ticket changes were undefined */ diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.ts b/packages/javascript-api/src/lib/services/ticket/ticket.ts index ef4dbe75..097fd234 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.ts @@ -237,18 +237,11 @@ export const ERROR_NO_QUEUE_POSITION: string = export const ERROR_INVALID_DESK: string = 'Desk is not a number or Desk object.'; -export type TicketEditingParameters = Pick< - Ticket, - 'line' | 'phoneNumber' | 'firstName' | 'lastName' | 'email' | 'extra' -> & { user: IdOrObject }; +type NullablePartial = { [K in keyof T]?: T[K] | null }; -/** - * The format of the HTTP request to send when editing a ticket. - */ -interface TicketEditingRequest extends TicketCreationRequest { - line?: number; - user?: number; -} +export type TicketEditingParameters = NullablePartial< + Pick +> & { languageCode?: string | null }; interface TicketCallRequest { user?: number | string; @@ -350,33 +343,25 @@ export function details(ticket: IdOrObject): Promise { return ApiBase.request(`v1/tickets/${ticketId}`) as Promise; } -export function edit( +export async function edit( ticket: IdOrObject, changes: TicketEditingParameters, -): Promise<'success'> { +): Promise { const ticketId = extractId(ticket); if (!changes) { throw new Error(ERROR_NO_TICKET_CHANGES); } - const intermediate: any = { ...changes }; - - if (intermediate.extra) { - intermediate.extra = JSON.stringify(intermediate.extra); - } - - if (intermediate.user) { - intermediate.user = extractId(intermediate.user); - } else { - delete intermediate.user; - } - - const request: TicketEditingRequest = intermediate; + const body = JSON.stringify(changes); - return ApiBase.request(`v1/tickets/${ticketId}/edit`, { body: request }).then( - (response: { result: 'success' }) => response.result, - ); + await ApiBase.request(`tickets/${ticketId}`, { + method: 'PATCH', + body, + headers: { + 'X-Qminder-API-Version': '2020-09-01', + }, + }); } export function call( From 37a1b15e849f41238971753ed950eec5b5fbf66d Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Wed, 11 Mar 2026 14:46:58 +0200 Subject: [PATCH 2/8] style: fix prettier formatting in ticket service tests --- .../services/ticket/ticket.service.spec.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts index 4079c6ff..8a81f217 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts @@ -886,11 +886,14 @@ describe('Ticket service', function () { it('calls the right URL with PATCH when ticket is passed as ID', async function () { await TicketService.edit(12345, editedFields); expect( - requestStub.calledWith('tickets/12345', sinon.match({ - method: 'PATCH', - body: JSON.stringify(editedFields), - headers: { 'X-Qminder-API-Version': '2020-09-01' }, - })), + requestStub.calledWith( + 'tickets/12345', + sinon.match({ + method: 'PATCH', + body: JSON.stringify(editedFields), + headers: { 'X-Qminder-API-Version': '2020-09-01' }, + }), + ), ).toBeTruthy(); }); @@ -898,11 +901,14 @@ describe('Ticket service', function () { const ticket = { id: 12345 }; await TicketService.edit(ticket, editedFields); expect( - requestStub.calledWith('tickets/12345', sinon.match({ - method: 'PATCH', - body: JSON.stringify(editedFields), - headers: { 'X-Qminder-API-Version': '2020-09-01' }, - })), + requestStub.calledWith( + 'tickets/12345', + sinon.match({ + method: 'PATCH', + body: JSON.stringify(editedFields), + headers: { 'X-Qminder-API-Version': '2020-09-01' }, + }), + ), ).toBeTruthy(); }); @@ -912,9 +918,7 @@ describe('Ticket service', function () { }); it('throws when ticket is missing', async function () { - await expect( - (TicketService.edit as any)(undefined), - ).rejects.toThrow(); + await expect((TicketService.edit as any)(undefined)).rejects.toThrow(); await expect( (TicketService.edit as any)(undefined, { lastName: 'wow' }), ).rejects.toThrow(); @@ -927,15 +931,11 @@ describe('Ticket service', function () { }); it('throws when ticket is invalid', async function () { - await expect( - (TicketService.edit as any)('wheeee'), - ).rejects.toThrow(); + await expect((TicketService.edit as any)('wheeee')).rejects.toThrow(); }); it('throws when ticket is a Ticket object but id is undefined', async function () { - await expect( - (TicketService.edit as any)({} as any), - ).rejects.toThrow(); + await expect((TicketService.edit as any)({} as any)).rejects.toThrow(); }); it('allows resetting first name to empty with empty string', async function () { From a91d3dd760cc58e0910b21aba31a68481b93dbbd Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Thu, 12 Mar 2026 10:41:05 +0200 Subject: [PATCH 3/8] fix: improve error handling in api-base and ticket edit validation Add descriptive error message when response body is not valid JSON instead of exposing a raw SyntaxError. Reject empty changes object in ticket edit to prevent sending a no-op PATCH request. --- .../src/lib/services/api-base/api-base.spec.ts | 15 +++++++++++++++ .../src/lib/services/api-base/api-base.ts | 7 ++++++- .../lib/services/ticket/ticket.service.spec.ts | 6 ++++++ .../src/lib/services/ticket/ticket.ts | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts index dc156b7e..b0de8e11 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts @@ -309,6 +309,21 @@ describe('ApiBase', () => { expect(result).toEqual({}); }); + it('throws a descriptive error for invalid JSON response', async () => { + Qminder.setKey(API_KEY); + + fetchSpy.mockReturnValue({ + ok: true, + text() { + return Promise.resolve('not valid json'); + }, + }); + + await expect(Qminder.ApiBase.request('TEST')).rejects.toThrow( + 'Failed to parse response body as JSON', + ); + }); + it('sends objects as www-form-urlencoded', (done) => { Qminder.setKey(API_KEY); const url = 'https://api.qminder.com/TEST'; diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.ts b/packages/javascript-api/src/lib/services/api-base/api-base.ts index cf39adcf..511400bb 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.ts @@ -106,7 +106,12 @@ export class ApiBase { const response = await fetch(`https://${this.apiServer}/${url}`, init); const text = await response.text(); - const parsedResponse = text ? JSON.parse(text) : {}; + let parsedResponse: any; + try { + parsedResponse = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Failed to parse response body as JSON: ${text.slice(0, 200)}`); + } if (!response.ok) { throw this.extractError(parsedResponse); diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts index 8a81f217..e3ecbed5 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts @@ -930,6 +930,12 @@ describe('Ticket service', function () { ).rejects.toThrow(); }); + it('throws when changes is an empty object', async function () { + await expect( + TicketService.edit(12345, {} as any), + ).rejects.toThrow(); + }); + it('throws when ticket is invalid', async function () { await expect((TicketService.edit as any)('wheeee')).rejects.toThrow(); }); diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.ts b/packages/javascript-api/src/lib/services/ticket/ticket.ts index 097fd234..dc56ce15 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.ts @@ -349,7 +349,7 @@ export async function edit( ): Promise { const ticketId = extractId(ticket); - if (!changes) { + if (!changes || Object.keys(changes).length === 0) { throw new Error(ERROR_NO_TICKET_CHANGES); } From 72ebb6fd988444e72295fefb21a6afde0aac631e Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Thu, 12 Mar 2026 10:46:38 +0200 Subject: [PATCH 4/8] style: fix prettier formatting --- packages/javascript-api/src/lib/services/api-base/api-base.ts | 4 +++- .../src/lib/services/ticket/ticket.service.spec.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.ts b/packages/javascript-api/src/lib/services/api-base/api-base.ts index 511400bb..ced609a4 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.ts @@ -110,7 +110,9 @@ export class ApiBase { try { parsedResponse = text ? JSON.parse(text) : {}; } catch { - throw new Error(`Failed to parse response body as JSON: ${text.slice(0, 200)}`); + throw new Error( + `Failed to parse response body as JSON: ${text.slice(0, 200)}`, + ); } if (!response.ok) { diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts index e3ecbed5..de742d5f 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts @@ -931,9 +931,7 @@ describe('Ticket service', function () { }); it('throws when changes is an empty object', async function () { - await expect( - TicketService.edit(12345, {} as any), - ).rejects.toThrow(); + await expect(TicketService.edit(12345, {} as any)).rejects.toThrow(); }); it('throws when ticket is invalid', async function () { From 0a4a089cbd5ea6fa8e4426ea1ff537064ad268d9 Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Fri, 13 Mar 2026 12:28:20 +0200 Subject: [PATCH 5/8] fix: address PR review feedback for ticket edit V2 migration - Replace `any` with generic type `T` for parsedResponse in api-base - Remove unnecessary truthiness check on response.text() result - Remove response body truncation in JSON parse error message - Make languageCode non-nullable in TicketEditingParameters - Update test descriptions for clarity --- .../src/lib/services/api-base/api-base.spec.ts | 7 ++++--- .../javascript-api/src/lib/services/api-base/api-base.ts | 6 +++--- .../src/lib/services/ticket/ticket.service.spec.ts | 2 +- .../src/lib/services/ticket/ticket.service.ts | 2 +- packages/javascript-api/src/lib/services/ticket/ticket.ts | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts index b0de8e11..2e5346e9 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts @@ -295,7 +295,7 @@ describe('ApiBase', () => { }); }); - it('handles empty response body gracefully', async () => { + it('throws an error for empty response body', async () => { Qminder.setKey(API_KEY); fetchSpy.mockReturnValue({ @@ -305,8 +305,9 @@ describe('ApiBase', () => { }, }); - const result = await Qminder.ApiBase.request('TEST'); - expect(result).toEqual({}); + await expect(Qminder.ApiBase.request('TEST')).rejects.toThrow( + 'Failed to parse response body as JSON', + ); }); it('throws a descriptive error for invalid JSON response', async () => { diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.ts b/packages/javascript-api/src/lib/services/api-base/api-base.ts index ced609a4..a962dc49 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.ts @@ -106,12 +106,12 @@ export class ApiBase { const response = await fetch(`https://${this.apiServer}/${url}`, init); const text = await response.text(); - let parsedResponse: any; + let parsedResponse: T; try { - parsedResponse = text ? JSON.parse(text) : {}; + parsedResponse = JSON.parse(text); } catch { throw new Error( - `Failed to parse response body as JSON: ${text.slice(0, 200)}`, + `Failed to parse response body as JSON: ${text}`, ); } diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts index de742d5f..02d0a079 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts @@ -924,7 +924,7 @@ describe('Ticket service', function () { ).rejects.toThrow(); }); - it("throws when there's no changes", async function () { + it('throws when the changes argument is missing', async function () { await expect( TicketService.edit({ id: 12345 }, undefined as any), ).rejects.toThrow(); diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.ts index 2a7579f6..93c90cd7 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.ts @@ -212,7 +212,7 @@ export const TicketService = { * * To edit a ticket, pass the ticket ID to edit, and an object that only includes the keys * that need to be changed. Supported fields: `firstName`, `lastName`, `email`, `phoneNumber`, - * `line`, and `languageCode`. Set a field to `null` to clear it. + * `line`, and `languageCode`. Set a field to `null` to clear it (except `languageCode`, which is required). * * ```javascript * import { Qminder } from 'qminder-api'; diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.ts b/packages/javascript-api/src/lib/services/ticket/ticket.ts index dc56ce15..56b37834 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.ts @@ -241,7 +241,7 @@ type NullablePartial = { [K in keyof T]?: T[K] | null }; export type TicketEditingParameters = NullablePartial< Pick -> & { languageCode?: string | null }; +> & { languageCode?: string }; interface TicketCallRequest { user?: number | string; From d9d7dd53ad026c0f8166aa04252e11411c3d7719 Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Fri, 13 Mar 2026 12:43:28 +0200 Subject: [PATCH 6/8] fix: handle empty response body for void endpoints and address remaining review feedback - Restore empty body handling for successful responses (V2 PATCH returns empty body) - Remove redundant optional chaining inside options?.body guard - Replace any with TicketEditingParameters in test fixture - Revert languageCode JSDoc parenthetical per reviewer request --- .../src/lib/services/api-base/api-base.spec.ts | 7 +++---- .../javascript-api/src/lib/services/api-base/api-base.ts | 7 ++++++- .../src/lib/services/ticket/ticket.service.spec.ts | 3 ++- .../src/lib/services/ticket/ticket.service.ts | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts index 2e5346e9..5f3fe947 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.spec.ts @@ -295,7 +295,7 @@ describe('ApiBase', () => { }); }); - it('throws an error for empty response body', async () => { + it('handles empty response body gracefully for successful responses', async () => { Qminder.setKey(API_KEY); fetchSpy.mockReturnValue({ @@ -305,9 +305,8 @@ describe('ApiBase', () => { }, }); - await expect(Qminder.ApiBase.request('TEST')).rejects.toThrow( - 'Failed to parse response body as JSON', - ); + const result = await Qminder.ApiBase.request('TEST'); + expect(result).toEqual({}); }); it('throws a descriptive error for invalid JSON response', async () => { diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.ts b/packages/javascript-api/src/lib/services/api-base/api-base.ts index a962dc49..022c80c1 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.ts @@ -86,7 +86,7 @@ export class ApiBase { }; if (options?.body) { - if (!options?.method || options.method === 'GET') { + if (!options.method) { init.method = 'POST'; } @@ -106,6 +106,11 @@ export class ApiBase { const response = await fetch(`https://${this.apiServer}/${url}`, init); const text = await response.text(); + + if (!text && response.ok) { + return {} as T; + } + let parsedResponse: T; try { parsedResponse = JSON.parse(text); diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts index 02d0a079..a2b52790 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.spec.ts @@ -4,6 +4,7 @@ import { TicketCreatedResponse } from '../../model/ticket/ticket-created-respons import { TicketCreationRequest } from '../../model/ticket/ticket-creation-request'; import { Qminder } from '../../qminder'; import { TicketService } from './ticket.service'; +import { TicketEditingParameters } from './ticket'; import { ResponseValidationError } from '../../model/errors/response-validation-error'; describe('Ticket service', function () { @@ -872,7 +873,7 @@ describe('Ticket service', function () { }); describe('edit()', function () { - const editedFields: any = { + const editedFields: TicketEditingParameters = { line: 11111, firstName: 'Johnny', lastName: 'Smithicus', diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.service.ts b/packages/javascript-api/src/lib/services/ticket/ticket.service.ts index 93c90cd7..2a7579f6 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.service.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.service.ts @@ -212,7 +212,7 @@ export const TicketService = { * * To edit a ticket, pass the ticket ID to edit, and an object that only includes the keys * that need to be changed. Supported fields: `firstName`, `lastName`, `email`, `phoneNumber`, - * `line`, and `languageCode`. Set a field to `null` to clear it (except `languageCode`, which is required). + * `line`, and `languageCode`. Set a field to `null` to clear it. * * ```javascript * import { Qminder } from 'qminder-api'; From 377c901768aa14a889f78a17dcdb26e795e41464 Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Fri, 13 Mar 2026 13:11:45 +0200 Subject: [PATCH 7/8] refactor: use Partial> instead of custom NullablePartial type --- packages/javascript-api/src/lib/services/ticket/ticket.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.ts b/packages/javascript-api/src/lib/services/ticket/ticket.ts index 56b37834..354b154c 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.ts @@ -237,10 +237,10 @@ export const ERROR_NO_QUEUE_POSITION: string = export const ERROR_INVALID_DESK: string = 'Desk is not a number or Desk object.'; -type NullablePartial = { [K in keyof T]?: T[K] | null }; +type Nullable = { [K in keyof T]: T[K] | null }; -export type TicketEditingParameters = NullablePartial< - Pick +export type TicketEditingParameters = Partial< + Nullable> > & { languageCode?: string }; interface TicketCallRequest { From 2f146a8ef41a1c3086ed1a9fe70a642ad8ca85cd Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Fri, 13 Mar 2026 13:16:55 +0200 Subject: [PATCH 8/8] style: fix prettier formatting --- packages/javascript-api/src/lib/services/api-base/api-base.ts | 4 +--- packages/javascript-api/src/lib/services/ticket/ticket.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/javascript-api/src/lib/services/api-base/api-base.ts b/packages/javascript-api/src/lib/services/api-base/api-base.ts index 022c80c1..7a035aec 100644 --- a/packages/javascript-api/src/lib/services/api-base/api-base.ts +++ b/packages/javascript-api/src/lib/services/api-base/api-base.ts @@ -115,9 +115,7 @@ export class ApiBase { try { parsedResponse = JSON.parse(text); } catch { - throw new Error( - `Failed to parse response body as JSON: ${text}`, - ); + throw new Error(`Failed to parse response body as JSON: ${text}`); } if (!response.ok) { diff --git a/packages/javascript-api/src/lib/services/ticket/ticket.ts b/packages/javascript-api/src/lib/services/ticket/ticket.ts index 354b154c..ce259abe 100644 --- a/packages/javascript-api/src/lib/services/ticket/ticket.ts +++ b/packages/javascript-api/src/lib/services/ticket/ticket.ts @@ -240,7 +240,9 @@ export const ERROR_INVALID_DESK: string = type Nullable = { [K in keyof T]: T[K] | null }; export type TicketEditingParameters = Partial< - Nullable> + Nullable< + Pick + > > & { languageCode?: string }; interface TicketCallRequest {