From f78e0a033550d839b9283ef6997579831cbc54ff Mon Sep 17 00:00:00 2001 From: authenticeasy-sys Date: Sat, 27 Jun 2026 17:23:28 +0000 Subject: [PATCH] fix: server-validate restore receipts (#615) and scope form cache by userId (#616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #615 – restorePurchases server validation - Each restored purchase is now POST'd to /payments/validate before updating subscription state. - Only purchases with validated: true update state; invalid receipts are logged and skipped. - _setTier is called for the most-recently-validated restored subscription. - Fixed stale log.error calls -> appLogger.errorSync throughout the service. Issue #616 – useFormCache user-scoped cache key - Added getFormCacheStorageKey(userId) which returns `@teachlink/form-cache/${userId}/v1`. - All formCache functions now accept a storageKey parameter so data is isolated per user. - useFormCache reads userId from useAppStore and derives storageKey via useMemo; cache reloads automatically when userId changes (user switch). Tests - src/__tests__/services/mobilePayments.test.ts: valid/invalid receipt filtering, finishTransaction only for valid, tier update. - src/__tests__/services/formCache.test.ts: user-scoped key isolation. - Updated tests/services/formCache.test.ts and tests/hooks/useFormCache.test.ts to match new signatures. Closes #615 Closes #616 --- src/__tests__/services/formCache.test.ts | 81 ++++++++++++++ src/__tests__/services/mobilePayments.test.ts | 105 ++++++++++++++++++ src/hooks/useFormCache.ts | 22 ++-- src/services/formCache.ts | 47 +++++--- src/services/mobilePayments.ts | 33 +++++- tests/hooks/useFormCache.test.ts | 20 +++- tests/services/formCache.test.ts | 28 +++-- 7 files changed, 294 insertions(+), 42 deletions(-) create mode 100644 src/__tests__/services/formCache.test.ts create mode 100644 src/__tests__/services/mobilePayments.test.ts diff --git a/src/__tests__/services/formCache.test.ts b/src/__tests__/services/formCache.test.ts new file mode 100644 index 00000000..05a9d23d --- /dev/null +++ b/src/__tests__/services/formCache.test.ts @@ -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'; + +jest.mock('@react-native-async-storage/async-storage'); + +const mockStorage = AsyncStorage as jest.Mocked; + +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); + }); +}); diff --git a/src/__tests__/services/mobilePayments.test.ts b/src/__tests__/services/mobilePayments.test.ts new file mode 100644 index 00000000..da8623d8 --- /dev/null +++ b/src/__tests__/services/mobilePayments.test.ts @@ -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; +const mockApi = apiService as jest.Mocked; +const mockStorage = AsyncStorage as jest.Mocked; + +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'); + }); +}); diff --git a/src/hooks/useFormCache.ts b/src/hooks/useFormCache.ts index b5d53d47..03b68936 100644 --- a/src/hooks/useFormCache.ts +++ b/src/hooks/useFormCache.ts @@ -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>>( {} ); @@ -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]); @@ -54,11 +60,11 @@ export function useFormCache(fieldKeys: FormCacheFieldKey[]) { const _commitWrite = useCallback( async (values: Partial>) => { - await cacheFormValues(values); + await cacheFormValues(storageKey, values); writeCountRef.current += 1; await refresh(); }, - [refresh] + [storageKey, refresh] ); const persistFields = useCallback( @@ -115,10 +121,10 @@ export function useFormCache(fieldKeys: FormCacheFieldKey[]) { ); const clearCache = useCallback(async () => { - await clearFormCache(); + await clearFormCache(storageKey); setPrefillValues({}); setCacheStore({}); - }, []); + }, [storageKey]); return { prefillValues, diff --git a/src/services/formCache.ts b/src/services/formCache.ts index 2d3b117e..5235908d 100644 --- a/src/services/formCache.ts +++ b/src/services/formCache.ts @@ -1,6 +1,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -/** AsyncStorage key for the form value cache (versioned for future migrations). */ +/** 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. */ export const FORM_CACHE_STORAGE_KEY = '@teachlink/form-cache/v1'; /** Cached entries older than this are pruned on read/write (90 days). */ @@ -46,14 +51,14 @@ export function pruneExpiredCache(store: FormCacheStore, now = Date.now()): Form return pruned; } -export async function loadFormCache(): Promise { - const raw = await AsyncStorage.getItem(FORM_CACHE_STORAGE_KEY); +export async function loadFormCache(storageKey: string): Promise { + 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 { @@ -61,21 +66,25 @@ export async function loadFormCache(): Promise { } } -export async function saveFormCache(store: FormCacheStore): Promise { - await AsyncStorage.setItem(FORM_CACHE_STORAGE_KEY, JSON.stringify(store)); +export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise { + await AsyncStorage.setItem(storageKey, JSON.stringify(store)); } -export async function getCachedFieldValue(key: FormCacheFieldKey): Promise { - const store = await loadFormCache(); +export async function getCachedFieldValue( + storageKey: string, + key: FormCacheFieldKey +): Promise { + 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>> { - const store = await loadFormCache(); + const store = await loadFormCache(storageKey); const result: Partial> = {}; for (const key of keys) { const entry = store[key]; @@ -86,19 +95,24 @@ export async function getCachedFieldValues( return result; } -export async function setCachedFieldValue(key: FormCacheFieldKey, value: string): Promise { +export async function setCachedFieldValue( + storageKey: string, + key: FormCacheFieldKey, + value: string +): Promise { 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> ): Promise { - const store = await loadFormCache(); + const store = await loadFormCache(storageKey); const now = Date.now(); for (const [key, value] of Object.entries(values) as [FormCacheFieldKey, string][]) { @@ -107,7 +121,7 @@ export async function cacheFormValues( store[key] = { value: trimmed, updatedAt: now }; } - await saveFormCache(pruneExpiredCache(store)); + await saveFormCache(storageKey, pruneExpiredCache(store)); } export function getSuggestionForField( @@ -123,8 +137,8 @@ export function getSuggestionForField( return suggestion; } -export async function clearFormCache(): Promise { - await AsyncStorage.removeItem(FORM_CACHE_STORAGE_KEY); +export async function clearFormCache(storageKey: string): Promise { + await AsyncStorage.removeItem(storageKey); } /** Maps profile/edit labels to shared cache keys. */ @@ -137,6 +151,7 @@ export const PROFILE_FORM_CACHE_KEYS: FormCacheFieldKey[] = [ ]; export const formCacheService = { + getFormCacheStorageKey, loadFormCache, getCachedFieldValue, getCachedFieldValues, diff --git a/src/services/mobilePayments.ts b/src/services/mobilePayments.ts index f625023c..b94c9c74 100644 --- a/src/services/mobilePayments.ts +++ b/src/services/mobilePayments.ts @@ -227,7 +227,10 @@ class MobilePaymentsService { }; }); } catch (error) { - log.error('[Payments] getProducts error:', error); + appLogger.errorSync( + '[Payments] getProducts error', + error instanceof Error ? error : new Error(String(error)) + ); return SUBSCRIPTION_PLANS.filter(p => productIds.includes(p.productId)); } } @@ -269,7 +272,10 @@ class MobilePaymentsService { await this._setTier(plan.tier); return record; } catch (error) { - log.error('[Payments] purchaseSubscription error:', error); + appLogger.errorSync( + '[Payments] purchaseSubscription error', + error instanceof Error ? error : new Error(String(error)) + ); throw error; } } @@ -296,7 +302,10 @@ class MobilePaymentsService { await this._savePurchaseRecord(record); return record; } catch (error) { - log.error('[Payments] purchaseProduct error:', error); + appLogger.errorSync( + '[Payments] purchaseProduct error', + error instanceof Error ? error : new Error(String(error)) + ); throw error; } } @@ -336,10 +345,23 @@ class MobilePaymentsService { receiptData: receipt, }); await IAP.finishTransaction({ purchase, isConsumable: false }); + } else { + appLogger.infoSync( + `[Payments] restorePurchases: invalid receipt skipped for ${purchase.productId}` + ); } } } + // Update subscription tier for the most recently valid restored subscription + const activeSub = validated + .filter(p => p.type === 'subscription') + .sort((a, b) => new Date(b.purchasedAt).getTime() - new Date(a.purchasedAt).getTime())[0]; + if (activeSub) { + const plan = SUBSCRIPTION_PLANS.find(p => p.productId === activeSub.productId); + if (plan) await this._setTier(plan.tier); + } + if (validated.length === 0) { // Fallback to local history for development const history = await this.getPurchaseHistory(); @@ -364,7 +386,10 @@ class MobilePaymentsService { return validated; } catch (error) { - log.error('[Payments] restorePurchases error:', error); + appLogger.errorSync( + '[Payments] restorePurchases error', + error instanceof Error ? error : new Error(String(error)) + ); throw error; } } diff --git a/tests/hooks/useFormCache.test.ts b/tests/hooks/useFormCache.test.ts index a2152a2b..0634054a 100644 --- a/tests/hooks/useFormCache.test.ts +++ b/tests/hooks/useFormCache.test.ts @@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { act, renderHook, waitFor } from '@testing-library/react-native'; import { useFormCache } from '../../src/hooks/useFormCache'; -import { FORM_CACHE_STORAGE_KEY } from '../../src/services/formCache'; +import { getFormCacheStorageKey } from '../../src/services/formCache'; jest.mock('@react-native-async-storage/async-storage', () => ({ getItem: jest.fn(), @@ -11,10 +11,18 @@ jest.mock('@react-native-async-storage/async-storage', () => ({ removeItem: jest.fn(), })); +// Mock the app store so we control the userId +jest.mock('../../src/store', () => ({ + useAppStore: (selector: (state: { user: { id: string } | null }) => unknown) => + selector({ user: { id: 'test-user-1' } }), +})); + const mockGetItem = AsyncStorage.getItem as jest.Mock; const mockSetItem = AsyncStorage.setItem as jest.Mock; const mockRemoveItem = AsyncStorage.removeItem as jest.Mock; +const EXPECTED_KEY = getFormCacheStorageKey('test-user-1'); + beforeEach(() => { jest.clearAllMocks(); mockGetItem.mockResolvedValue(null); @@ -40,6 +48,12 @@ describe('useFormCache', () => { expect(result.current.prefillValues.email).toBe('c@d.com'); }); + it('reads from the user-scoped storage key', async () => { + const { result } = renderHook(() => useFormCache(['fullName'])); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockGetItem).toHaveBeenCalledWith(EXPECTED_KEY); + }); + it('applyPrefillToFields only fills empty fields', async () => { const now = Date.now(); mockGetItem.mockResolvedValue( @@ -67,7 +81,7 @@ describe('useFormCache', () => { expect(setEmail).toHaveBeenCalledWith('cached@e.com'); }); - it('clearCache removes storage and resets state', async () => { + it('clearCache removes the user-scoped storage key and resets state', async () => { const { result } = renderHook(() => useFormCache(['fullName'])); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -76,7 +90,7 @@ describe('useFormCache', () => { await result.current.clearCache(); }); - expect(mockRemoveItem).toHaveBeenCalledWith(FORM_CACHE_STORAGE_KEY); + expect(mockRemoveItem).toHaveBeenCalledWith(EXPECTED_KEY); expect(result.current.prefillValues).toEqual({}); }); diff --git a/tests/services/formCache.test.ts b/tests/services/formCache.test.ts index 2bbc3b96..26679099 100644 --- a/tests/services/formCache.test.ts +++ b/tests/services/formCache.test.ts @@ -4,9 +4,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { cacheFormValues, clearFormCache, - FORM_CACHE_STORAGE_KEY, FORM_CACHE_TTL_MS, getCachedFieldValue, + getFormCacheStorageKey, getSuggestionForField, isExpired, loadFormCache, @@ -24,6 +24,8 @@ const mockGetItem = AsyncStorage.getItem as jest.Mock; const mockSetItem = AsyncStorage.setItem as jest.Mock; const mockRemoveItem = AsyncStorage.removeItem as jest.Mock; +const TEST_KEY = getFormCacheStorageKey('test-user'); + beforeEach(() => { jest.clearAllMocks(); mockGetItem.mockResolvedValue(null); @@ -60,7 +62,11 @@ describe('formCache', () => { describe('getSuggestionForField', () => { it('returns null when current matches cache', () => { - const s = getSuggestionForField({ email: { value: 'a@b.com', updatedAt: Date.now() } }, 'email', 'a@b.com'); + const s = getSuggestionForField( + { email: { value: 'a@b.com', updatedAt: Date.now() } }, + 'email', + 'a@b.com' + ); expect(s).toBeNull(); }); @@ -77,7 +83,7 @@ describe('formCache', () => { describe('loadFormCache', () => { it('returns empty object when storage is empty', async () => { mockGetItem.mockResolvedValueOnce(null); - expect(await loadFormCache()).toEqual({}); + expect(await loadFormCache(TEST_KEY)).toEqual({}); }); it('parses valid JSON and persists prune when expired keys exist', async () => { @@ -87,7 +93,7 @@ describe('formCache', () => { city: { value: 'gone', updatedAt: now - FORM_CACHE_TTL_MS - 10 }, }); mockGetItem.mockResolvedValueOnce(raw); - const result = await loadFormCache(); + const result = await loadFormCache(TEST_KEY); expect(result.city).toBeUndefined(); expect(result.email?.value).toBe('keep@x.com'); expect(mockSetItem).toHaveBeenCalled(); @@ -97,23 +103,23 @@ describe('formCache', () => { describe('setCachedFieldValue', () => { it('does not persist empty strings', async () => { mockGetItem.mockResolvedValueOnce('{}'); - await setCachedFieldValue('fullName', ' '); + await setCachedFieldValue(TEST_KEY, 'fullName', ' '); expect(mockSetItem).not.toHaveBeenCalled(); }); it('merges with existing store', async () => { mockGetItem.mockResolvedValueOnce(JSON.stringify({})); - await setCachedFieldValue('fullName', 'Jane'); + await setCachedFieldValue(TEST_KEY, 'fullName', 'Jane'); const written = JSON.parse(mockSetItem.mock.calls[0][1] as string); expect(written.fullName.value).toBe('Jane'); - expect(mockSetItem.mock.calls[0][0]).toBe(FORM_CACHE_STORAGE_KEY); + expect(mockSetItem.mock.calls[0][0]).toBe(TEST_KEY); }); }); describe('cacheFormValues', () => { it('writes multiple keys', async () => { mockGetItem.mockResolvedValueOnce('{}'); - await cacheFormValues({ fullName: 'A', email: 'a@b.com' }); + await cacheFormValues(TEST_KEY, { fullName: 'A', email: 'a@b.com' }); const written = JSON.parse(mockSetItem.mock.calls[0][1] as string); expect(written.fullName.value).toBe('A'); expect(written.email.value).toBe('a@b.com'); @@ -123,14 +129,14 @@ describe('formCache', () => { describe('getCachedFieldValue', () => { it('returns null for missing key', async () => { mockGetItem.mockResolvedValueOnce('{}'); - expect(await getCachedFieldValue('phone')).toBeNull(); + expect(await getCachedFieldValue(TEST_KEY, 'phone')).toBeNull(); }); }); describe('clearFormCache', () => { it('removes storage key', async () => { - await clearFormCache(); - expect(mockRemoveItem).toHaveBeenCalledWith(FORM_CACHE_STORAGE_KEY); + await clearFormCache(TEST_KEY); + expect(mockRemoveItem).toHaveBeenCalledWith(TEST_KEY); }); }); });