diff --git a/CHANGELOG.md b/CHANGELOG.md index 0076e41..a9671f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.2 + +- **Fixed:** retrying `applyCode` after a timeout could count an install twice. 0.3.2 sends a request ID so the server recognises the retry. No code changes needed. + ## 0.3.1 - **Fixed:** Android and React Native 0.3.0 could return an error from `applyCode()` as if it were a normal result, so an app could show a code as applied when it wasn't. Upgrade to 0.3.1, which raises a typed error for unknown, inactive and wrong-environment codes. iOS and Flutter 0.3.0 threw a generic error, and 0.3.1 makes it typed. diff --git a/__tests__/tapAppLink.test.ts b/__tests__/tapAppLink.test.ts index 16cdf28..18dc4fc 100644 --- a/__tests__/tapAppLink.test.ts +++ b/__tests__/tapAppLink.test.ts @@ -63,8 +63,8 @@ describe("TapAppLink", () => { const headers = (fetchMock.mock.calls[0][1] as { headers: Record }) .headers; - expect(headers["X-TapAppLink-SDK-Version"]).toBe("0.3.1"); - expect(SDK_VERSION).toBe("0.3.1"); + expect(headers["X-TapAppLink-SDK-Version"]).toBe("0.3.2"); + expect(SDK_VERSION).toBe("0.3.2"); }); it("tracks install once, sends installId, and caches offer", async () => { @@ -388,5 +388,132 @@ describe("TapAppLink", () => { expect(TapAppLink.getAttributionId()).toBeUndefined(); expect(TapAppLink.getOffer()).toBeNull(); }); + + const redeemRequestId = (fetchMock: jest.Mock, callIndex: number) => { + const body = JSON.parse( + (fetchMock.mock.calls[callIndex][1] as { body: string }).body, + ) as { requestId: string; code: string }; + return body; + }; + + it("reuses the same requestId when retrying applyCode after a timeout", async () => { + const fetchMock = jest + .fn() + .mockRejectedValueOnce(new Error("Network request failed")) + .mockResolvedValueOnce( + jsonResponse(200, { + attributionId: "attr_retry", + alreadyAttributed: false, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + await expect(TapAppLink.applyCode("ada-10")).rejects.toMatchObject({ + code: "network", + }); + const first = redeemRequestId(fetchMock, 0); + expect(first.requestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + + const result = await TapAppLink.applyCode("ADA10"); + const second = redeemRequestId(fetchMock, 1); + expect(second.requestId).toBe(first.requestId); + expect(result).toMatchObject({ attributionId: "attr_retry" }); + expect(asyncStore.get(STORAGE_KEY)).not.toContain("pendingRedeem"); + }); + + it("reuses a pending requestId after an app restart", async () => { + const fetchMock = jest + .fn() + .mockRejectedValueOnce(new Error("Network request failed")) + .mockResolvedValueOnce( + jsonResponse(200, { + attributionId: "attr_restart", + alreadyAttributed: false, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + await expect(TapAppLink.applyCode("BEA10")).rejects.toMatchObject({ + code: "network", + }); + const first = redeemRequestId(fetchMock, 0); + const persisted = asyncStore.get(STORAGE_KEY); + expect(persisted).toContain(first.requestId); + expect(persisted).toContain("BEA10"); + + await TapAppLink.resetForTesting(); + asyncStore.set(STORAGE_KEY, persisted!); + configure(); + + await TapAppLink.applyCode("bea-10"); + const second = redeemRequestId(fetchMock, 1); + expect(second.requestId).toBe(first.requestId); + }); + + it("uses a new requestId when applyCode is called with a different code", async () => { + const fetchMock = jest + .fn() + .mockRejectedValueOnce(new Error("Network request failed")) + .mockRejectedValueOnce(new Error("Network request failed")); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + await expect(TapAppLink.applyCode("ADA10")).rejects.toMatchObject({ + code: "network", + }); + await expect(TapAppLink.applyCode("BEA10")).rejects.toMatchObject({ + code: "network", + }); + const first = redeemRequestId(fetchMock, 0); + const second = redeemRequestId(fetchMock, 1); + expect(second.requestId).not.toBe(first.requestId); + expect(asyncStore.get(STORAGE_KEY)).toContain(second.requestId); + expect(asyncStore.get(STORAGE_KEY)).not.toContain(first.requestId); + }); + + it("clears the pending requestId after a definitive success or typed error", async () => { + const fetchMock = jest + .fn() + .mockRejectedValueOnce(new Error("Network request failed")) + .mockResolvedValueOnce( + jsonResponse(404, { + error: "unknown_code", + message: "Unknown", + }), + ) + .mockResolvedValueOnce( + jsonResponse(200, { + attributionId: "attr_ok", + alreadyAttributed: false, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + await expect(TapAppLink.applyCode("ADA10")).rejects.toMatchObject({ + code: "network", + }); + expect(asyncStore.get(STORAGE_KEY)).toContain("pendingRedeem"); + const pendingId = redeemRequestId(fetchMock, 0).requestId; + + await expect(TapAppLink.applyCode("ADA10")).rejects.toMatchObject({ + code: "unknownCode", + }); + expect(redeemRequestId(fetchMock, 1).requestId).toBe(pendingId); + expect(asyncStore.get(STORAGE_KEY) ?? "").not.toContain( + '"pendingRedeem":{', + ); + + await TapAppLink.applyCode("ADA10"); + const third = redeemRequestId(fetchMock, 2); + expect(third.requestId).not.toBe(pendingId); + expect(asyncStore.get(STORAGE_KEY) ?? "").not.toContain( + '"pendingRedeem":{', + ); + }); }); }); diff --git a/package-lock.json b/package-lock.json index 1b729fb..3c9ff08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tapapplink/react-native", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tapapplink/react-native", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "devDependencies": { "@react-native-async-storage/async-storage": "^2.2.0", diff --git a/package.json b/package.json index 2fa3f75..e85dfbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tapapplink/react-native", - "version": "0.3.1", + "version": "0.3.2", "description": "Tap App Link attribution SDK for React Native and Expo", "license": "MIT", "homepage": "https://tapapplink.com", diff --git a/src/storage.ts b/src/storage.ts index bec7ea5..618a6d8 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,8 @@ +export type PendingRedeemState = { + normalisedCode: string; + requestId: string; +}; + export type PersistedInstallState = { installId: string; tracked: boolean; @@ -7,6 +12,8 @@ export type PersistedInstallState = { promoCode: string | null; billingOfferId: string | null; } | null; + /** Pending redeem attempt so retries after restart reuse the same requestId. */ + pendingRedeem?: PendingRedeemState | null; }; export type KeyValueStorage = { @@ -70,6 +77,18 @@ export const loadInstallState = async ( if (!raw) return null; const parsed = JSON.parse(raw) as PersistedInstallState; if (!parsed || typeof parsed.installId !== "string") return null; + const pending = parsed.pendingRedeem; + const pendingRedeem = + pending && + typeof pending === "object" && + typeof pending.normalisedCode === "string" && + typeof pending.requestId === "string" + ? { + normalisedCode: pending.normalisedCode, + requestId: pending.requestId, + } + : null; + return { installId: parsed.installId, tracked: Boolean(parsed.tracked), @@ -78,6 +97,7 @@ export const loadInstallState = async ( ? parsed.attributionId : undefined, offer: parsed.offer ?? null, + pendingRedeem, }; } catch { return null; diff --git a/src/tapAppLink.ts b/src/tapAppLink.ts index 560b316..157b71c 100644 --- a/src/tapAppLink.ts +++ b/src/tapAppLink.ts @@ -7,11 +7,12 @@ import { resolveStorage, saveInstallState, type KeyValueStorage, + type PendingRedeemState, type PersistedInstallState, } from "./storage"; /** Sent as `X-TapAppLink-SDK-Version` on every request. */ -export const SDK_VERSION = "0.3.1"; +export const SDK_VERSION = "0.3.2"; export type TapAppLinkEnvironment = "production" | "sandbox"; @@ -129,6 +130,19 @@ let tracked = false; let lastAttributionId: string | undefined; let lastAppUserId: string | undefined; let lastOffer: TapAppLinkOffer | null = null; +let pendingRedeem: PendingRedeemState | null = null; + +/** Uppercase, strip non-alphanumerics, max 24 chars. Used only for requestId reuse. */ +const normaliseRedeemCode = (code: string): string => + code + .toUpperCase() + .replace(/[^A-Z0-9]/g, "") + .slice(0, 24); + +const isDefinitiveRedeemError = (code: TapAppLinkRedeemErrorCode): boolean => + code === "unknownCode" || + code === "inactiveCode" || + code === "wrongEnvironment"; const redactKey = (value: string): string => { if (value.length <= 8) return "***"; @@ -151,15 +165,20 @@ const applyPersisted = (state: PersistedInstallState) => { tracked = state.tracked; lastAttributionId = state.attributionId; lastOffer = state.offer ?? null; + pendingRedeem = state.pendingRedeem ?? null; }; const persist = async () => { - if (!installId) return; + if (!installId && !pendingRedeem) return; + if (!installId) { + installId = createInstallId(); + } const state: PersistedInstallState = { installId, tracked, attributionId: lastAttributionId, offer: lastOffer, + ...(pendingRedeem ? { pendingRedeem } : {}), }; await saveInstallState(storage, state); debugLog("stored state", { @@ -167,6 +186,7 @@ const persist = async () => { tracked: state.tracked, attributionId: state.attributionId, offer: state.offer, + pendingRedeem: state.pendingRedeem ?? null, }); }; @@ -375,18 +395,38 @@ export const TapAppLink = { applyCode: async (code: string) => { await ensureHydrated(); + const normalisedCode = normaliseRedeemCode(code); + let requestId: string; + if ( + pendingRedeem && + pendingRedeem.normalisedCode === normalisedCode + ) { + requestId = pendingRedeem.requestId; + } else { + requestId = createInstallId(); + pendingRedeem = { normalisedCode, requestId }; + await persist(); + } + try { const result = await post("/redeemCode", { code, + requestId, appUserId: lastAppUserId, attributionId: lastAttributionId, platform: platformName(), }); cacheFromResult(result); + pendingRedeem = null; await persist(); return result; } catch (error) { - throw mapRedeemError(error); + const mapped = mapRedeemError(error); + if (isDefinitiveRedeemError(mapped.code)) { + pendingRedeem = null; + await persist(); + } + throw mapped; } }, @@ -410,6 +450,7 @@ export const TapAppLink = { lastAttributionId = undefined; lastAppUserId = undefined; lastOffer = null; + pendingRedeem = null; hydratePromise = null; await clearInstallState(storage); resetStorageHelpersForTesting();