227 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|