learning_ai_common_plat/packages/billing-client/src/index.test.ts
saravanakumardb1 774244eaa2 feat(billing-client): add @bytelyst/billing-client shared package
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.
2026-04-13 10:28:29 -07:00

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