import { describe, it, expect, vi, beforeEach } from 'vitest'; import worker, { type Env } from './index'; import { ALLOWED_PRODUCTS, extractLatestExpiry, acknowledgeSubscription, acknowledgeOneTimeProduct, } from './play-verification'; describe('Worker /billing/verify-play-purchase endpoint', () => { let mockEnv: Env; beforeEach(() => { mockEnv = { R2_BUCKET: {} as any, FIREBASE_PROJECT_ID: 'motoapp-d22e0', ENVIRONMENT: 'test', GOOGLE_SERVICE_ACCOUNT_EMAIL: 'test-sa@motoapp-d22e0.iam.gserviceaccount.com', GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3...\n-----END PRIVATE KEY-----', GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle', }; }); it('rejects malformed or empty JSON payload with 400', async () => { const req = new Request('https://worker.local/billing/verify-play-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'not-json', }); const res = await worker.fetch(req, mockEnv); expect(res.status).toBe(400); const body = await res.json() as any; expect(body.code).toBe('MALFORMED_REQUEST'); }); it('rejects oversized payload with 413', async () => { const req = new Request('https://worker.local/billing/verify-play-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json', 'content-length': '5000', }, body: JSON.stringify({ productId: 'test', purchaseToken: 'test' }), }); const res = await worker.fetch(req, mockEnv); expect(res.status).toBe(413); const body = await res.json() as any; expect(body.code).toBe('MALFORMED_REQUEST'); }); it('rejects missing or empty productId with 400', async () => { const req = new Request('https://worker.local/billing/verify-play-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purchaseToken: 'some_long_token_here_12345' }), }); const res = await worker.fetch(req, mockEnv); expect(res.status).toBe(400); const body = await res.json() as any; expect(body.code).toBe('INVALID_PRODUCT'); }); it('rejects unrecognized product ID with 400', async () => { const req = new Request('https://worker.local/billing/verify-play-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId: 'hacked_super_tier', purchaseToken: 'some_long_token_here_12345', }), }); const res = await worker.fetch(req, mockEnv); expect(res.status).toBe(400); const body = await res.json() as any; expect(body.code).toBe('INVALID_PRODUCT'); }); it('rejects short or missing purchaseToken with 400', async () => { const req = new Request('https://worker.local/billing/verify-play-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId: ALLOWED_PRODUCTS.PRO_MONTHLY, purchaseToken: 'short', }), }); const res = await worker.fetch(req, mockEnv); expect(res.status).toBe(400); const body = await res.json() as any; expect(body.code).toBe('INVALID_PURCHASE'); }); it('returns 503 if Google service account credentials are unconfigured', async () => { const envNoCreds: Env = { ...mockEnv, GOOGLE_SERVICE_ACCOUNT_EMAIL: undefined, GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: undefined, }; const req = new Request('https://worker.local/billing/verify-play-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId: ALLOWED_PRODUCTS.PRO_MONTHLY, purchaseToken: 'valid_looking_purchase_token_12345', }), }); const res = await worker.fetch(req, envNoCreds); expect(res.status).toBe(503); const body = await res.json() as any; expect(body.code).toBe('GOOGLE_TEMPORARILY_UNAVAILABLE'); }); }); describe('extractLatestExpiry', () => { it('handles empty or undefined line items', () => { expect(extractLatestExpiry(undefined, 'motorsignal_pro_monthly')).toEqual({ expiryTimeIso: null, expiryTimeMs: NaN, }); expect(extractLatestExpiry([], 'motorsignal_pro_monthly')).toEqual({ expiryTimeIso: null, expiryTimeMs: NaN, }); }); it('filters out non-matching products and selects latest expiry', () => { const lineItems = [ { productId: 'other_tier', expiryTime: '2026-12-31T00:00:00Z' }, { productId: 'motorsignal_pro_monthly', expiryTime: '2026-06-01T12:00:00Z' }, { productId: 'motorsignal_pro_monthly', expiryTime: '2026-07-01T12:00:00Z' }, { productId: 'motorsignal_pro_monthly', expiryTime: 'invalid-date' }, ]; const result = extractLatestExpiry(lineItems, 'motorsignal_pro_monthly'); expect(result.expiryTimeIso).toBe('2026-07-01T12:00:00.000Z'); expect(result.expiryTimeMs).toBe(Date.parse('2026-07-01T12:00:00Z')); }); }); describe('acknowledgeSubscription and acknowledgeOneTimeProduct', () => { it('acknowledgeSubscription succeeds on 200/204', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); try { const result = await acknowledgeSubscription( 'app.motorsignal.motorcycle', 'motorsignal_pro_monthly', 'token_123', 'access_token', 'hash123' ); expect(result.success).toBe(true); expect(result.isRetryable).toBe(false); } finally { globalThis.fetch = originalFetch; } }); it('acknowledgeSubscription handles idempotency on 400 already acknowledged', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ error: { message: 'Purchase already acknowledged' } }), { status: 400, headers: { 'Content-Type': 'application/json' }, }) ); try { const result = await acknowledgeSubscription( 'app.motorsignal.motorcycle', 'motorsignal_pro_monthly', 'token_123', 'access_token', 'hash123' ); expect(result.success).toBe(true); expect(result.isRetryable).toBe(false); } finally { globalThis.fetch = originalFetch; } }); it('acknowledgeSubscription flags 500 error as retryable', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn().mockResolvedValue( new Response('Internal Server Error', { status: 500 }) ); try { const result = await acknowledgeSubscription( 'app.motorsignal.motorcycle', 'motorsignal_pro_monthly', 'token_123', 'access_token', 'hash123' ); expect(result.success).toBe(false); expect(result.isRetryable).toBe(true); } finally { globalThis.fetch = originalFetch; } }); it('acknowledgeOneTimeProduct succeeds on 200', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); try { const result = await acknowledgeOneTimeProduct( 'app.motorsignal.motorcycle', 'motorsignal_pro_lifetime', 'token_123', 'access_token', 'hash123' ); expect(result.success).toBe(true); expect(result.isRetryable).toBe(false); } finally { globalThis.fetch = originalFetch; } }); });