import AsyncStorage from '@react-native-async-storage/async-storage'; import { mockBackendService } from '../../services/backend/mockBackendService'; import { openAiScanService } from '../../services/backend/openAiScanService'; 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; }; const runHealthCheck = async (userId: string, idempotencyKey: string) => { const settledPromise = mockBackendService.healthCheck({ userId, idempotencyKey, imageUri: `data:image/jpeg;base64,${idempotencyKey}`, language: 'en', plantContext: { name: 'Monstera', botanicalName: 'Monstera deliciosa', careInfo: { waterIntervalDays: 7, light: 'Bright indirect light', temp: '18-24C', }, }, }).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.restoreAllMocks(); 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('charges one credit for a normal scan after a two-credit health check', async () => { const userId = 'test-user-health-then-scan-cost'; jest.spyOn(openAiScanService, 'isConfigured').mockReturnValue(true); jest.spyOn(openAiScanService, 'analyzePlantHealth').mockResolvedValue({ overallHealthScore: 72, status: 'watch', analysisSummary: 'Mild stress signs are visible.', likelyIssues: [ { title: 'Watering stress', confidence: 0.62, details: 'The leaf texture suggests inconsistent watering.', }, ], actionsNow: ['Check soil moisture before watering.'], plan7Days: ['Take a comparison photo in one week.'], }); await mockBackendService.simulatePurchase({ userId, idempotencyKey: 'sub-health-then-scan-cost', productId: 'monthly_pro', }); const healthCheck = await runHealthCheck(userId, 'health-cost-1'); expect(healthCheck.creditsCharged).toBe(2); expect(healthCheck.billing.credits.usedThisCycle).toBe(2); const scan = await runScan(userId, 'scan-after-health-cost-1'); expect(scan.modelPath).toContain('mock-review'); expect(scan.creditsCharged).toBe(1); expect(scan.billing.credits.usedThisCycle).toBe(3); }); 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); }); });