import AsyncStorage from '@react-native-async-storage/async-storage'; import { mockBackendService } from '../../services/backend/mockBackendService'; jest.mock('@react-native-async-storage/async-storage', () => ({ getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), })); const asyncStorageMemory: Record = {}; const mockedAsyncStorage = AsyncStorage as jest.Mocked; const runScan = async (userId: string, idempotencyKey: string) => { const settledPromise = mockBackendService.scanPlant({ userId, idempotencyKey, imageUri: `data:image/jpeg;base64,${idempotencyKey}`, language: 'en', }).then( value => ({ ok: true as const, value }), error => ({ ok: false as const, error }), ); await Promise.resolve(); await jest.runAllTimersAsync(); const settled = await settledPromise; if (!settled.ok) throw settled.error; return settled.value; }; describe('mockBackendService billing simulation', () => { beforeEach(() => { jest.useFakeTimers(); Object.keys(asyncStorageMemory).forEach((key) => { delete asyncStorageMemory[key]; }); mockedAsyncStorage.getItem.mockImplementation(async (key: string) => { return key in asyncStorageMemory ? asyncStorageMemory[key] : null; }); mockedAsyncStorage.setItem.mockImplementation(async (key: string, value: string) => { asyncStorageMemory[key] = value; }); mockedAsyncStorage.removeItem.mockImplementation(async (key: string) => { delete asyncStorageMemory[key]; }); }); afterEach(() => { jest.useRealTimers(); jest.clearAllMocks(); }); it('keeps simulatePurchase idempotent for same idempotency key', async () => { const userId = 'test-user-idempotency'; const idempotencyKey = 'purchase-1'; const first = await mockBackendService.simulatePurchase({ userId, idempotencyKey, productId: 'topup_small', }); const second = await mockBackendService.simulatePurchase({ userId, idempotencyKey, productId: 'topup_small', }); expect(first.billing.credits.topupBalance).toBe(30); expect(second.billing.credits.topupBalance).toBe(30); }); it('consumes plan credits before topup credits', async () => { const userId = 'test-user-credit-order'; await mockBackendService.simulatePurchase({ userId, idempotencyKey: 'sub-order-1', productId: 'monthly_pro', }); await mockBackendService.simulatePurchase({ userId, idempotencyKey: 'topup-order-1', productId: 'topup_small', }); let lastScan = await runScan(userId, 'scan-order-0'); expect(lastScan.billing.credits.usedThisCycle).toBe(1); expect(lastScan.billing.credits.topupBalance).toBe(30); let scanIndex = 1; while ( lastScan.billing.credits.usedThisCycle < 100 && lastScan.billing.credits.topupBalance === 30 && scanIndex < 150 ) { lastScan = await runScan(userId, `scan-order-${scanIndex}`); scanIndex += 1; } if (lastScan.billing.credits.topupBalance === 30) { lastScan = await runScan(userId, `scan-order-${scanIndex}`); } expect(lastScan.billing.credits.usedThisCycle).toBe(100); expect(lastScan.billing.credits.topupBalance).toBeLessThan(30); expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0); }); it('can deplete all available credits via webhook simulation', async () => { const userId = 'test-user-deplete-credits'; await mockBackendService.simulatePurchase({ userId, idempotencyKey: 'topup-deplete-1', productId: 'topup_small', }); const response = await mockBackendService.simulateWebhook({ userId, idempotencyKey: 'webhook-deplete-1', event: 'credits_depleted', }); expect(response.billing.credits.available).toBe(0); expect(response.billing.credits.topupBalance).toBe(0); expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance); }); it('does not double-charge scan when idempotency key is reused', async () => { const userId = 'test-user-scan-idempotency'; await mockBackendService.simulatePurchase({ userId, idempotencyKey: 'sub-scan-idempotency', productId: 'monthly_pro', }); const first = await runScan(userId, 'scan-abc'); const second = await runScan(userId, 'scan-abc'); expect(first.creditsCharged).toBeGreaterThan(0); expect(second.creditsCharged).toBe(first.creditsCharged); expect(second.billing.credits.available).toBe(first.billing.credits.available); }); it('blocks free users from real scans', async () => { const userId = 'test-user-credit-limit'; let successfulScans = 0; let errorCode: string | null = null; try { await runScan(userId, 'scan-free-hard-paywall'); successfulScans += 1; } catch (error) { errorCode = (error as { code?: string }).code || null; } expect(errorCode).toBe('INSUFFICIENT_CREDITS'); expect(successfulScans).toBe(0); }); it('syncs pro entitlement from RevenueCat customer info', async () => { const response = await mockBackendService.syncRevenueCatState({ userId: 'test-user-rc-pro', customerInfo: { entitlements: { active: { pro: { productIdentifier: 'monthly_pro', expirationDate: '2026-04-30T00:00:00.000Z', }, }, }, nonSubscriptions: {}, }, }); expect(response.billing.entitlement.plan).toBe('pro'); expect(response.billing.entitlement.status).toBe('active'); expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z'); }); it('limits RevenueCat trial entitlement to trial credits', async () => { const response = await mockBackendService.syncRevenueCatState({ userId: 'test-user-rc-trial', customerInfo: { entitlements: { active: { pro: { productIdentifier: 'monthly_pro', expirationDate: '2026-04-30T00:00:00.000Z', periodType: 'TRIAL', }, }, }, nonSubscriptions: {}, }, }); expect(response.billing.entitlement.plan).toBe('pro'); expect(response.billing.credits.monthlyAllowance).toBe(30); expect(response.billing.credits.available).toBe(30); }); it('resets trial usage when RevenueCat trial converts to paid pro', async () => { const userId = 'test-user-rc-trial-converts'; await mockBackendService.syncRevenueCatState({ userId, customerInfo: { entitlements: { active: { pro: { productIdentifier: 'monthly_pro', expirationDate: '2026-04-30T00:00:00.000Z', periodType: 'TRIAL', }, }, }, nonSubscriptions: {}, }, }); const trialScan = await runScan(userId, 'trial-conversion-scan'); expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0); const paidResponse = await mockBackendService.syncRevenueCatState({ userId, customerInfo: { entitlements: { active: { pro: { productIdentifier: 'monthly_pro', expirationDate: '2026-05-30T00:00:00.000Z', periodType: 'NORMAL', }, }, }, nonSubscriptions: {}, }, }); expect(paidResponse.billing.credits.monthlyAllowance).toBe(100); expect(paidResponse.billing.credits.usedThisCycle).toBe(0); expect(paidResponse.billing.credits.available).toBe(100); }); it('credits RevenueCat top-up transactions only once', async () => { const userId = 'test-user-rc-topup'; await mockBackendService.syncRevenueCatState({ userId, customerInfo: { entitlements: { active: {} }, nonSubscriptions: { topup_small: [ { productIdentifier: 'topup_small', transactionIdentifier: 'rc-topup-1', }, ], }, }, }); const second = await mockBackendService.syncRevenueCatState({ userId, customerInfo: { entitlements: { active: {} }, nonSubscriptions: { topup_small: [ { productIdentifier: 'topup_small', transactionIdentifier: 'rc-topup-1', }, ], }, }, }); expect(second.billing.credits.topupBalance).toBe(30); }); it('ignores malformed pro entitlements coming from top-up customer info', async () => { const response = await mockBackendService.syncRevenueCatState({ userId: 'test-user-rc-topup-misconfigured-entitlement', source: 'topup_purchase', customerInfo: { entitlements: { active: { pro: { productIdentifier: 'topup_small', expirationDate: '2026-04-30T00:00:00.000Z', }, }, }, nonSubscriptions: { topup_small: [ { productIdentifier: 'topup_small', transactionIdentifier: 'rc-topup-malformed-1', }, ], }, }, }); expect(response.billing.entitlement.plan).toBe('free'); expect(response.billing.entitlement.status).toBe('inactive'); expect(response.billing.credits.topupBalance).toBe(30); expect(response.billing.credits.available).toBe(0); }); it('does not downgrade an existing pro user during a top-up sync', async () => { const userId = 'test-user-rc-pro-topup'; await mockBackendService.syncRevenueCatState({ userId, source: 'subscription_purchase', customerInfo: { entitlements: { active: { pro: { productIdentifier: 'monthly_pro', expirationDate: '2026-04-30T00:00:00.000Z', }, }, }, nonSubscriptions: {}, }, }); const response = await mockBackendService.syncRevenueCatState({ userId, source: 'topup_purchase', customerInfo: { entitlements: { active: { pro: { productIdentifier: 'topup_small', expirationDate: '2026-04-30T00:00:00.000Z', }, }, }, nonSubscriptions: { topup_small: [ { productIdentifier: 'topup_small', transactionIdentifier: 'rc-topup-pro-1', }, ], }, }, }); expect(response.billing.entitlement.plan).toBe('pro'); expect(response.billing.credits.available).toBe(130); expect(response.billing.credits.topupBalance).toBe(30); }); });