Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,14 +495,40 @@ 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<string, any> = {
title: options?.title,
backgroundColor: options?.backgroundColor,
color: options?.color,
size: options?.size,
logo: options?.logo,
};
// The QR code endpoint consumes multipart/form-data, so each
// personalization option is sent as a form field (and the logo as a
// file upload) rather than as a JSON payload.
const form = new FormData();
if (options?.title !== undefined) {
form.append("title", options.title);
}
if (options?.backgroundColor !== undefined) {
form.append("backgroundColor", options.backgroundColor);
}
if (options?.color !== undefined) {
form.append("color", options.color);
}
if (options?.size !== undefined) {
form.append("size", options.size);
}
if (options?.logo !== undefined) {
// The logo is supplied as a base64-encoded string but the API expects
// a file upload, so decode it and append it as a file.
const logo = new Blob([Buffer.from(options.logo, "base64")]);
form.append("logo", logo, "logo");
}

// The API rejects an empty multipart body ("body must be object"), so
// when no options are provided fall back to an empty JSON payload, which
// yields a QR code with default personalization.
// biome-ignore lint/suspicious/noExplicitAny: body is either form data or JSON
let body: any = {};
if ([...form.keys()].length > 0) {
body = form;
// Remove the JSON content-type so the multipart boundary is set
// automatically by the underlying fetch call.
delete headers["content-type"];
}

const response = await this.post(url, body, { headers });

Expand Down
75 changes: 54 additions & 21 deletions test/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,11 +380,10 @@ 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.
// https://github.com/Hyphen/nodejs-sdk/issues/133
test.skip(
// The QR create endpoint consumes multipart/form-data, so the custom options
// (title/backgroundColor/color/size) are sent as form fields. This exercises
// that contract end-to-end against the live API. See issue #133.
test(
"should create a QR code with custom options",
async () => {
const link = new Link({ organizationId, apiKey });
Expand Down Expand Up @@ -422,10 +421,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 is built and
// the options-defined branches of the request are exercised.
test("should build a multipart body when creating a QR code with options", async () => {
const link = new Link({ organizationId, apiKey });
const qrCodeOptions: CreateQrCodeOptions = {
title: "Custom QR Code",
Expand All @@ -435,13 +433,12 @@ describe("Link QR Code", () => {
logo: "bG9nbw==",
};

let capturedBody: Record<string, unknown> | undefined;
let capturedBody: FormData | undefined;
let capturedHeaders: Record<string, string> | undefined;
// biome-ignore lint/suspicious/noExplicitAny: minimal stub of the post method
(link as any).post = async (
_url: string,
data: Record<string, unknown>,
) => {
(link as any).post = async (_url: string, data: FormData, config: any) => {
capturedBody = data;
capturedHeaders = config?.headers;
return {
data: {
id: "qr_test",
Expand All @@ -458,18 +455,54 @@ 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");
// The JSON content-type must be removed so the multipart boundary is set
// automatically by the underlying fetch call.
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: the API rejects an empty multipart body,
// so an empty JSON payload is sent instead (keeping the JSON content-type).
test("should send an empty JSON body when creating a QR code without options", async () => {
const link = new Link({ organizationId, apiKey });

let capturedBody: unknown;
let capturedHeaders: Record<string, string> | undefined;
// biome-ignore lint/suspicious/noExplicitAny: minimal stub of the post method
(link as any).post = async (_url: string, data: unknown, config: any) => {
capturedBody = data;
capturedHeaders = config?.headers;
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).toEqual({});
expect(capturedBody).not.toBeInstanceOf(FormData);
expect(capturedHeaders?.["content-type"]).toBe("application/json");
});

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
Expand Down
Loading