Skip to content
Open
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
90 changes: 90 additions & 0 deletions __tests__/services/analytics/user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { STORAGE_KEYS } from "services/analytics/constants";
import { getUserId } from "services/analytics/user";
import { getAuthUserId } from "services/auth/getAuthUserId";

// jest.setup.js globally stubs services/analytics/user (getUserId always
// resolves "test-user-id") for the rest of the suite. Unmock it here so this
// file exercises the real implementation.
jest.unmock("services/analytics/user");

// Mock AsyncStorage directly (same pattern used across the mobile test suite).
jest.mock("@react-native-async-storage/async-storage", () => ({
getItem: jest.fn(),
setItem: jest.fn(),
}));

// user.ts imports the real @amplitude/analytics-react-native package (jest.setup.js's
// blanket mock of services/analytics/user is unmocked above), which otherwise
// pulls in its own native AsyncStorage requirement at import time.
jest.mock("@amplitude/analytics-react-native", () => ({
setUserId: jest.fn(),
}));

// Mock only the immediate dependency: getAuthUserId does its own thorough
// keypair-resolution testing in getAuthUserId.test.ts.
jest.mock("services/auth/getAuthUserId", () => ({
getAuthUserId: jest.fn(),
}));

const mockGetItem = AsyncStorage.getItem as jest.Mock;
const mockSetItem = AsyncStorage.setItem as jest.Mock;
const mockGetAuthUserId = getAuthUserId as jest.Mock;

describe("getUserId", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("returns the auth-derived id and overwrites a previously stored random id", async () => {
mockGetAuthUserId.mockResolvedValue("auth-derived-hex-id");
mockGetItem.mockResolvedValue("old-random-id");

const userId = await getUserId();

expect(userId).toBe("auth-derived-hex-id");
expect(mockSetItem).toHaveBeenCalledWith(
STORAGE_KEYS.METRICS_USER_ID,
"auth-derived-hex-id",
);
});

it("returns the auth-derived id and persists it when nothing was stored yet", async () => {
mockGetAuthUserId.mockResolvedValue("auth-derived-hex-id");
mockGetItem.mockResolvedValue(null);

const userId = await getUserId();

expect(userId).toBe("auth-derived-hex-id");
expect(mockSetItem).toHaveBeenCalledWith(
STORAGE_KEYS.METRICS_USER_ID,
"auth-derived-hex-id",
);
});

it("falls back to the existing stored id when getAuthUserId resolves null (locked session)", async () => {
mockGetAuthUserId.mockResolvedValue(null);
mockGetItem.mockResolvedValue("existing-random-id");

const userId = await getUserId();

expect(userId).toBe("existing-random-id");
// The pre-existing random path is untouched: no re-persist of an
// already-stored id.
expect(mockSetItem).not.toHaveBeenCalled();
});

it("generates and persists a new random id when getAuthUserId resolves null and nothing is stored", async () => {
mockGetAuthUserId.mockResolvedValue(null);
mockGetItem.mockResolvedValue(null);

const userId = await getUserId();

expect(userId).toEqual(expect.any(String));
expect(userId.length).toBeGreaterThan(0);
expect(mockSetItem).toHaveBeenCalledWith(
STORAGE_KEYS.METRICS_USER_ID,
userId,
);
});
});
28 changes: 28 additions & 0 deletions __tests__/services/auth/getAuthUserId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getAuthKeypair } from "services/auth/getAuthKeypair";
import { getAuthUserId } from "services/auth/getAuthUserId";

// Mock only the immediate dependency: getAuthKeypair does its own thorough
// derivation/caching/session-gate testing in getAuthKeypair.test.ts.
jest.mock("services/auth/getAuthKeypair", () => ({
getAuthKeypair: jest.fn(),
}));

const mockGetAuthKeypair = getAuthKeypair as jest.Mock;

describe("getAuthUserId", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("returns the public hex when a keypair is available", async () => {
mockGetAuthKeypair.mockResolvedValue({
rawPublicKey: () => Buffer.from("ff", "hex"),
});
expect(await getAuthUserId()).toBe("ff");
});

it("returns null when the session has no keypair", async () => {
mockGetAuthKeypair.mockResolvedValue(null);
expect(await getAuthUserId()).toBeNull();
});
});
28 changes: 26 additions & 2 deletions src/services/analytics/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from "config/logger";
import { useAnalyticsStore } from "ducks/analytics";
import { STORAGE_KEYS, DEBUG_CONFIG } from "services/analytics/constants";
import { isInitialized } from "services/analytics/core";
import { getAuthUserId } from "services/auth/getAuthUserId";

// -----------------------------------------------------------------------------
// USER ID MANAGEMENT
Expand All @@ -19,12 +20,35 @@ const generateRandomUserId = (): string =>
Math.random().toString().split(".")[1];

/**
* Gets user ID with fallback strategy:
* 1. Try to get from AsyncStorage
* Gets user ID, preferring the seed-derived auth id, with fallback strategy:
* 0. Prefer the auth id (services/auth/getAuthUserId) when the session is
* unlocked, persisting/overwriting the stored id so migrated users keep a
* stable identity going forward.
* 1. Otherwise (locked / no session) try to get from AsyncStorage
* 2. Generate new one and store it
* 3. Use session-only ID if storage fails
*/
export const getUserId = async (): Promise<string> => {
const authId = await getAuthUserId();

if (authId) {
try {
await AsyncStorage.setItem(STORAGE_KEYS.METRICS_USER_ID, authId);
} catch (setError) {
// Same rationale as the random-id session-only fallback below: don't
// let a persistence failure discard an id we already have.
logger.warn(
DEBUG_CONFIG.LOG_PREFIX,
"Failed to persist auth-derived user ID, using it for this session only",
setError,
);
}

sessionUserId = authId;

return authId;
}

try {
const storedId = await AsyncStorage.getItem(STORAGE_KEYS.METRICS_USER_ID);

Expand Down
11 changes: 11 additions & 0 deletions src/services/auth/getAuthUserId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getAuthKeypair } from "services/auth/getAuthKeypair";

/**
* Public auth user id (hex) for analytics; null when the session is locked.
* Only the public key hex is ever returned — the underlying keypair (and any
* private key material) never leaves `getAuthKeypair`.
*/
export const getAuthUserId = async (): Promise<string | null> => {
const keypair = await getAuthKeypair();
return keypair ? keypair.rawPublicKey().toString("hex") : null;
};
Loading