MotorSignal-App/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts

227 lines
7.5 KiB
TypeScript

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;
}
});
});