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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
131 changes: 129 additions & 2 deletions __tests__/tapAppLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ describe("TapAppLink", () => {

const headers = (fetchMock.mock.calls[0][1] as { headers: Record<string, string> })
.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 () => {
Expand Down Expand Up @@ -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":{',
);
});
});
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export type PendingRedeemState = {
normalisedCode: string;
requestId: string;
};

export type PersistedInstallState = {
installId: string;
tracked: boolean;
Expand All @@ -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 = {
Expand Down Expand Up @@ -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),
Expand All @@ -78,6 +97,7 @@ export const loadInstallState = async (
? parsed.attributionId
: undefined,
offer: parsed.offer ?? null,
pendingRedeem,
};
} catch {
return null;
Expand Down
47 changes: 44 additions & 3 deletions src/tapAppLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 "***";
Expand All @@ -151,22 +165,28 @@ 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", {
installId: state.installId,
tracked: state.tracked,
attributionId: state.attributionId,
offer: state.offer,
pendingRedeem: state.pendingRedeem ?? null,
});
};

Expand Down Expand Up @@ -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;
}
},

Expand All @@ -410,6 +450,7 @@ export const TapAppLink = {
lastAttributionId = undefined;
lastAppUserId = undefined;
lastOffer = null;
pendingRedeem = null;
hydratePromise = null;
await clearInstallState(storage);
resetStorageHelpersForTesting();
Expand Down
Loading