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
81 changes: 81 additions & 0 deletions src/__tests__/services/formCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Tests for #616: useFormCache cache key is user-scoped.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';

import {
cacheFormValues,
clearFormCache,
getCachedFieldValues,
getFormCacheStorageKey,
loadFormCache,
} from '../../services/formCache';

Check failure on line 12 in src/__tests__/services/formCache.test.ts

View workflow job for this annotation

GitHub Actions / ci

Parse errors in imported module '../../services/formCache': '}' expected. (170:0)

jest.mock('@react-native-async-storage/async-storage');

const mockStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;

describe('formCache – user-scoped storage key (#616)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockStorage.getItem.mockResolvedValue(null);
mockStorage.setItem.mockResolvedValue(undefined);
mockStorage.removeItem.mockResolvedValue(undefined);
});

it('generates distinct keys for different users', () => {
expect(getFormCacheStorageKey('user-1')).not.toBe(getFormCacheStorageKey('user-2'));
});

it('generates key that includes the userId', () => {
expect(getFormCacheStorageKey('abc123')).toContain('abc123');
});

it('uses user-scoped key when reading cache', async () => {
const key = getFormCacheStorageKey('user-1');
await loadFormCache(key);
expect(mockStorage.getItem).toHaveBeenCalledWith(key);
});

it('uses user-scoped key when writing cache', async () => {
const key = getFormCacheStorageKey('user-1');
await cacheFormValues(key, { fullName: 'Alice' });
expect(mockStorage.setItem).toHaveBeenCalledWith(key, expect.any(String));
});

it('does not read or write to another user key', async () => {
const keyA = getFormCacheStorageKey('user-A');
const keyB = getFormCacheStorageKey('user-B');

await cacheFormValues(keyA, { fullName: 'Alice' });

const allSetCalls = mockStorage.setItem.mock.calls.map(([k]) => k);
expect(allSetCalls).not.toContain(keyB);
});

it('reads from the correct user key and ignores another user data', async () => {
const keyA = getFormCacheStorageKey('user-A');
const keyB = getFormCacheStorageKey('user-B');

// Seed user-A cache with data
const now = Date.now();
mockStorage.getItem.mockImplementation(k => {
if (k === keyA)
return Promise.resolve(JSON.stringify({ fullName: { value: 'Alice', updatedAt: now } }));
return Promise.resolve(null);
});

const valuesA = await getCachedFieldValues(keyA, ['fullName']);
const valuesB = await getCachedFieldValues(keyB, ['fullName']);

expect(valuesA.fullName).toBe('Alice');
expect(valuesB.fullName).toBeUndefined();
});

it('clears only the correct user key', async () => {
const keyA = getFormCacheStorageKey('user-A');
await clearFormCache(keyA);
expect(mockStorage.removeItem).toHaveBeenCalledWith(keyA);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(1);
});
});
105 changes: 105 additions & 0 deletions src/__tests__/services/mobilePayments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Tests for #615: restorePurchases validates server-side before updating state.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as IAP from 'react-native-iap';

import { apiService } from '../../services/api';
import { mobilePaymentsService, PRODUCT_IDS } from '../../services/mobilePayments';

jest.mock('@react-native-async-storage/async-storage');
jest.mock('react-native-iap');
jest.mock('../../services/api', () => ({
apiService: { post: jest.fn() },
}));
jest.mock('../../utils/logger', () => ({
appLogger: { errorSync: jest.fn(), infoSync: jest.fn(), debugSync: jest.fn() },
}));
jest.mock('../../store/deviceStore', () => ({
useDeviceStore: { getState: () => ({ isDeviceCompromised: false }) },
}));

const mockIAP = IAP as jest.Mocked<typeof IAP>;
const mockApi = apiService as jest.Mocked<typeof apiService>;
const mockStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;

const makePurchase = (productId: string, receipt: string) => ({
productId,
transactionId: `txn_${productId}`,
transactionReceipt: receipt,
transactionDate: new Date().toISOString(),
priceAmountMicros: 9_990_000,
priceCurrencyCode: 'USD',
});

describe('mobilePaymentsService.restorePurchases (#615)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockStorage.getItem.mockResolvedValue(null);
mockStorage.setItem.mockResolvedValue(undefined);
mockIAP.finishTransaction = jest.fn().mockResolvedValue(undefined);
});

it('only returns purchases with validated: true from server', async () => {
const validPurchase = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'valid-receipt');
const invalidPurchase = makePurchase(PRODUCT_IDS.PREMIUM_MONTHLY, 'invalid-receipt');

mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([validPurchase, invalidPurchase]);

mockApi.post.mockImplementation((_path, body) => {
if ((body as { receipt: string }).receipt === 'valid-receipt') {
return Promise.resolve({ data: { valid: true, tier: 'pro' } });
}
return Promise.resolve({ data: { valid: false } });
});

const result = await mobilePaymentsService.restorePurchases();

expect(result).toHaveLength(1);
expect(result[0].productId).toBe(PRODUCT_IDS.PRO_MONTHLY);
expect(result[0].status).toBe('restored');
});

it('calls finishTransaction only for valid purchases', async () => {
const validPurchase = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'valid-receipt');
const invalidPurchase = makePurchase(PRODUCT_IDS.PRO_ANNUAL, 'invalid-receipt');

mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([validPurchase, invalidPurchase]);

mockApi.post.mockImplementation((_path, body) => {
const valid = (body as { receipt: string }).receipt === 'valid-receipt';
return Promise.resolve({ data: { valid } });
});

await mobilePaymentsService.restorePurchases();

expect(mockIAP.finishTransaction).toHaveBeenCalledTimes(1);
expect(mockIAP.finishTransaction).toHaveBeenCalledWith(
expect.objectContaining({ purchase: validPurchase })
);
});

it('returns empty array when all receipts are invalid', async () => {
const p1 = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'bad-1');
const p2 = makePurchase(PRODUCT_IDS.PRO_ANNUAL, 'bad-2');

mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([p1, p2]);
mockApi.post.mockResolvedValue({ data: { valid: false } });

const result = await mobilePaymentsService.restorePurchases();

// Falls back to local history (empty), so final result is []
expect(result).toHaveLength(0);
expect(mockIAP.finishTransaction).not.toHaveBeenCalled();
});

it('updates subscription tier for the valid restored subscription', async () => {
const validPurchase = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'valid-receipt');
mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([validPurchase]);
mockApi.post.mockResolvedValue({ data: { valid: true, tier: 'pro' } });

await mobilePaymentsService.restorePurchases();

expect(mockStorage.setItem).toHaveBeenCalledWith('@teachlink:subscription_tier', 'pro');
});
});
22 changes: 14 additions & 8 deletions src/hooks/useFormCache.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import {
cacheFormValues,
clearFormCache,
formCacheService,
getCachedFieldValues,
getFormCacheStorageKey,
type FormCacheFieldKey,
type FormCacheStore,
loadFormCache,
} from '../services/formCache';
import { useAppStore } from '../store';

const DEBOUNCE_MS = 800;

export function useFormCache(fieldKeys: FormCacheFieldKey[]) {
const userId = useAppStore(state => state.user?.id);
const storageKey = useMemo(() => getFormCacheStorageKey(userId ?? 'anonymous'), [userId]);

const [prefillValues, setPrefillValues] = useState<Partial<Record<FormCacheFieldKey, string>>>(
{}
);
Expand All @@ -31,14 +36,15 @@ export function useFormCache(fieldKeys: FormCacheFieldKey[]) {
const refresh = useCallback(async () => {
setIsLoading(true);
const [values, store] = await Promise.all([
getCachedFieldValues(stableFieldKeysRef.current),
loadFormCache(),
getCachedFieldValues(storageKey, stableFieldKeysRef.current),
loadFormCache(storageKey),
]);
setPrefillValues(values);
setCacheStore(store);
setIsLoading(false);
}, []);
}, [storageKey]);

// Re-load cache when userId changes (user switches account)
useEffect(() => {
void refresh();
}, [refresh]);
Expand All @@ -54,11 +60,11 @@ export function useFormCache(fieldKeys: FormCacheFieldKey[]) {

const _commitWrite = useCallback(
async (values: Partial<Record<FormCacheFieldKey, string>>) => {
await cacheFormValues(values);
await cacheFormValues(storageKey, values);
writeCountRef.current += 1;
await refresh();
},
[refresh]
[storageKey, refresh]
);

const persistFields = useCallback(
Expand Down Expand Up @@ -115,10 +121,10 @@ export function useFormCache(fieldKeys: FormCacheFieldKey[]) {
);

const clearCache = useCallback(async () => {
await clearFormCache();
await clearFormCache(storageKey);
setPrefillValues({});
setCacheStore({});
}, []);
}, [storageKey]);

return {
prefillValues,
Expand Down
44 changes: 31 additions & 13 deletions src/services/formCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import AsyncStorage from '@react-native-async-storage/async-storage';

/** Returns an AsyncStorage key scoped to the given user (versioned for future migrations). */
export function getFormCacheStorageKey(userId: string): string {
return `@teachlink/form-cache/${userId}/v1`;
}

/** @deprecated Use getFormCacheStorageKey(userId) instead. Kept for migration only. */
import { safeStorageWrite } from '../utils/storage';

/** AsyncStorage key for the form value cache (versioned for future migrations). */
Expand Down Expand Up @@ -48,36 +54,42 @@ export function pruneExpiredCache(store: FormCacheStore, now = Date.now()): Form
return pruned;
}

export async function loadFormCache(): Promise<FormCacheStore> {
const raw = await AsyncStorage.getItem(FORM_CACHE_STORAGE_KEY);
export async function loadFormCache(storageKey: string): Promise<FormCacheStore> {
const raw = await AsyncStorage.getItem(storageKey);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as FormCacheStore;
const pruned = pruneExpiredCache(parsed);
if (Object.keys(pruned).length !== Object.keys(parsed).length) {
await saveFormCache(pruned);
await saveFormCache(storageKey, pruned);
}
return pruned;
} catch {
return {};
}
}

export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise<void> {
await AsyncStorage.setItem(storageKey, JSON.stringify(store));
export async function saveFormCache(store: FormCacheStore): Promise<void> {
await safeStorageWrite(FORM_CACHE_STORAGE_KEY, JSON.stringify(store));
}

export async function getCachedFieldValue(key: FormCacheFieldKey): Promise<string | null> {
const store = await loadFormCache();
export async function getCachedFieldValue(
storageKey: string,
key: FormCacheFieldKey
): Promise<string | null> {
const store = await loadFormCache(storageKey);
const entry = store[key];
if (!entry || isExpired(entry)) return null;
return entry.value;
}

export async function getCachedFieldValues(
storageKey: string,
keys: FormCacheFieldKey[]
): Promise<Partial<Record<FormCacheFieldKey, string>>> {
const store = await loadFormCache();
const store = await loadFormCache(storageKey);
const result: Partial<Record<FormCacheFieldKey, string>> = {};
for (const key of keys) {
const entry = store[key];
Expand All @@ -88,19 +100,24 @@ export async function getCachedFieldValues(
return result;
}

export async function setCachedFieldValue(key: FormCacheFieldKey, value: string): Promise<void> {
export async function setCachedFieldValue(
storageKey: string,
key: FormCacheFieldKey,
value: string
): Promise<void> {
const trimmed = value.trim();
if (!trimmed || SENSITIVE_FIELD_KEYS.includes(key)) return;

const store = await loadFormCache();
const store = await loadFormCache(storageKey);
store[key] = { value: trimmed, updatedAt: Date.now() };
await saveFormCache(pruneExpiredCache(store));
await saveFormCache(storageKey, pruneExpiredCache(store));
}

export async function cacheFormValues(
storageKey: string,
values: Partial<Record<FormCacheFieldKey, string>>
): Promise<void> {
const store = await loadFormCache();
const store = await loadFormCache(storageKey);
const now = Date.now();

for (const [key, value] of Object.entries(values) as [FormCacheFieldKey, string][]) {
Expand All @@ -109,7 +126,7 @@ export async function cacheFormValues(
store[key] = { value: trimmed, updatedAt: now };
}

await saveFormCache(pruneExpiredCache(store));
await saveFormCache(storageKey, pruneExpiredCache(store));
}

export function getSuggestionForField(
Expand All @@ -125,8 +142,8 @@ export function getSuggestionForField(
return suggestion;
}

export async function clearFormCache(): Promise<void> {
await AsyncStorage.removeItem(FORM_CACHE_STORAGE_KEY);
export async function clearFormCache(storageKey: string): Promise<void> {
await AsyncStorage.removeItem(storageKey);
}

/** Maps profile/edit labels to shared cache keys. */
Expand All @@ -139,6 +156,7 @@ export const PROFILE_FORM_CACHE_KEYS: FormCacheFieldKey[] = [
];

export const formCacheService = {
getFormCacheStorageKey,
loadFormCache,
getCachedFieldValue,
getCachedFieldValues,
Expand Down
Loading
Loading