From 13ffb29b26c37d12ee0fe17c961190fb52301770 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 15:29:49 +0000 Subject: [PATCH] fix(link): send QR create options as multipart/form-data The Hyphen API's `POST .../link/codes/:code/qrs/` endpoint consumes `multipart/form-data` (the logo is an uploaded file, and each text field is validated as a multipart field). `createQrCode` was sending a JSON body, so any request that included custom options (title, backgroundColor, color, size) failed the multipart body-schema validation and was rejected with HTTP 400; only the no-options call slipped through. Build the request as `FormData`, appending only the options that were provided and uploading the logo as a file, and drop the JSON content-type header so fetch sets `multipart/form-data` with its generated boundary. Update the request-body unit test to assert on the multipart body, add a no-options case, and re-enable the custom-options integration test (resolves #133). https://claude.ai/code/session_011Cn9uaUZa7n8MsPgBP79Dd --- src/link.ts | 39 ++++++++++++++++++++------ test/link.test.ts | 71 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/link.ts b/src/link.ts index a923f20..2037d9e 100644 --- a/src/link.ts +++ b/src/link.ts @@ -493,16 +493,37 @@ export class Link extends BaseService { } const url = this.getUri(this._organizationId, code, "qrs"); - const headers = this.createHeaders(this._apiKey); - // biome-ignore lint/suspicious/noExplicitAny: this is valid for body - const body: Record = { - title: options?.title, - backgroundColor: options?.backgroundColor, - color: options?.color, - size: options?.size, - logo: options?.logo, - }; + // The Hyphen API expects this endpoint as `multipart/form-data` (the logo + // is uploaded as a file). Sending JSON makes the API reject the custom + // options with HTTP 400, so build a FormData body and only append the + // options that were provided. + const body = new FormData(); + if (options?.title !== undefined) { + body.append("title", options.title); + } + + if (options?.backgroundColor !== undefined) { + body.append("backgroundColor", options.backgroundColor); + } + + if (options?.color !== undefined) { + body.append("color", options.color); + } + + if (options?.size !== undefined) { + body.append("size", options.size); + } + + if (options?.logo !== undefined) { + const logoBytes = Buffer.from(options.logo, "base64"); + body.append("logo", new Blob([logoBytes]), "logo.png"); + } + + // Drop the JSON content-type so fetch sets `multipart/form-data` with the + // boundary it generates for the FormData body. + const headers = this.createHeaders(this._apiKey); + delete headers["content-type"]; const response = await this.post(url, body, { headers }); diff --git a/test/link.test.ts b/test/link.test.ts index aabaabc..d5db510 100644 --- a/test/link.test.ts +++ b/test/link.test.ts @@ -380,11 +380,11 @@ describe("Link QR Code", () => { testTimeout, ); - // TODO(#133): The Hyphen API now rejects the custom-options QR payload - // (title/backgroundColor/color/size) with HTTP 400; it accepted it through - // at least 2026-04-13. Skipped until the QR-create API contract is confirmed. + // Resolves #133: the QR-create endpoint expects multipart/form-data, so the + // custom options are now sent as form fields (and the logo as a file) rather + // than a JSON payload that the API rejected with HTTP 400. // https://github.com/Hyphen/nodejs-sdk/issues/133 - test.skip( + test( "should create a QR code with custom options", async () => { const link = new Link({ organizationId, apiKey }); @@ -422,10 +422,9 @@ describe("Link QR Code", () => { ); // Coverage for the custom-options path of createQrCode without hitting the - // live API (the integration test above is skipped pending #133). The network - // layer is stubbed so the options-defined branches of the request body are - // still exercised. - test("should build the request body when creating a QR code with options", async () => { + // live API. The network layer is stubbed so the multipart body that gets + // sent to the API is still exercised and asserted on. + test("should build a multipart request body when creating a QR code with options", async () => { const link = new Link({ organizationId, apiKey }); const qrCodeOptions: CreateQrCodeOptions = { title: "Custom QR Code", @@ -435,13 +434,16 @@ describe("Link QR Code", () => { logo: "bG9nbw==", }; - let capturedBody: Record | undefined; + let capturedBody: FormData | undefined; + let capturedHeaders: Record | undefined; // biome-ignore lint/suspicious/noExplicitAny: minimal stub of the post method (link as any).post = async ( _url: string, - data: Record, + data: FormData, + config: { headers: Record }, ) => { capturedBody = data; + capturedHeaders = config.headers; return { data: { id: "qr_test", @@ -458,18 +460,53 @@ describe("Link QR Code", () => { const response = await link.createQrCode("code_test", qrCodeOptions); - expect(capturedBody).toEqual({ - title: "Custom QR Code", - backgroundColor: "#ffffff", - color: "#000000", - size: "medium", - logo: "bG9nbw==", - }); + expect(capturedBody).toBeInstanceOf(FormData); + expect(capturedBody?.get("title")).toBe("Custom QR Code"); + expect(capturedBody?.get("backgroundColor")).toBe("#ffffff"); + expect(capturedBody?.get("color")).toBe("#000000"); + expect(capturedBody?.get("size")).toBe("medium"); + + const logo = capturedBody?.get("logo"); + expect(logo).toBeInstanceOf(Blob); + expect(await (logo as Blob).text()).toBe("logo"); + + // fetch must be left to set the multipart content-type (with boundary). + expect(capturedHeaders?.["content-type"]).toBeUndefined(); + expect(response.qrCode).toBeDefined(); expect(response.qrCodeBytes).toBeDefined(); expect(response.qrLink).toBe("https://hyphen.ai/qr"); }); + // Coverage for the no-options path: every option is omitted, so the + // multipart body should be empty. + test("should build an empty multipart request body when creating a QR code without options", async () => { + const link = new Link({ organizationId, apiKey }); + + let capturedBody: FormData | undefined; + // biome-ignore lint/suspicious/noExplicitAny: minimal stub of the post method + (link as any).post = async (_url: string, data: FormData) => { + capturedBody = data; + return { + data: { + id: "qr_test", + qrCode: Buffer.from("qr").toString("base64"), + qrLink: "https://hyphen.ai/qr", + }, + status: 201, + statusText: "Created", + headers: {}, + config: undefined, + request: undefined, + }; + }; + + await link.createQrCode("code_test"); + + expect(capturedBody).toBeInstanceOf(FormData); + expect([...(capturedBody as FormData).keys()]).toEqual([]); + }); + test("should throw on create QR code with invalid parameters", async () => { const link = new Link({ organizationId, apiKey }); link.organizationId = undefined; // Clear organization ID to force an error