From a4db0196678e3b3e90dada1f085d40fa5abdeeac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 17:01:50 +0000 Subject: [PATCH] Fix applyCode non-2xx handling with typed redeem errors (0.3.1) Check HTTP status on every request, map redeem failures to TapAppLinkRedeemError, send X-TapAppLink-SDK-Version, and document customer UI states for each result. Co-authored-by: Kenny Ye --- CHANGELOG.md | 7 + README.md | 88 ++++++++++- __tests__/tapAppLink.test.ts | 281 +++++++++++++++++++++++++++++------ package-lock.json | 4 +- package.json | 2 +- src/index.ts | 3 + src/tapAppLink.ts | 168 +++++++++++++++++++-- 7 files changed, 488 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4489e2..0076e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 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. +- Check HTTP status on every request; non-2xx responses are never treated as success. +- Add `TapAppLinkRedeemError` with codes `unknownCode`, `inactiveCode`, `wrongEnvironment`, `network` and `other`. +- Send `X-TapAppLink-SDK-Version: 0.3.1` on all requests. + ## 0.3.0 - Persist install id, tracked flag, attribution id and offer across launches (AsyncStorage optional peer). diff --git a/README.md b/README.md index a561dea..8be7ba8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,10 @@ The Play Install Referrer bridge is Android-only. Storing install state only the ## Usage ```ts -import { TapAppLink } from "@tapapplink/react-native"; +import { + TapAppLink, + TapAppLinkRedeemError, +} from "@tapapplink/react-native"; TapAppLink.configure({ publicKey: "etk_live_…", @@ -52,6 +55,89 @@ Set `debug: true` to log each request, response and stored state. The API key is Purchases are attributed with billing **webhooks**, not a client `trackPurchase` call. +### Apply a creator code + +Show success only when `applyCode` resolves. Map each result to UI state as below. Customers must never see the word "environment". + +```ts +type RedeemUi = { + title: string; + detail?: string; + hint?: string; + showCode: boolean; +}; + +async function redeemForUi(code: string): Promise { + try { + const result = await TapAppLink.applyCode(code); + const offerLine = + typeof result.offer === "object" && + result.offer && + typeof (result.offer as { creatorName?: string }).creatorName === "string" + ? `Offer from ${(result.offer as { creatorName: string }).creatorName}` + : undefined; + + if (result.alreadyAttributed === true) { + return { + title: "You're all set", + detail: offerLine, + showCode: false, + }; + } + + return { + title: "Code applied", + detail: offerLine, + showCode: true, + }; + } catch (error) { + if (error instanceof TapAppLinkRedeemError) { + switch (error.code) { + case "unknownCode": + return { + title: "We don't recognise that code. Check it and try again.", + hint: "Codes aren't case sensitive.", + showCode: true, + }; + case "inactiveCode": + return { + title: "This code is no longer active.", + hint: "You can still subscribe at the regular price.", + showCode: true, + }; + case "wrongEnvironment": + // Developer only. Customers see the unknownCode copy (never "environment"). + console.warn(TapAppLinkRedeemError.wrongEnvironmentDevWarning); + return { + title: "We don't recognise that code. Check it and try again.", + hint: "Codes aren't case sensitive.", + showCode: true, + }; + case "network": + return { + title: + "We couldn't check your code. Check your connection and try again.", + showCode: true, + }; + case "other": + default: + console.warn("TapAppLink applyCode failed", { + code: error.code, + status: error.status, + message: error.message, + }); + return { + title: + "We couldn't check your code. Check your connection and try again.", + showCode: true, + }; + } + } + throw error; + } +} +``` + ## Publishing Maintainers: see [PUBLISHING.md](./PUBLISHING.md) for the CI release tag flow and one-time npm trusted publishing setup. diff --git a/__tests__/tapAppLink.test.ts b/__tests__/tapAppLink.test.ts index 407f92b..16cdf28 100644 --- a/__tests__/tapAppLink.test.ts +++ b/__tests__/tapAppLink.test.ts @@ -18,9 +18,27 @@ jest.mock("@react-native-async-storage/async-storage", () => ({ }, })); -import { TapAppLink } from "../src/tapAppLink"; +import { + SDK_VERSION, + TapAppLink, + TapAppLinkRedeemError, +} from "../src/tapAppLink"; import { STORAGE_KEY } from "../src/storage"; +const configure = () => { + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); +}; + +const jsonResponse = (status: number, body: unknown) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => body, +}); + describe("TapAppLink", () => { beforeEach(async () => { asyncStore.clear(); @@ -34,10 +52,24 @@ describe("TapAppLink", () => { ); }); + it("sends the SDK version header on every request", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(200, { attributionId: "attr_hdr" }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + configure(); + await TapAppLink.trackInstall(); + + 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"); + }); + it("tracks install once, sends installId, and caches offer", async () => { - const fetchMock = jest.fn().mockResolvedValue({ - status: 200, - json: async () => ({ + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(200, { attributionId: "attr_123", offer: { creatorName: "Ada", @@ -45,14 +77,10 @@ describe("TapAppLink", () => { billingOfferId: "offer_1", }, }), - }); + ); global.fetch = fetchMock as unknown as typeof fetch; - TapAppLink.configure({ - publicKey: "etk_test_key", - environment: "sandbox", - ingestUrl: "https://example.test", - }); + configure(); const first = await TapAppLink.trackInstall(); const second = await TapAppLink.trackInstall(); @@ -85,9 +113,8 @@ describe("TapAppLink", () => { }); it("does not post ingestInstall again after a cold start with persisted state", async () => { - const fetchMock = jest.fn().mockResolvedValue({ - status: 200, - json: async () => ({ + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(200, { attributionId: "attr_cold", offer: { creatorName: "Bea", @@ -95,14 +122,10 @@ describe("TapAppLink", () => { billingOfferId: "offer_2", }, }), - }); + ); global.fetch = fetchMock as unknown as typeof fetch; - TapAppLink.configure({ - publicKey: "etk_test_key", - environment: "sandbox", - ingestUrl: "https://example.test", - }); + configure(); await TapAppLink.trackInstall(); expect(fetchMock).toHaveBeenCalledTimes(1); const installId = TapAppLink.getInstallId(); @@ -113,11 +136,7 @@ describe("TapAppLink", () => { await TapAppLink.resetForTesting(); asyncStore.set(STORAGE_KEY, persisted!); - TapAppLink.configure({ - publicKey: "etk_test_key", - environment: "sandbox", - ingestUrl: "https://example.test", - }); + configure(); const resumed = await TapAppLink.trackInstall(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -138,32 +157,18 @@ describe("TapAppLink", () => { it("sends stored attributionId on identify after hydrate", async () => { const fetchMock = jest .fn() - .mockResolvedValueOnce({ - status: 200, - json: async () => ({ attributionId: "attr_id_9" }), - }) - .mockResolvedValueOnce({ - status: 200, - json: async () => ({ ok: true }), - }); + .mockResolvedValueOnce(jsonResponse(200, { attributionId: "attr_id_9" })) + .mockResolvedValueOnce(jsonResponse(200, { ok: true })); global.fetch = fetchMock as unknown as typeof fetch; - TapAppLink.configure({ - publicKey: "etk_test_key", - environment: "sandbox", - ingestUrl: "https://example.test", - }); + configure(); await TapAppLink.trackInstall(); const persisted = asyncStore.get(STORAGE_KEY)!; await TapAppLink.resetForTesting(); asyncStore.set(STORAGE_KEY, persisted); - TapAppLink.configure({ - publicKey: "etk_test_key", - environment: "sandbox", - ingestUrl: "https://example.test", - }); + configure(); await TapAppLink.setAppUserId("user_42"); expect(TapAppLink.getAppUserId()).toBe("user_42"); @@ -177,12 +182,26 @@ describe("TapAppLink", () => { }); }); + it("does not treat a non-2xx ingest response as success", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(500, { + error: "server_error", + message: "boom", + attributionId: "should_not_cache", + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + configure(); + await expect(TapAppLink.trackInstall()).rejects.toThrow("boom"); + expect(TapAppLink.getAttributionId()).toBeUndefined(); + }); + it("redacts the API key in debug logs", async () => { const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - const fetchMock = jest.fn().mockResolvedValue({ - status: 200, - json: async () => ({ attributionId: "attr_dbg" }), - }); + const fetchMock = jest + .fn() + .mockResolvedValue(jsonResponse(200, { attributionId: "attr_dbg" })); global.fetch = fetchMock as unknown as typeof fetch; TapAppLink.configure({ @@ -200,4 +219,174 @@ describe("TapAppLink", () => { expect(logged.some((line) => line.includes("etk_...cret"))).toBe(true); logSpy.mockRestore(); }); + + describe("applyCode", () => { + const expectRedeemCode = async ( + status: number, + body: Record, + code: string, + ) => { + const fetchMock = jest.fn().mockResolvedValue(jsonResponse(status, body)); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + try { + await TapAppLink.applyCode("BADCODE"); + throw new Error("expected applyCode to throw"); + } catch (error) { + expect(error).toBeInstanceOf(TapAppLinkRedeemError); + const redeem = error as TapAppLinkRedeemError; + expect(redeem.code).toBe(code); + expect(redeem.status).toBe(status); + } + const headers = ( + fetchMock.mock.calls[0][1] as { headers: Record } + ).headers; + expect(headers["X-TapAppLink-SDK-Version"]).toBe(SDK_VERSION); + }; + + it("returns a success result and caches the offer", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(200, { + attributionId: "attr_applied", + alreadyAttributed: false, + offer: { + creatorName: "Ada", + promoCode: "ADA10", + billingOfferId: "offer_1", + }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + const result = await TapAppLink.applyCode("ADA10"); + expect(result).toMatchObject({ + attributionId: "attr_applied", + alreadyAttributed: false, + }); + expect(TapAppLink.getOffer()?.creatorName).toBe("Ada"); + expect(TapAppLink.getAttributionId()).toBe("attr_applied"); + }); + + it("returns alreadyAttributed success without treating it as an error", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(200, { + attributionId: "attr_existing", + alreadyAttributed: true, + offer: { + creatorName: "Bea", + promoCode: "BEA10", + billingOfferId: null, + }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + const result = await TapAppLink.applyCode("BEA10"); + expect(result).toMatchObject({ + attributionId: "attr_existing", + alreadyAttributed: true, + }); + expect(result).not.toBeInstanceOf(TapAppLinkRedeemError); + }); + + it("maps legacy 404 + unknown_code body to unknownCode", async () => { + await expectRedeemCode( + 404, + { error: "unknown_code", message: "Unknown code" }, + "unknownCode", + ); + }); + + it("maps new 404 without body error field to unknownCode via status", async () => { + await expectRedeemCode(404, { message: "Not found" }, "unknownCode"); + }); + + it("maps 410 + inactive_code body to inactiveCode", async () => { + await expectRedeemCode( + 410, + { error: "inactive_code", message: "Inactive" }, + "inactiveCode", + ); + }); + + it("maps 410 without body error field to inactiveCode via status", async () => { + await expectRedeemCode(410, { message: "Gone" }, "inactiveCode"); + }); + + it("maps 400 + wrong_environment body to wrongEnvironment", async () => { + await expectRedeemCode( + 400, + { error: "wrong_environment", message: "Wrong env" }, + "wrongEnvironment", + ); + }); + + it("maps legacy 404 + wrong_environment body to wrongEnvironment", async () => { + await expectRedeemCode( + 404, + { error: "wrong_environment", message: "Wrong env" }, + "wrongEnvironment", + ); + }); + + it("exposes the exact wrongEnvironment developer warning (Production, not Live)", () => { + expect(TapAppLinkRedeemError.wrongEnvironmentDevWarning).toBe( + "This code belongs to the other environment (Sandbox or Production). Check your API key.", + ); + expect(TapAppLinkRedeemError.wrongEnvironmentDevWarning).not.toContain( + "Live", + ); + }); + + it("maps other non-2xx responses to other with status", async () => { + await expectRedeemCode( + 500, + { error: "server_error", message: "Internal error" }, + "other", + ); + }); + + it("maps network failures to network", async () => { + const fetchMock = jest + .fn() + .mockRejectedValue(new Error("Network request failed")); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + try { + await TapAppLink.applyCode("ADA10"); + throw new Error("expected applyCode to throw"); + } catch (error) { + expect(error).toBeInstanceOf(TapAppLinkRedeemError); + const redeem = error as TapAppLinkRedeemError; + expect(redeem.code).toBe("network"); + expect(redeem.status).toBeUndefined(); + expect(redeem.message).toContain("Network request failed"); + } + }); + + it("does not cache attribution from a failed redeem response", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse(404, { + error: "unknown_code", + attributionId: "should_not_apply", + offer: { + creatorName: "Nope", + promoCode: "NOPE", + billingOfferId: null, + }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + + await expect(TapAppLink.applyCode("NOPE")).rejects.toBeInstanceOf( + TapAppLinkRedeemError, + ); + expect(TapAppLink.getAttributionId()).toBeUndefined(); + expect(TapAppLink.getOffer()).toBeNull(); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index 2f29bfe..1b729fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tapapplink/react-native", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tapapplink/react-native", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "devDependencies": { "@react-native-async-storage/async-storage": "^2.2.0", diff --git a/package.json b/package.json index 60c1b74..2fa3f75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tapapplink/react-native", - "version": "0.3.0", + "version": "0.3.1", "description": "Tap App Link attribution SDK for React Native and Expo", "license": "MIT", "homepage": "https://tapapplink.com", diff --git a/src/index.ts b/src/index.ts index 6958e56..0faa072 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,11 @@ export { + SDK_VERSION, TapAppLink, + TapAppLinkRedeemError, type TapAppLinkConfig, type TapAppLinkEnvironment, type TapAppLinkOffer, + type TapAppLinkRedeemErrorCode, } from "./tapAppLink"; export type { PersistedInstallState } from "./storage"; export { TapAppLink as default } from "./tapAppLink"; diff --git a/src/tapAppLink.ts b/src/tapAppLink.ts index 945df78..560b316 100644 --- a/src/tapAppLink.ts +++ b/src/tapAppLink.ts @@ -10,6 +10,9 @@ import { type PersistedInstallState, } from "./storage"; +/** Sent as `X-TapAppLink-SDK-Version` on every request. */ +export const SDK_VERSION = "0.3.1"; + export type TapAppLinkEnvironment = "production" | "sandbox"; export type TapAppLinkOffer = { @@ -26,6 +29,42 @@ export type TapAppLinkConfig = { debug?: boolean; }; +export type TapAppLinkRedeemErrorCode = + | "unknownCode" + | "inactiveCode" + | "wrongEnvironment" + | "network" + | "other"; + +/** + * Thrown by `applyCode` when the redeem endpoint fails or the request cannot + * complete. Inspect `code` to choose customer-facing copy. + */ +export class TapAppLinkRedeemError extends Error { + readonly name = "TapAppLinkRedeemError"; + readonly code: TapAppLinkRedeemErrorCode; + /** HTTP status when the server responded; omitted for network failures. */ + readonly status?: number; + + /** + * Developer-only warning for `wrongEnvironment`. Log this; never show it to + * customers (they should see the same copy as `unknownCode`). + */ + static readonly wrongEnvironmentDevWarning = + "This code belongs to the other environment (Sandbox or Production). Check your API key."; + + constructor( + code: TapAppLinkRedeemErrorCode, + message: string, + status?: number, + ) { + super(message); + this.code = code; + this.status = status; + Object.setPrototypeOf(this, new.target.prototype); + } +} + type NativeSignals = { deviceFamily?: string; locale?: string; @@ -37,6 +76,19 @@ type NativeTapAppLink = { getInstallSignals?: () => Promise; }; +class TapAppLinkHttpError extends Error { + readonly status: number; + readonly body: Record; + + constructor(status: number, body: Record, message: string) { + super(message); + this.name = "TapAppLinkHttpError"; + this.status = status; + this.body = body; + Object.setPrototypeOf(this, new.target.prototype); + } +} + const nativeModule = (): NativeTapAppLink => NativeModules.TapAppLink ?? {}; const jsSignals = (): NativeSignals => { @@ -138,6 +190,22 @@ const ingestBase = (cfg: TapAppLinkConfig): string => { return "https://us-central1-tapapplink.cloudfunctions.net"; }; +const parseJsonBody = async ( + response: Response, +): Promise> => { + try { + const parsed: unknown = await response.json(); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Non-JSON bodies still need a status check below. + } + return {}; +}; + +const isOkStatus = (status: number): boolean => status >= 200 && status < 300; + const post = async (path: string, body: unknown) => { if (!config) { throw new Error("TapAppLink.configure() must be called first"); @@ -146,6 +214,7 @@ const post = async (path: string, body: unknown) => { const headers = { Authorization: `Bearer ${config.publicKey}`, "Content-Type": "application/json", + "X-TapAppLink-SDK-Version": SDK_VERSION, }; debugLog("request", { url, @@ -156,16 +225,81 @@ const post = async (path: string, body: unknown) => { }, body, }); - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - const result = (await response.json()) as Record; + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + } catch (cause) { + const message = + cause instanceof Error ? cause.message : "Network request failed"; + throw new Error(message); + } + const result = await parseJsonBody(response); debugLog("response", { url, status: response.status, body: result }); + if (!isOkStatus(response.status)) { + const serverMessage = + typeof result.message === "string" + ? result.message + : typeof result.error === "string" + ? result.error + : `Request failed with status ${response.status}`; + throw new TapAppLinkHttpError(response.status, result, serverMessage); + } return result; }; +const mapRedeemError = (error: unknown): TapAppLinkRedeemError => { + if (error instanceof TapAppLinkRedeemError) { + return error; + } + if (error instanceof TapAppLinkHttpError) { + const bodyError = + typeof error.body.error === "string" ? error.body.error : undefined; + const message = + typeof error.body.message === "string" + ? error.body.message + : error.message; + + if (bodyError === "unknown_code") { + return new TapAppLinkRedeemError("unknownCode", message, error.status); + } + if (bodyError === "inactive_code") { + return new TapAppLinkRedeemError("inactiveCode", message, error.status); + } + if (bodyError === "wrong_environment") { + return new TapAppLinkRedeemError( + "wrongEnvironment", + message, + error.status, + ); + } + + // Status fallback when the body lacks a known `error` field. + if (error.status === 404) { + return new TapAppLinkRedeemError("unknownCode", message, error.status); + } + if (error.status === 410) { + return new TapAppLinkRedeemError("inactiveCode", message, error.status); + } + if (error.status === 400 && bodyError === "wrong_environment") { + return new TapAppLinkRedeemError( + "wrongEnvironment", + message, + error.status, + ); + } + + return new TapAppLinkRedeemError("other", message, error.status); + } + + const message = + error instanceof Error ? error.message : "Network request failed"; + return new TapAppLinkRedeemError("network", message); +}; + const cacheFromResult = (result: Record) => { if (typeof result.attributionId === "string") { lastAttributionId = result.attributionId; @@ -241,15 +375,19 @@ export const TapAppLink = { applyCode: async (code: string) => { await ensureHydrated(); - const result = await post("/redeemCode", { - code, - appUserId: lastAppUserId, - attributionId: lastAttributionId, - platform: platformName(), - }); - cacheFromResult(result); - await persist(); - return result; + try { + const result = await post("/redeemCode", { + code, + appUserId: lastAppUserId, + attributionId: lastAttributionId, + platform: platformName(), + }); + cacheFromResult(result); + await persist(); + return result; + } catch (error) { + throw mapRedeemError(error); + } }, getOffer: () => lastOffer,