Files
Sauci/apps/api/test/billing.routes.test.ts
T
jjbrunton 006761d3b0 feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
2026-08-28 14:42:11 +01:00

142 lines
5.9 KiB
TypeScript

import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { BillingRepository } from '../src/domains/billing/repository.js';
import { registerBillingRoutes } from '../src/domains/billing/routes.js';
import { BillingService } from '../src/domains/billing/service.js';
import { BillingError, type RevenueCatWebhook } from '../src/domains/billing/types.js';
const payload: RevenueCatWebhook = {
api_version: '1.0',
event: {
type: 'INITIAL_PURCHASE',
id: 'event-1',
app_user_id: '11111111-1111-4111-8111-111111111111',
original_app_user_id: '11111111-1111-4111-8111-111111111111',
product_id: 'sauci.pro.monthly',
entitlement_ids: ['Sauci Pro'],
purchased_at_ms: 1_787_824_800_000,
expiration_at_ms: 1_790_503_200_000,
original_transaction_id: 'transaction-1',
store: 'APP_STORE',
environment: 'SANDBOX',
},
};
function repository(): BillingRepository {
return {
processRevenueCatEvent: vi.fn(async () => ({ duplicate: false, handled: true })),
redeem: vi.fn(async () => ({ success: true as const, message: 'redeemed' })),
close: vi.fn(async () => undefined),
};
}
function appWith(service: BillingService) {
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
registerBillingRoutes(app, service);
return app;
}
describe('billing routes', () => {
it('fails closed when the webhook secret is missing or incorrect', async () => {
const repo = repository();
const missing = await appWith(new BillingService(repo, undefined)).request('/webhooks/revenuecat', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload),
});
expect(missing.status).toBe(503);
const wrong = await appWith(new BillingService(repo, 'correct')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong' },
body: JSON.stringify(payload),
});
expect(wrong.status).toBe(401);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
});
it('rejects malformed webhook payloads before persistence', async () => {
const repo = repository();
const response = await appWith(new BillingService(repo, 'secret')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify({ event: { id: 'missing-fields' } }),
});
expect(response.status).toBe(400);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
});
it('rejects webhook authorization before attempting to parse JSON', async () => {
const repo = repository();
const response = await appWith(new BillingService(repo, 'secret')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong' },
body: '{not-json',
});
expect(response.status).toBe(401);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
});
it('bounds webhook and public redemption bodies', async () => {
const repo = repository();
const app = appWith(new BillingService(repo, 'secret'));
const webhook = await app.request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify({ padding: 'x'.repeat(1024 * 1024) }),
});
expect(webhook.status).toBe(413);
const redemption = await app.request('/public/v1/redemptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'alice@example.test', code: 'x'.repeat(16 * 1024) }),
});
expect(redemption.status).toBe(413);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
expect(repo.redeem).not.toHaveBeenCalled();
});
it('maps known events and acknowledges authenticated delivery', async () => {
const repo = repository();
const response = await appWith(new BillingService(repo, 'secret')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
expect(repo.processRevenueCatEvent).toHaveBeenCalledWith(payload, 'active');
});
it('validates public redemptions and preserves product error responses', async () => {
const repo = repository();
const app = appWith(new BillingService(repo, 'secret'));
expect((await app.request('/public/v1/redemptions', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: '', code: '' }),
})).status).toBe(400);
vi.mocked(repo.redeem).mockResolvedValueOnce({ success: false, error: 'Invalid redemption code' });
const response = await app.request('/public/v1/redemptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: ' alice@example.test ', code: ' promo ' }),
});
expect(response.status).toBe(400);
expect(repo.redeem).toHaveBeenCalledWith('alice@example.test', 'promo');
});
it('returns a bounded-throttle response without leaking internals', async () => {
const repo = repository();
vi.mocked(repo.redeem).mockRejectedValueOnce(
new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429),
);
const response = await appWith(new BillingService(repo, 'secret')).request('/public/v1/redemptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'alice@example.test', code: 'PROMO' }),
});
expect(response.status).toBe(429);
await expect(response.json()).resolves.toEqual({
success: false,
error: 'Too many redemption attempts. Please try again shortly.',
});
});
});