Browser/React Native-safe typed client for platform-service billing endpoints: plans, subscriptions, payments, and usage. Follows the same factory pattern as broadcast-client and survey-client. Any ByteLyst product can add billing with 5 lines of wiring code. 12 tests covering all methods, auth headers, error handling, and 404 null-return for missing subscriptions.
120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { createBillingClient, BillingApiError } from './index.js';
|
|
import type { BillingClient } from './index.js';
|
|
|
|
function mockFetch(status: number, body: unknown) {
|
|
return vi.fn().mockResolvedValue({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: () => Promise.resolve(body),
|
|
});
|
|
}
|
|
|
|
describe('createBillingClient', () => {
|
|
let client: BillingClient;
|
|
const config = {
|
|
baseUrl: 'http://localhost:4003/api',
|
|
productId: 'notelett',
|
|
getAccessToken: () => 'test-token',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
client = createBillingClient(config);
|
|
});
|
|
|
|
it('listPlans — returns plans array', async () => {
|
|
const plans = [{ name: 'free', displayName: 'Free', price: 0 }];
|
|
globalThis.fetch = mockFetch(200, { plans });
|
|
const result = await client.listPlans();
|
|
expect(result).toEqual(plans);
|
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:4003/api/plans',
|
|
expect.objectContaining({ method: 'GET' })
|
|
);
|
|
});
|
|
|
|
it('getPlan — returns single plan', async () => {
|
|
const plan = { name: 'pro', displayName: 'Pro', price: 9.99 };
|
|
globalThis.fetch = mockFetch(200, plan);
|
|
const result = await client.getPlan('pro');
|
|
expect(result).toEqual(plan);
|
|
});
|
|
|
|
it('getSubscription — returns subscription', async () => {
|
|
const sub = { id: 'sub_1', plan: 'free', status: 'active' };
|
|
globalThis.fetch = mockFetch(200, sub);
|
|
const result = await client.getSubscription();
|
|
expect(result).toEqual(sub);
|
|
});
|
|
|
|
it('getSubscription — returns null on 404', async () => {
|
|
globalThis.fetch = mockFetch(404, { message: 'Not found' });
|
|
const result = await client.getSubscription();
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('changePlan — sends plan in body', async () => {
|
|
const sub = { id: 'sub_1', plan: 'pro', status: 'active' };
|
|
globalThis.fetch = mockFetch(200, sub);
|
|
const result = await client.changePlan('pro');
|
|
expect(result.plan).toBe('pro');
|
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:4003/api/subscriptions/me/change-plan',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ plan: 'pro' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('cancelSubscription — POST to cancel endpoint', async () => {
|
|
const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true };
|
|
globalThis.fetch = mockFetch(200, sub);
|
|
const result = await client.cancelSubscription();
|
|
expect(result.cancelAtPeriodEnd).toBe(true);
|
|
});
|
|
|
|
it('resumeSubscription — POST to resume endpoint', async () => {
|
|
const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false };
|
|
globalThis.fetch = mockFetch(200, sub);
|
|
const result = await client.resumeSubscription();
|
|
expect(result.cancelAtPeriodEnd).toBe(false);
|
|
});
|
|
|
|
it('listPayments — returns payments array', async () => {
|
|
const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }];
|
|
globalThis.fetch = mockFetch(200, { payments });
|
|
const result = await client.listPayments();
|
|
expect(result).toEqual(payments);
|
|
});
|
|
|
|
it('getUsage — returns usage summary', async () => {
|
|
const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 };
|
|
globalThis.fetch = mockFetch(200, usage);
|
|
const result = await client.getUsage();
|
|
expect(result.tokensRemaining).toBe(9500);
|
|
});
|
|
|
|
it('sends auth header and product-id header', async () => {
|
|
globalThis.fetch = mockFetch(200, { plans: [] });
|
|
await client.listPlans();
|
|
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const headers = call[1].headers;
|
|
expect(headers['Authorization']).toBe('Bearer test-token');
|
|
expect(headers['x-product-id']).toBe('notelett');
|
|
});
|
|
|
|
it('throws BillingApiError on non-ok response', async () => {
|
|
globalThis.fetch = mockFetch(403, { message: 'Forbidden' });
|
|
await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError);
|
|
});
|
|
|
|
it('works without access token', async () => {
|
|
const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null });
|
|
globalThis.fetch = mockFetch(200, { plans: [] });
|
|
await noAuthClient.listPlans();
|
|
const headers = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
|
|
expect(headers['Authorization']).toBeUndefined();
|
|
});
|
|
});
|