From f6c2eee434e6f17f92593e3162a080ae642a8f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 17:40:42 +0000 Subject: [PATCH] fix: eliminate flaky 404s in link/QR live tests and fix qrCodeBytes type The link integration tests intermittently failed with 'Fetch failed with status 404'. Audit findings and fixes: - The stats test listed the organization's short codes (newest first) and picked a random one. Concurrent CI runs (Node version matrix plus the coverage workflow) share the organization and delete their temporary codes within seconds, so the picked code often vanished before getCodeStats resolved. The test now creates and uses its own code. - Operations that run immediately after a create can transiently 404 while the API is eventually consistent. All live calls now go through a retry helper with backoff (replacing the one-off 500ms sleep), and list assertions poll until the created items are visible. - Cleanup deletes were asserted, so a transient cleanup failure failed tests whose subject had already passed. Cleanup is now best-effort with a warning; dedicated delete tests assert the delete paths explicitly. Also fixes qrCodeBytes to be Uint8Array: new Uint16Array(buffer) widened each byte to 16 bits, which corrupts the QR image when the bytes are written out. https://claude.ai/code/session_01Jw4fk5in8dEyFKCs3pLY9g --- src/link.ts | 8 +- test/link.test.ts | 288 +++++++++++++++++++++++++++++----------------- 2 files changed, 189 insertions(+), 107 deletions(-) diff --git a/src/link.ts b/src/link.ts index d891e51..5fd97fd 100644 --- a/src/link.ts +++ b/src/link.ts @@ -109,7 +109,7 @@ export type CreateQrCodeResponse = { id: string; title?: string; qrCode: string; - qrCodeBytes: Uint16Array; + qrCodeBytes: Uint8Array; qrLink: string; }; @@ -538,7 +538,7 @@ export class Link extends BaseService { if (result.qrCode) { const buffer = Buffer.from(result.qrCode, "base64"); - result.qrCodeBytes = new Uint16Array(buffer); + result.qrCodeBytes = new Uint8Array(buffer); } return result; @@ -573,7 +573,7 @@ export class Link extends BaseService { if (result.qrCode) { const buffer = Buffer.from(result.qrCode, "base64"); - result.qrCodeBytes = new Uint16Array(buffer); + result.qrCodeBytes = new Uint8Array(buffer); } return result; @@ -612,7 +612,7 @@ export class Link extends BaseService { for (const qrCode of result.data) { if (qrCode.qrCode) { const buffer = Buffer.from(qrCode.qrCode, "base64"); - qrCode.qrCodeBytes = new Uint16Array(buffer); + qrCode.qrCodeBytes = new Uint8Array(buffer); } } diff --git a/test/link.test.ts b/test/link.test.ts index a7d4597..da91146 100644 --- a/test/link.test.ts +++ b/test/link.test.ts @@ -15,13 +15,52 @@ const longUrl = [ "https://hyphen.ai/toggle", "https://hyphen.ai/net-info", ]; -const testTimeout = 20_000; +const testTimeout = 30_000; // biome-ignore lint/suspicious/noExportsInTest: this is used across tests export function getRandomLongUrl(): string { return longUrl[Math.floor(Math.random() * longUrl.length)]; } +/** + * Retry a live API call with a backoff. The link API is eventually + * consistent, so operations that run immediately after a write (creating a + * QR code or reading, updating, or deleting a code right after it was + * created) can transiently fail with a 404 until the write is visible to the + * read path. Wrapping those calls keeps transient failures from flaking the + * suite. Callers can also throw from `fn` when a result is incomplete (for + * example a list that does not include a just-created item yet) to poll + * until it is. + */ +async function retry(fn: () => Promise, attempts = 5): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); + } + } + } + + throw lastError; +} + +/** + * Best-effort removal of a short code created by a test. Cleanup is not the + * behavior under test (the delete tests assert deletion explicitly), so a + * cleanup failure logs a warning instead of failing the test. + */ +async function cleanupShortCode(link: Link, id: string): Promise { + try { + await retry(() => link.deleteShortCode(id)); + } catch { + console.warn(`Cleanup failed for ${id}`); + } +} + describe("Link", () => { test("should create a Link instance with default URIs", () => { const link = new Link({ organizationId, apiKey }); @@ -103,22 +142,15 @@ describe("Link Create", () => { const title = faker.string.alpha(10); const options = { tags, title }; - const response = await link.createShortCode(longUrl, domain, options); + const response = await retry(() => + link.createShortCode(longUrl, domain, options), + ); expect(response).toBeDefined(); expect(response.id).toBeDefined(); if (response.id) { - // Small delay for eventual consistency - await new Promise((r) => setTimeout(r, 500)); - - try { - const deleteResponse = await link.deleteShortCode(response.id); - expect(deleteResponse).toBe(true); - } catch { - // Cleanup failure shouldn't fail the test - console.warn(`Cleanup failed for ${response.id}`); - } + await cleanupShortCode(link, response.id); } }, testTimeout, @@ -148,10 +180,8 @@ describe("Link Get", () => { const title = faker.string.alpha(10) as string; const options = { tags, title }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); @@ -159,7 +189,16 @@ describe("Link Get", () => { expect(createResponse.title).toBe(title); expect(createResponse.tags).toEqual(tags); - const response = await link.getShortCodes(title, tags); + // Poll until the just-created code is visible in the list, since the + // list can lag behind the create. + const response = await retry(async () => { + const result = await link.getShortCodes(title, tags); + if (result.total === 0) { + throw new Error(`Short code "${title}" is not visible in the list`); + } + + return result; + }); expect(response).toBeDefined(); expect(response.total).toBeGreaterThan(0); @@ -168,13 +207,24 @@ describe("Link Get", () => { // Delete the created short code if (createResponse.id) { - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, ); + test( + "should get short codes without a title or tag filter", + async () => { + const link = new Link({ organizationId, apiKey }); + const response = await retry(() => link.getShortCodes("", [], 1, 10)); + expect(response).toBeDefined(); + expect(response.pageNum).toBe(1); + expect(response.pageSize).toBe(10); + }, + testTimeout, + ); + test("should throw on get short codes with invalid parameters", async () => { const link = new Link({ organizationId, apiKey }); link.organizationId = undefined; // Clear organization ID to force an error @@ -188,10 +238,8 @@ describe("Link Get", () => { const longUrl = getRandomLongUrl(); const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); @@ -202,13 +250,14 @@ describe("Link Get", () => { // Retrieve the short code by ID if (createResponse.id) { - const getResponse = await link.getShortCode(createResponse.id); + const getResponse = await retry(() => + link.getShortCode(createResponse.id), + ); expect(getResponse).toEqual(createResponse); } if (createResponse.id) { - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -224,6 +273,31 @@ describe("Link Get", () => { }); describe("Link Delete", () => { + test( + "should create and delete a short code", + async () => { + const link = new Link({ organizationId, apiKey }); + const longUrl = getRandomLongUrl(); + const domain = linkDomain; + const options = { tags }; + + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), + ); + + expect(createResponse).toBeDefined(); + expect(createResponse.id).toBeDefined(); + + if (createResponse.id) { + const deleteResponse = await retry(() => + link.deleteShortCode(createResponse.id), + ); + expect(deleteResponse).toBe(true); + } + }, + testTimeout, + ); + test("should delete a short code with invalid organization Id", async () => { const link = new Link({ organizationId, apiKey }); const fakeCodeId = "code_1234567890abcdef"; @@ -252,10 +326,8 @@ describe("Link Update", () => { const title = faker.string.alpha(10); const options = { tags, title }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); @@ -267,9 +339,8 @@ describe("Link Update", () => { tags: ["updated-tag"], long_url: "https://updated.url", }; - const updateResponse = await link.updateShortCode( - createResponse.id, - updateOptions, + const updateResponse = await retry(() => + link.updateShortCode(createResponse.id, updateOptions), ); expect(updateResponse).toBeDefined(); @@ -277,8 +348,7 @@ describe("Link Update", () => { expect(updateResponse.tags).toEqual(["updated-tag"]); expect(updateResponse.long_url).toBe("https://updated.url"); - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -290,7 +360,7 @@ describe("Link Tags", () => { "should get tags for the organization", async () => { const link = new Link({ organizationId, apiKey }); - const tags = await link.getTags(); + const tags = await retry(() => link.getTags()); expect(tags).toBeDefined(); expect(Array.isArray(tags)).toBe(true); }, @@ -314,19 +384,24 @@ describe("Link Stats", () => { async () => { const link = new Link({ organizationId, apiKey }); - // Get all the short codes - const shortCodes = await link.getShortCodes("", [], 1, 10); - expect(shortCodes).toBeDefined(); - expect(shortCodes.data.length).toBeGreaterThan(0); + // Use a short code owned by this test. Picking a random code from the + // organization is flaky: concurrent test runs (such as the CI matrix) + // create and delete their own temporary codes in the same + // organization, so a randomly picked code can be deleted by another + // run before the stats call resolves, which returns a 404. + const createResponse = await retry(() => + link.createShortCode(getRandomLongUrl(), linkDomain, { tags }), + ); - // Select a random short code - const randomShortCode = - shortCodes.data[Math.floor(Math.random() * shortCodes.data.length)]; + expect(createResponse).toBeDefined(); + expect(createResponse.id).toBeDefined(); - const codeStats = await link.getCodeStats( - randomShortCode.id, - new Date(Date.now() - 24 * 60 * 60 * 1000), - new Date(), + const codeStats = await retry(() => + link.getCodeStats( + createResponse.id, + new Date(Date.now() - 24 * 60 * 60 * 1000), + new Date(), + ), ); expect(codeStats).toBeDefined(); @@ -335,6 +410,10 @@ describe("Link Stats", () => { expect(codeStats.browsers).toBeDefined(); expect(codeStats.devices).toBeDefined(); expect(codeStats.locations).toBeDefined(); + + if (createResponse.id) { + await cleanupShortCode(link, createResponse.id); + } }, testTimeout, ); @@ -358,23 +437,22 @@ describe("Link QR Code", () => { const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); expect(createResponse.id).toBeDefined(); if (createResponse.id) { - const qrCodeResponse = await link.createQrCode(createResponse.id); + const qrCodeResponse = await retry(() => + link.createQrCode(createResponse.id), + ); expect(qrCodeResponse).toBeDefined(); expect(qrCodeResponse.qrCode).toBeDefined(); expect(qrCodeResponse.qrLink).toBeDefined(); - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -390,10 +468,8 @@ describe("Link QR Code", () => { const longUrl = getRandomLongUrl(); const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); expect(createResponse.id).toBeDefined(); @@ -405,16 +481,14 @@ describe("Link QR Code", () => { color: "#000000", size: QrSize.MEDIUM, }; - const qrCodeResponse = await link.createQrCode( - createResponse.id, - qrCodeOptions, + const qrCodeResponse = await retry(() => + link.createQrCode(createResponse.id, qrCodeOptions), ); expect(qrCodeResponse).toBeDefined(); expect(qrCodeResponse.qrCode).toBeDefined(); expect(qrCodeResponse.qrLink).toBeDefined(); - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -467,7 +541,8 @@ describe("Link QR Code", () => { // automatically by the underlying fetch call. expect(capturedHeaders?.["content-type"]).toBeUndefined(); expect(response.qrCode).toBeDefined(); - expect(response.qrCodeBytes).toBeDefined(); + expect(response.qrCodeBytes).toBeInstanceOf(Uint8Array); + expect(Buffer.from(response.qrCodeBytes).toString()).toBe("qr"); expect(response.qrLink).toBe("https://hyphen.ai/qr"); }); @@ -517,32 +592,37 @@ describe("Link QR Code", () => { const longUrl = getRandomLongUrl(); const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); expect(createResponse.id).toBeDefined(); if (createResponse.id) { - const qrCode1 = await link.createQrCode(createResponse.id); + const qrCode1 = await retry(() => link.createQrCode(createResponse.id)); expect(qrCode1).toBeDefined(); expect(qrCode1.qrCode).toBeDefined(); expect(qrCode1.qrLink).toBeDefined(); - const qrCode2 = await link.createQrCode(createResponse.id); + const qrCode2 = await retry(() => link.createQrCode(createResponse.id)); expect(qrCode2).toBeDefined(); expect(qrCode2.qrCode).toBeDefined(); expect(qrCode2.qrLink).toBeDefined(); - const qrCodes = await link.getQrCodes(createResponse.id); + // Poll until both QR codes are visible in the list. + const qrCodes = await retry(async () => { + const result = await link.getQrCodes(createResponse.id); + if (result.data.length < 2) { + throw new Error("QR codes are not visible in the list"); + } + + return result; + }); expect(qrCodes).toBeDefined(); expect(qrCodes.data.length).toBeGreaterThanOrEqual(2); - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -562,27 +642,32 @@ describe("Link QR Code", () => { const longUrl = getRandomLongUrl(); const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); expect(createResponse.id).toBeDefined(); if (createResponse.id) { - await link.createQrCode(createResponse.id); - await link.createQrCode(createResponse.id); - - const qrCodes = await link.getQrCodes(createResponse.id, 1, 10); + await retry(() => link.createQrCode(createResponse.id)); + await retry(() => link.createQrCode(createResponse.id)); + + // Poll until both QR codes are visible in the list. + const qrCodes = await retry(async () => { + const result = await link.getQrCodes(createResponse.id, 1, 10); + if (result.data.length < 2) { + throw new Error("QR codes are not visible in the list"); + } + + return result; + }); expect(qrCodes).toBeDefined(); expect(qrCodes.data.length).toBeGreaterThanOrEqual(2); expect(qrCodes.pageNum).toBe(1); expect(qrCodes.pageSize).toBe(10); - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -595,29 +680,27 @@ describe("Link QR Code", () => { const longUrl = getRandomLongUrl(); const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); expect(createResponse.id).toBeDefined(); if (createResponse.id) { - const qrCodeResponse = await link.createQrCode(createResponse.id); + const qrCodeResponse = await retry(() => + link.createQrCode(createResponse.id), + ); expect(qrCodeResponse).toBeDefined(); expect(qrCodeResponse.qrCode).toBeDefined(); expect(qrCodeResponse.qrLink).toBeDefined(); - const qrCodeById = await link.getQrCode( - createResponse.id, - qrCodeResponse.id, + const qrCodeById = await retry(() => + link.getQrCode(createResponse.id, qrCodeResponse.id), ); expect(qrCodeById.id).toEqual(qrCodeResponse.id); - const deleteResponse = await link.deleteShortCode(createResponse.id); - expect(deleteResponse).toBe(true); + await cleanupShortCode(link, createResponse.id); } }, testTimeout, @@ -638,29 +721,28 @@ describe("Link QR Code", () => { const longUrl = getRandomLongUrl(); const domain = linkDomain; const options = { tags }; - const createResponse = await link.createShortCode( - longUrl, - domain, - options, + const createResponse = await retry(() => + link.createShortCode(longUrl, domain, options), ); expect(createResponse).toBeDefined(); expect(createResponse.id).toBeDefined(); if (createResponse.id) { - const qrCodeResponse = await link.createQrCode(createResponse.id); + const qrCodeResponse = await retry(() => + link.createQrCode(createResponse.id), + ); expect(qrCodeResponse).toBeDefined(); expect(qrCodeResponse.qrCode).toBeDefined(); expect(qrCodeResponse.qrLink).toBeDefined(); - const deleteQrCodeResponse = await link.deleteQrCode( - createResponse.id, - qrCodeResponse.id, + const deleteQrCodeResponse = await retry(() => + link.deleteQrCode(createResponse.id, qrCodeResponse.id), ); expect(deleteQrCodeResponse).toBe(true); - const deleteShortCodeResponse = await link.deleteShortCode( - createResponse.id, + const deleteShortCodeResponse = await retry(() => + link.deleteShortCode(createResponse.id), ); expect(deleteShortCodeResponse).toBe(true); }