fix(mobile): address Apple review onboarding flows

This commit is contained in:
jjbrunton committed 2026-09-01 09:42:48 +01:00
1 parent 6883befff3
commit 7c8d91c89c
20 files changed
+661 -262

No files matched your search

@@ -1,14 +1,19 @@
import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native';
import { ActionSheetIOS, Alert, Linking, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker';
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
jest.mock('@/lib/mediaApi', () => ({
uploadMedia: jest.fn(),
}));
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn(),
}));
jest.mock('expo-image-picker', () => ({
getCameraPermissionsAsync: jest.fn(),
requestCameraPermissionsAsync: jest.fn(),
requestMediaLibraryPermissionsAsync: jest.fn(),
launchCameraAsync: jest.fn(),
@@ -23,7 +28,10 @@ describe('useAvatarPicker', () => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined);
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined', canAskAgain: true });
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
});
@@ -64,4 +72,41 @@ describe('useAvatarPicker', () => {
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
});
it('does not show a custom alert immediately after a camera permission denial', async () => {
const { result } = renderHook(() => useAvatarPicker());
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
act(() => result.current.showPicker());
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Take Photo'
);
await act(async () => takePhoto.onPress());
expect(Alert.alert).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
});
it('offers Settings only for a later camera attempt that iOS cannot prompt again', async () => {
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Take Photo'
);
await act(async () => takePhoto.onPress());
const settingsAlert = (Alert.alert as jest.Mock).mock.calls[1];
expect(settingsAlert[0]).toBe('Camera Access');
expect(settingsAlert[2]).toEqual([
{ text: 'Not Now', style: 'cancel' },
expect.objectContaining({ text: 'Open Settings' }),
]);
await act(async () => settingsAlert[2][1].onPress());
expect(Linking.openSettings).toHaveBeenCalledTimes(1);
expect(ImagePicker.requestCameraPermissionsAsync).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { ApiError, apiRequest } from "@/lib/apiClient";
import { ApiError, apiRequest, apiRequestWithAccessToken } from "@/lib/apiClient";
import { authClient } from "@/lib/authClient";
function response(status: number, body?: unknown) {
@@ -40,6 +40,17 @@ describe("apiClient", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses an explicitly captured bearer without reading a mutable auth session", async () => {
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
(authClient.auth.getSession as jest.Mock).mockClear();
await apiRequestWithAccessToken("/v1/me", "captured-token");
const headers = fetchMock.mock.calls[0][1].headers as Headers;
expect(headers.get("Authorization")).toBe("Bearer captured-token");
expect(authClient.auth.getSession).not.toHaveBeenCalled();
});
it("preserves API status and error details", async () => {
fetchMock.mockResolvedValue(response(403, {
error: { message: "Couple access denied", code: "FORBIDDEN" },
@@ -0,0 +1,21 @@
import { formatAppleProfileName } from '@/lib/appleProfileName';
describe('formatAppleProfileName', () => {
it('combines Apple name parts into the profile display name', () => {
expect(formatAppleProfileName({
givenName: ' Ada ',
middleName: ' Lovelace',
familyName: ' Byron ',
})).toBe('Ada Lovelace Byron');
});
it('returns null when Apple has not supplied a usable name', () => {
expect(formatAppleProfileName({ givenName: ' \n\t ' })).toBeNull();
expect(formatAppleProfileName(null)).toBeNull();
});
it('removes control characters and stays within the profile contract limit', () => {
expect(formatAppleProfileName({ givenName: 'Ada\u0000 Lovelace' })).toBe('Ada Lovelace');
expect(formatAppleProfileName({ givenName: 'a'.repeat(101) })).toHaveLength(100);
});
});
@@ -0,0 +1,45 @@
import { persistAppleProfileName } from '@/lib/appleProfileNamePersistence';
describe('persistAppleProfileName', () => {
const session = { userId: 'apple-user', accessToken: 'apple-token' };
const getProfile = jest.fn();
const updateProfileName = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('writes a supplied name only after a matching blank profile is read with the captured bearer', async () => {
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: null } });
updateProfileName.mockResolvedValue({ updated: true });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.resolves.toBe('updated');
expect(getProfile).toHaveBeenCalledWith('apple-token');
expect(updateProfileName).toHaveBeenCalledWith('apple-token', 'Ada Lovelace');
});
it('preserves a returning user nickname', async () => {
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: 'Ada B' } });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.resolves.toBe('existing');
expect(updateProfileName).not.toHaveBeenCalled();
});
it('fails closed on a subject mismatch before any profile write', async () => {
getProfile.mockResolvedValue({ profile: { id: 'different-user', name: null } });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.rejects.toThrow('subject did not match');
expect(updateProfileName).not.toHaveBeenCalled();
});
it('propagates API failure so the caller can clear its pending onboarding name', async () => {
getProfile.mockRejectedValue(new Error('offline'));
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.rejects.toThrow('offline');
expect(updateProfileName).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage } from '@/lib/onboardingStages';
describe('initialOnboardingStage', () => {
it('starts at gender when a profile already has an Apple-supplied name', () => {
expect(initialOnboardingStage('Ada Lovelace')).toBe('gender');
});
it('starts at name as a fallback when no name is available', () => {
expect(initialOnboardingStage(' ')).toBe('name');
expect(initialOnboardingStage(null)).toBe('name');
});
it('does not provide a back destination before the first reachable stage', () => {
expect(previousOnboardingStage('name', false)).toBeNull();
expect(previousOnboardingStage('gender', true)).toBeNull();
});
it('allows backwards navigation only through reachable stages', () => {
expect(previousOnboardingStage('gender', false)).toBe('name');
expect(previousOnboardingStage('purpose', true)).toBe('gender');
expect(previousOnboardingStage('notifications', false)).toBe('purpose');
});
it('keeps first-auth onboarding out of the name screen only while its subject-keyed name is pending', () => {
expect(activeOnboardingStage('name', null, 'Ada Lovelace')).toBe('gender');
expect(activeOnboardingStage('name', null, undefined)).toBe('name');
});
});
@@ -1,4 +1,4 @@
import { apiClient } from '../../lib/apiClient';
import { apiClient, apiRequestWithAccessToken } from '../../lib/apiClient';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
jest.mock('../../lib/apiClient', () => ({
@@ -7,6 +7,7 @@ jest.mock('../../lib/apiClient', () => ({
post: jest.fn(),
patch: jest.fn(),
},
apiRequestWithAccessToken: jest.fn(),
}));
describe('profileSettingsApi', () => {
@@ -27,4 +28,14 @@ describe('profileSettingsApi', () => {
type: 'bug', title: 'Title', description: 'Details',
});
});
it('can bind a profile update to a captured authentication bearer', async () => {
await profileSettingsApi.updateProfileWithAccessToken('captured-token', { name: 'Ada' });
expect(apiRequestWithAccessToken).toHaveBeenCalledWith(
'/v1/me/profile',
'captured-token',
{ method: 'PATCH', body: { name: 'Ada' } },
);
});
});
@@ -30,10 +30,14 @@ const profile = {
describe("authStore", () => {
beforeEach(() => {
jest.clearAllMocks();
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
data: { session: { user: { id: "me" }, access_token: "token-me" } },
});
// fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape.
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
jest.spyOn(coupleApi, "getStateWithAccessToken").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({
user: null,
couple: null,
@@ -42,6 +46,7 @@ describe("authStore", () => {
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
pendingAppleDisplayName: null,
} as any);
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
@@ -64,13 +69,13 @@ describe("authStore", () => {
it("bootstraps and reads the profile through GET /v1/me", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me", is_anonymous: true } } },
data: { session: { user: { id: "me", is_anonymous: true }, access_token: "token-me" } },
});
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile });
const getSpy = jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({ profile });
await useAuthStore.getState().fetchUser();
expect(getSpy).toHaveBeenCalledWith("/v1/me");
expect(getSpy).toHaveBeenCalledWith("/v1/me", "token-me");
expect(useAuthStore.getState()).toMatchObject({
user: profile,
isAuthenticated: true,
@@ -114,9 +119,9 @@ describe("authStore", () => {
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
useAuthStore.setState({ sealedCount: 3 } as any);
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me" } } },
data: { session: { user: { id: "me" }, access_token: "token-me" } },
});
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
jest.spyOn(apiClient, "getWithAccessToken").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
await useAuthStore.getState().fetchUser();
@@ -135,6 +140,123 @@ describe("authStore", () => {
expect(authClient.auth.signOut).toHaveBeenCalled();
});
it("does not let an older session response overwrite a newer profile", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
let resolveA!: (value: { profile: Profile }) => void;
let resolveB!: (value: { profile: Profile }) => void;
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
.mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve; }));
const firstFetch = useAuthStore.getState().fetchUser();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await Promise.resolve();
await Promise.resolve();
resolveB({ profile: { ...profile, id: "user-b", name: "Newer Name" } as Profile });
await secondFetch;
resolveA({ profile: { ...profile, id: "user-a", name: "Older Name" } as Profile });
await firstFetch;
expect(getSpy).toHaveBeenNthCalledWith(1, "/v1/me", "token-a");
expect(getSpy).toHaveBeenNthCalledWith(2, "/v1/me", "token-b");
expect(useAuthStore.getState()).toMatchObject({
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
isAuthenticated: true,
isLoading: false,
});
});
it("does not let an older couple response overwrite a newer session", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
jest.spyOn(apiClient, "getWithAccessToken")
.mockResolvedValueOnce({ profile: { ...profile, id: "user-a", name: "Older Name" } })
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
let resolveCoupleA!: (value: { couple: null; partner: null; sealed_count: number }) => void;
jest.spyOn(coupleApi, "getStateWithAccessToken")
.mockImplementationOnce(() => new Promise((resolve) => { resolveCoupleA = resolve; }))
.mockResolvedValueOnce({ couple: null, partner: null, sealed_count: 7 });
const firstFetch = useAuthStore.getState().fetchUser();
for (let attempt = 0; attempt < 10 && !resolveCoupleA; attempt += 1) {
await Promise.resolve();
}
expect(resolveCoupleA).toBeDefined();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await secondFetch;
resolveCoupleA({ couple: null, partner: null, sealed_count: 1 });
await firstFetch;
expect(useAuthStore.getState()).toMatchObject({
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
sealedCount: 7,
isLoading: false,
});
});
it("rejects a mismatched API profile without signing out the active session", async () => {
useAuthStore.setState({
user: profile,
couple: { id: "prior-couple", invite_code: "PRIOR123", created_at: profile.created_at },
partner: { ...profile, id: "prior-partner", name: "Prior Partner" },
sealedCount: 4,
isAuthenticated: true,
isAnonymous: true,
pendingAppleDisplayName: { userId: "me", name: "Prior Name" },
} as any);
jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({
profile: { ...profile, id: "different-user", name: "Wrong Subject" },
});
await useAuthStore.getState().fetchUser();
expect(useAuthStore.getState()).toMatchObject({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
pendingAppleDisplayName: null,
});
expect(useMatchStore.getState().matches).toEqual([]);
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
expect(authClient.auth.signOut).not.toHaveBeenCalled();
expect(coupleApi.getStateWithAccessToken).not.toHaveBeenCalled();
});
it("does not sign out a newer session when an older request is rejected", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
let rejectA!: (error: Error) => void;
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
.mockImplementationOnce(() => new Promise((_, reject) => { rejectA = reject; }))
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
const firstFetch = useAuthStore.getState().fetchUser();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await Promise.resolve();
await Promise.resolve();
await secondFetch;
rejectA(new ApiError("Unauthorized", 401));
await firstFetch;
expect(getSpy).toHaveBeenCalledTimes(2);
expect(authClient.auth.signOut).not.toHaveBeenCalled();
expect(useAuthStore.getState().user).toMatchObject({ id: "user-b", name: "Newer Name" });
});
it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
@@ -158,4 +280,14 @@ describe("authStore", () => {
isAuthenticated: false,
});
});
it('keeps a pending Apple name scoped to its original subject', () => {
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
expect(useAuthStore.getState().pendingAppleDisplayName).toEqual({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('apple-user');
expect(useAuthStore.getState().pendingAppleDisplayName).toBeNull();
});
});