144 lines
4.4 KiB
TypeScript
144 lines
4.4 KiB
TypeScript
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<string, string> = {};
|
|
|
|
const mockedAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
|
|
|
|
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);
|
|
});
|
|
});
|