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(25); expect(second.billing.credits.topupBalance).toBe(25); }); it('consumes plan credits before topup credits', async () => { const userId = 'test-user-credit-order'; 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(25); for (let i = 1; i <= 15; i += 1) { lastScan = await runScan(userId, `scan-order-${i}`); } expect(lastScan.billing.credits.usedThisCycle).toBe(15); expect(lastScan.billing.credits.topupBalance).toBe(24); }); 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'; const first = await runScan(userId, 'scan-abc'); const second = await runScan(userId, 'scan-abc'); expect(first.creditsCharged).toBe(1); expect(second.creditsCharged).toBe(1); expect(second.billing.credits.available).toBe(first.billing.credits.available); }); it('enforces free monthly credit limit', async () => { const userId = 'test-user-credit-limit'; let successfulScans = 0; let errorCode: string | null = null; for (let i = 0; i < 30; i += 1) { try { await runScan(userId, `scan-${i}`); successfulScans += 1; } catch (error) { errorCode = (error as { code?: string }).code || null; break; } } expect(errorCode).toBe('INSUFFICIENT_CREDITS'); expect(successfulScans).toBeGreaterThanOrEqual(7); expect(successfulScans).toBeLessThanOrEqual(15); }); });