Initial import of zappier-edge from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 12:58:25 -04:00
commit b8d239e759
120 changed files with 19867 additions and 0 deletions

114
tests/accounts.test.ts Normal file
View file

@ -0,0 +1,114 @@
import {
base32Decode,
base32Encode,
generateTotpSecret,
hashPassword,
hotp,
InMemorySessionRepo,
totp,
totpUri,
verifyPassword,
verifyTotp,
} from '../src/accounts';
describe('password hashing (scrypt)', () => {
it('round-trips a correct password', () => {
const stored = hashPassword('correct horse battery staple');
expect(verifyPassword('correct horse battery staple', stored)).toBe(true);
});
it('rejects a wrong password', () => {
const stored = hashPassword('correct horse battery staple');
expect(verifyPassword('wrong', stored)).toBe(false);
});
it('uses a random salt per hash', () => {
expect(hashPassword('same')).not.toBe(hashPassword('same'));
});
it('rejects malformed stored hashes', () => {
expect(verifyPassword('x', 'not-a-hash')).toBe(false);
expect(verifyPassword('x', '')).toBe(false);
});
});
describe('base32', () => {
it('round-trips bytes', () => {
const buf = Buffer.from('hello world, this is base32');
expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true);
});
it('encodes without padding', () => {
expect(base32Encode(Buffer.from('f'))).toBe('MY');
expect(base32Encode(Buffer.from('fo'))).toBe('MZXQ');
});
});
describe('TOTP (RFC 6238)', () => {
// RFC 6238 SHA-1 seed, ASCII "12345678901234567890", base32-encoded.
const RFC_SEED_B32 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
it.each([
[59_000, '287082'],
[1_111_111_109_000, '081804'],
[1_234_567_890_000, '005924'],
])('produces the RFC 6-digit truncation at %ims', (atMs, expected) => {
expect(totp(RFC_SEED_B32, atMs)).toBe(expected);
});
it('hotp pads short codes to 6 digits', () => {
expect(hotp(RFC_SEED_B32, Math.floor(1_234_567_890 / 30))).toBe('005924');
});
it('verifyTotp accepts codes within the window and rejects outside', () => {
const at = 1_111_111_109_000;
const code = totp(RFC_SEED_B32, at);
expect(verifyTotp(RFC_SEED_B32, code, at)).toBe(true);
expect(verifyTotp(RFC_SEED_B32, code, at + 30_000)).toBe(true); // one step later, window 1
expect(verifyTotp(RFC_SEED_B32, code, at + 90_000)).toBe(false); // three steps later
});
it('verifyTotp rejects malformed codes', () => {
expect(verifyTotp(RFC_SEED_B32, '12345', 59_000)).toBe(false);
expect(verifyTotp(RFC_SEED_B32, 'abcdef', 59_000)).toBe(false);
});
it('generateTotpSecret returns a decodable 160-bit base32 secret', () => {
const secret = generateTotpSecret();
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
expect(base32Decode(secret).length).toBe(20);
});
it('totpUri builds an otpauth URI', () => {
const uri = totpUri('ABC234', 'ada@example.com', 'Zappier');
expect(uri).toBe(
'otpauth://totp/Zappier:ada%40example.com?secret=ABC234&issuer=Zappier',
);
});
});
describe('InMemorySessionRepo', () => {
it('creates and retrieves sessions within the TTL', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
expect(s.customerId).toBe('cust_1');
expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1');
});
it('expires sessions after the TTL', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined();
});
it('deletes sessions (logout)', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
repo.delete(s.token);
expect(repo.get(s.token, s.createdMs)).toBeUndefined();
});
it('returns undefined for unknown tokens', () => {
expect(new InMemorySessionRepo().get('nope')).toBeUndefined();
});
});

View file

@ -0,0 +1,207 @@
import request from 'supertest';
import { buildApp } from '../src/app';
import { UsageEntry } from '../src/usage';
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
const entry = (customerId: string, endpointId: string, cents: number, iso: string): UsageEntry => ({
customerId,
endpointId,
cents,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(iso),
});
function seededApp() {
const built = buildApp();
built.usage.record(entry('cust_2', 'transform', 3, '2026-07-05T10:00:00Z'));
built.usage.record(entry('cust_2', 'storage', 1500, '2026-07-06T10:00:00Z'));
built.usage.record(entry('cust_2', 'storage', 300, '2026-08-01T01:00:00Z'));
return built;
}
describe('admin accounting API', () => {
it('generates draft invoices for customers with usage, skips the rest', async () => {
const { app } = seededApp();
const res = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07' });
expect(res.status).toBe(200);
expect(res.body.generated).toEqual(['INV-2026-07-0001']);
expect(res.body.skipped.map((s: { customerId: string }) => s.customerId)).toEqual(
expect.arrayContaining(['cust_1', 'cust_3']),
);
const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN);
expect(inv.body.lines).toEqual([
{ endpointId: 'storage', calls: 1, cents: 1500 },
{ endpointId: 'transform', calls: 1, cents: 3 },
]);
expect(inv.body.totalCents).toBe(1503);
expect(inv.body.billableCents).toBe(503); // Pro credit 1000
});
it('regenerating a draft keeps the id; issued invoices are skipped', async () => {
const { app, usage } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
usage.record(entry('cust_2', 'transform', 3, '2026-07-20T10:00:00Z'));
const again = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07' });
expect(again.body.generated).toEqual(['INV-2026-07-0001']);
const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN);
expect(inv.body.totalCents).toBe(1506);
await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
const third = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07' });
expect(third.body.generated).toEqual([]);
expect(third.body.skipped).toEqual(
expect.arrayContaining([expect.objectContaining({ customerId: 'cust_2' })]),
);
});
it('walks the lifecycle draft → issued → paid and rejects illegal transitions', async () => {
const { app } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const payDraft = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN);
expect(payDraft.status).toBe(409);
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.status).toBe(200);
expect(issue.body.status).toBe('issued');
expect(issue.body.issuedAtMs).toEqual(expect.any(Number));
const reissue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(reissue.status).toBe(409);
const paid = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN);
expect(paid.status).toBe(200);
expect(paid.body.status).toBe('paid');
expect(paid.body.paidAtMs).toEqual(expect.any(Number));
});
it('sets a due date on issued purchase-order invoices', async () => {
const { app } = seededApp();
await request(app)
.put('/admin/api/customers/cust_2')
.set(ADMIN)
.send({ billingType: 'purchase_order' });
await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07', poNumber: 'PO-77' });
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.body.poNumber).toBe('PO-77');
expect(issue.body.dueAtMs - issue.body.issuedAtMs).toBe(30 * 24 * 60 * 60 * 1000);
});
it('rejects a malformed period with 400', async () => {
const { app } = seededApp();
const res = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: 'July 2026' });
expect(res.status).toBe(400);
});
it('lists invoices with filters and 404s unknown ids', async () => {
const { app } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const list = await request(app)
.get('/admin/api/invoices?customerId=cust_2&status=draft')
.set(ADMIN);
expect(list.body.invoices).toHaveLength(1);
const empty = await request(app).get('/admin/api/invoices?status=paid').set(ADMIN);
expect(empty.body.invoices).toHaveLength(0);
const missing = await request(app).get('/admin/api/invoices/INV-1999-01-0001').set(ADMIN);
expect(missing.status).toBe(404);
});
it('renders a print-ready HTML invoice', async () => {
const { app } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const res = await request(app)
.get('/admin/api/invoices/INV-2026-07-0001?format=html')
.set(ADMIN);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.text).toContain('INV-2026-07-0001');
expect(res.text).toContain('Grace (pro)');
});
it('serves the billing report as JSON and CSV with filters', async () => {
const { app } = seededApp();
const json = await request(app)
.get('/admin/api/reports/billing?from=2026-07-01&to=2026-08-01')
.set(ADMIN);
expect(json.status).toBe(200);
const grace = json.body.rows.find((r: { customerId: string }) => r.customerId === 'cust_2');
expect(grace).toMatchObject({ calls: 2, totalCents: 1503, billableCents: 503 });
const csv = await request(app)
.get('/admin/api/reports/billing?format=csv&customerId=cust_2')
.set(ADMIN);
expect(csv.headers['content-type']).toContain('text/csv');
expect(csv.text.split('\n')[0]).toBe('Customer Id,Name,Billing Type,Calls,Total Cents,Credit Cents,Billable Cents');
expect(csv.text).toContain('cust_2,Grace (pro),stripe,3,1803,1000,803');
await request(app)
.put('/admin/api/customers/cust_3')
.set(ADMIN)
.send({ billingType: 'purchase_order' });
const poOnly = await request(app)
.get('/admin/api/reports/billing?billingType=purchase_order')
.set(ADMIN);
expect(poOnly.body.rows.map((r: { customerId: string }) => r.customerId)).toEqual(['cust_3']);
});
it('serves the usage trend', async () => {
const { app } = seededApp();
const res = await request(app)
.get('/admin/api/reports/usage-trend?bucket=day')
.set(ADMIN);
expect(res.status).toBe(200);
expect(res.body.points).toEqual([
{ bucket: '2026-07-05', calls: 1, cents: 3 },
{ bucket: '2026-07-06', calls: 1, cents: 1500 },
{ bucket: '2026-08-01', calls: 1, cents: 300 },
]);
});
it('reports Zapier app status from the integration directory', async () => {
const { app } = seededApp();
const res = await request(app).get('/admin/api/zapier/status').set(ADMIN);
expect(res.status).toBe(200);
expect(res.body.triggers).toEqual(['new_item']);
expect(res.body.creates).toEqual(['store_data']);
});
it('draws down the prepaid balance at issue; full coverage marks the invoice paid', async () => {
const { app, customers } = seededApp();
const grace = customers.findByApiKey('key-grace')!;
customers.save({ ...grace, balanceCents: 600 });
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
// billable is 503c after the Pro credit
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.status).toBe(200);
expect(issue.body.status).toBe('paid');
expect(issue.body.paidAtMs).toEqual(expect.any(Number));
expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(97);
});
it('leaves the invoice issued and the balance untouched when coverage is partial', async () => {
const { app, customers } = seededApp();
const grace = customers.findByApiKey('key-grace')!;
customers.save({ ...grace, balanceCents: 100 });
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.status).toBe(200);
expect(issue.body.status).toBe('issued');
expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(100);
});
});

115
tests/admin-users.test.ts Normal file
View file

@ -0,0 +1,115 @@
import Database from 'better-sqlite3';
import request from 'supertest';
import { hashPassword } from '../src/accounts';
import { InMemoryAdminUserRepo, seedAdminUsersFromEnv } from '../src/admin-users';
import { buildApp } from '../src/app';
import { SqliteAdminUserRepo } from '../src/db/admin-user-repo';
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
describe('admin user repos', () => {
it('seeds from env defaults (in-memory)', () => {
const repo = InMemoryAdminUserRepo.seeded(seedAdminUsersFromEnv());
expect(repo.findByUsername('admin')?.active).toBe(true);
expect(repo.findByUsername('demo')?.active).toBe(true);
expect(repo.findByUsername('admin')?.passwordHash).toMatch(/^scrypt:/);
});
it('seeds from env defaults (sqlite) and persists', () => {
const db = new Database(':memory:');
const first = SqliteAdminUserRepo.seeded(db, seedAdminUsersFromEnv());
first.save({
id: 'usr_extra',
username: 'ops',
passwordHash: hashPassword('ops-password-1'),
active: true,
createdMs: Date.now(),
});
const second = SqliteAdminUserRepo.seeded(db, seedAdminUsersFromEnv());
expect(second.findByUsername('ops')?.active).toBe(true);
// re-seeding must not duplicate the env accounts
expect(second.list().filter((u) => u.username === 'admin')).toHaveLength(1);
});
it('deactivation persists in sqlite', () => {
const repo = SqliteAdminUserRepo.seeded(new Database(':memory:'), seedAdminUsersFromEnv());
const demo = repo.findByUsername('demo')!;
repo.save({ ...demo, active: false });
expect(repo.findByUsername('demo')?.active).toBe(false);
});
});
describe('admin users API', () => {
it('lists users without password hashes', async () => {
const { app } = buildApp();
const res = await request(app).get('/admin/api/users').set(ADMIN);
expect(res.status).toBe(200);
const usernames = res.body.users.map((u: { username: string }) => u.username);
expect(usernames).toEqual(expect.arrayContaining(['admin', 'demo']));
expect(res.body.users[0].passwordHash).toBeUndefined();
});
it('creates a user who can then log in', async () => {
const { app } = buildApp();
const created = await request(app)
.post('/admin/api/users')
.set(ADMIN)
.send({ username: 'finance', password: 'finance-pass-1' });
expect(created.status).toBe(201);
const login = await request(app)
.post('/admin/api/login')
.send({ username: 'finance', password: 'finance-pass-1' });
expect(login.status).toBe(200);
expect(login.body.token).toMatch(/^[a-f0-9]{48}$/);
});
it('rejects duplicates, weak passwords, and bad usernames', async () => {
const { app } = buildApp();
const dup = await request(app)
.post('/admin/api/users')
.set(ADMIN)
.send({ username: 'demo', password: 'whatever-pass-1' });
expect(dup.status).toBe(409);
const weak = await request(app)
.post('/admin/api/users')
.set(ADMIN)
.send({ username: 'x1', password: 'short' });
expect(weak.status).toBe(400);
const bad = await request(app)
.post('/admin/api/users')
.set(ADMIN)
.send({ username: 'bad name!', password: 'fine-password-1' });
expect(bad.status).toBe(400);
});
it('deactivation blocks login; reactivation restores it', async () => {
const { app } = buildApp();
const off = await request(app).post('/admin/api/users/demo/deactivate').set(ADMIN);
expect(off.status).toBe(200);
const login = await request(app)
.post('/admin/api/login')
.send({ username: 'demo', password: '$$$Adm1n###' });
expect(login.status).toBe(401);
const on = await request(app).post('/admin/api/users/demo/activate').set(ADMIN);
expect(on.status).toBe(200);
const again = await request(app)
.post('/admin/api/login')
.send({ username: 'demo', password: '$$$Adm1n###' });
expect(again.status).toBe(200);
});
it('refuses to deactivate the last active admin', async () => {
const { app } = buildApp();
await request(app).post('/admin/api/users/demo/deactivate').set(ADMIN);
const last = await request(app).post('/admin/api/users/admin/deactivate').set(ADMIN);
expect(last.status).toBe(400);
expect(last.body.error).toContain('last active');
});
it('404s unknown usernames on activate/deactivate', async () => {
const { app } = buildApp();
const res = await request(app).post('/admin/api/users/nobody/deactivate').set(ADMIN);
expect(res.status).toBe(404);
});
});

156
tests/admin.test.ts Normal file
View file

@ -0,0 +1,156 @@
import request from 'supertest';
import { buildApp } from '../src/app';
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
const KEY = 'key-ada';
describe('admin API', () => {
it('rejects calls without an admin key', async () => {
const { app } = buildApp();
const res = await request(app).get('/admin/api/pricing');
expect(res.status).toBe(403);
});
it('returns the current pricing', async () => {
const { app } = buildApp();
const res = await request(app).get('/admin/api/pricing').set(ADMIN);
expect(res.status).toBe(200);
expect(res.body.rateCard.endpoints.status).toEqual({ kind: 'free' });
expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(['free', 'pro', 'business']);
});
it('rejects an invalid price rule with 400', async () => {
const { app } = buildApp();
const res = await request(app)
.put('/admin/api/endpoints/transform')
.set(ADMIN)
.send({ kind: 'sometimes' });
expect(res.status).toBe(400);
});
it('reprices an endpoint live, without restart', async () => {
const { app } = buildApp();
await request(app)
.put('/admin/api/endpoints/transform')
.set(ADMIN)
.send({ kind: 'fixed', fixedCents: 10 });
const res = await request(app)
.post('/v1/transform')
.set('x-api-key', KEY)
.send({ text: 'hi' });
expect(res.body.quote.totalCents).toBe(10); // was 4
});
it('creates a customer type live and prices calls for it', async () => {
const { app } = buildApp();
await request(app)
.put('/admin/api/tiers/edu')
.set(ADMIN)
.send({ id: 'edu', name: 'Education', multiplier: 0.5, monthlyCreditCents: 500 });
const created = await request(app)
.post('/admin/api/customers')
.set(ADMIN)
.send({ name: 'School', tierId: 'edu' });
expect(created.status).toBe(201);
expect(created.body.apiKey).toMatch(/^key-/);
const res = await request(app)
.post('/v1/transform')
.set('x-api-key', created.body.apiKey)
.send({ text: 'hi' });
expect(res.body.quote.totalCents).toBe(2); // list 4 x 0.5
});
it('sets a per-customer multiplier override live', async () => {
const { app } = buildApp();
const res = await request(app)
.put('/admin/api/customers/cust_1')
.set(ADMIN)
.send({ multiplierOverride: 0.5 });
expect(res.status).toBe(200);
const call = await request(app)
.post('/v1/transform')
.set('x-api-key', KEY)
.send({ text: 'hi' });
expect(call.body.quote.totalCents).toBe(2); // was 4
});
it('masks API keys in the customer list and 404s unknown customers', async () => {
const { app } = buildApp();
const list = await request(app).get('/admin/api/customers').set(ADMIN);
expect(list.status).toBe(200);
expect(JSON.stringify(list.body)).not.toContain('key-ada');
const missing = await request(app)
.put('/admin/api/customers/cust_nope')
.set(ADMIN)
.send({ tierId: 'pro' });
expect(missing.status).toBe(404);
});
});
describe('admin login', () => {
it('logs in the demo account and accepts the issued token', async () => {
const { app } = buildApp();
const login = await request(app)
.post('/admin/api/login')
.send({ username: 'demo', password: '$$$Adm1n###' });
expect(login.status).toBe(200);
expect(login.body.token).toMatch(/^[a-f0-9]{48}$/);
const res = await request(app)
.get('/admin/api/pricing')
.set('authorization', `Bearer ${login.body.token}`);
expect(res.status).toBe(200);
});
it('logs in the primary admin account with the admin key as password', async () => {
const { app } = buildApp();
const login = await request(app)
.post('/admin/api/login')
.send({ username: 'admin', password: 'admin-dev-key' });
expect(login.status).toBe(200);
});
it('rejects bad credentials with 401 and issues no token', async () => {
const { app } = buildApp();
const login = await request(app)
.post('/admin/api/login')
.send({ username: 'demo', password: 'wrong' });
expect(login.status).toBe(401);
expect(login.body.token).toBeUndefined();
});
it('still accepts the legacy x-admin-key header', async () => {
const { app } = buildApp();
const res = await request(app).get('/admin/api/pricing').set(ADMIN);
expect(res.status).toBe(200);
});
it('rejects unknown bearer tokens', async () => {
const { app } = buildApp();
const res = await request(app)
.get('/admin/api/pricing')
.set('authorization', 'Bearer deadbeef');
expect(res.status).toBe(403);
});
it('sets billingType and email on a customer', async () => {
const { app } = buildApp();
const res = await request(app)
.put('/admin/api/customers/cust_1')
.set(ADMIN)
.send({ billingType: 'purchase_order', email: 'ada@example.com' });
expect(res.status).toBe(200);
const list = await request(app).get('/admin/api/customers').set(ADMIN);
const ada = list.body.customers.find((c: { id: string }) => c.id === 'cust_1');
expect(ada.billingType).toBe('purchase_order');
expect(ada.email).toBe('ada@example.com');
});
it('rejects an invalid billingType with 400', async () => {
const { app } = buildApp();
const res = await request(app)
.put('/admin/api/customers/cust_1')
.set(ADMIN)
.send({ billingType: 'carrier-pigeon' });
expect(res.status).toBe(400);
});
});

152
tests/app.test.ts Normal file
View file

@ -0,0 +1,152 @@
import request from 'supertest';
import { buildApp } from '../src/app';
const KEY = 'key-ada'; // seeded free-tier customer
describe('Zappier API', () => {
it('rejects calls without an API key', async () => {
const { app } = buildApp();
const res = await request(app).get('/v1/status');
expect(res.status).toBe(401);
});
it('GET /v1/status is free', async () => {
const { app } = buildApp();
const res = await request(app).get('/v1/status').set('x-api-key', KEY);
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.quote.totalCents).toBe(0);
});
it('POST /v1/timestamp is idempotent by sha256', async () => {
const { app } = buildApp();
const a = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ data: 'abc' });
expect(a.status).toBe(202);
const b = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ data: 'abc' });
expect(b.body.jobId).toBe(a.body.jobId);
expect(b.body.existing).toBe(true);
const look = await request(app)
.get(`/v1/hashes/${a.body.sha256}`)
.set('x-api-key', KEY);
expect(look.status).toBe(200);
expect(look.body.jobId).toBe(a.body.jobId);
const receipt = await request(app)
.get(`/v1/receipts/${a.body.jobId}`)
.set('x-api-key', KEY);
expect(receipt.status).toBe(200);
expect(receipt.body.type).toBe('verae.retrieval-receipt');
});
it('POST /v1/add returns the sum and is free', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/v1/add')
.set('x-api-key', KEY)
.send({ number1: 2, number2: 3 });
expect(res.status).toBe(200);
expect(res.body.sum).toBe(5);
expect(res.body.number1).toBe(2);
expect(res.body.number2).toBe(3);
expect(res.body.quote.totalCents).toBe(0);
});
it('POST /v1/add rejects missing fields', async () => {
const { app } = buildApp();
const res = await request(app).post('/v1/add').set('x-api-key', KEY).send({ number1: 1 });
expect(res.status).toBe(400);
});
it('POST /v1/transform uppercases text at the multiplied fixed price', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/v1/transform')
.set('x-api-key', KEY)
.send({ text: 'hello' });
expect(res.status).toBe(200);
expect(res.body.output).toBe('HELLO');
expect(res.body.quote.totalCents).toBe(4); // list 4 x free-tier multiplier 1
});
it('rejects a request that violates the OpenAPI schema with 400', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/v1/transform')
.set('x-api-key', KEY)
.send({ wrong: 1 });
expect(res.status).toBe(400);
});
it('POST /v1/storage stores metadata plus attachments and quotes by size', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/v1/storage')
.set('x-api-key', KEY)
.field('metadata', JSON.stringify({ title: 'report' }))
.attach('attachments', Buffer.alloc(1024 * 1024), 'one.bin')
.attach('attachments', Buffer.alloc(1024 * 1024), 'two.bin');
expect(res.status).toBe(200);
expect(res.body.id).toBeTruthy();
// metadata string is 20 bytes -> 1 KB; 2 MB attachments
// list 10 + 1 * 1 + 2 * 50 = 111, free-tier multiplier 1
expect(res.body.quote.totalCents).toBe(111);
});
it('POST /v1/storage with malformed metadata JSON returns 400 and charges nothing', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/v1/storage')
.set('x-api-key', KEY)
.field('metadata', '{not json');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid metadata JSON/);
const usageRes = await request(app).get('/v1/usage').set('x-api-key', KEY);
expect(usageRes.status).toBe(200);
expect(usageRes.body.totalCents).toBe(0);
});
it('GET /v1/storage lists the caller items newest first', async () => {
const { app } = buildApp();
await request(app)
.post('/v1/storage')
.set('x-api-key', KEY)
.field('metadata', JSON.stringify({ title: 'a' }));
await request(app)
.post('/v1/storage')
.set('x-api-key', KEY)
.field('metadata', JSON.stringify({ title: 'b' }));
const res = await request(app).get('/v1/storage').set('x-api-key', KEY);
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
expect(res.body.items[0].metadata.title).toBe('b');
});
it('GET /v1/usage returns the caller summary with monthly credit applied', async () => {
const { app } = buildApp();
await request(app)
.post('/v1/transform')
.set('x-api-key', KEY)
.send({ text: 'x' });
const res = await request(app).get('/v1/usage').set('x-api-key', KEY);
expect(res.status).toBe(200);
expect(res.body.customerId).toBe('cust_1');
expect(res.body.calls).toBe(1);
expect(res.body.totalCents).toBe(4);
expect(res.body.includedCents).toBe(4); // free-tier credit (100) covers it
expect(res.body.billableCents).toBe(0);
});
it('charges less for a business-tier customer', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/v1/transform')
.set('x-api-key', 'key-linus')
.send({ text: 'hello' });
expect(res.body.quote.totalCents).toBe(1); // list 4 x business multiplier 0.25
});
});

51
tests/auth.test.ts Normal file
View file

@ -0,0 +1,51 @@
import express from 'express';
import request from 'supertest';
import { apiKeyAuth, InMemoryCustomerRepo } from '../src/auth';
const repo = new InMemoryCustomerRepo([
{ id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' },
{ id: 'cust_2', name: 'Grace', tierId: 'business', apiKey: 'key-grace', stripeCustomerId: 'cus_123' },
]);
const app = express();
app.use(apiKeyAuth(repo));
app.get('/ping', (req, res) =>
res.json({ customerId: req.customer!.id, tierId: req.customer!.tierId }),
);
describe('apiKeyAuth', () => {
it('rejects a missing key with 401', async () => {
const res = await request(app).get('/ping');
expect(res.status).toBe(401);
expect(res.body.error).toMatch(/API key/);
});
it('rejects an unknown key with 401', async () => {
const res = await request(app).get('/ping').set('x-api-key', 'wrong');
expect(res.status).toBe(401);
});
it('attaches the customer for a valid key', async () => {
const res = await request(app).get('/ping').set('x-api-key', 'key-ada');
expect(res.status).toBe(200);
expect(res.body).toEqual({ customerId: 'cust_1', tierId: 'pro' });
});
});
describe('InMemoryCustomerRepo', () => {
it('lists all customers', () => {
expect(repo.list().map((c) => c.id)).toEqual(['cust_1', 'cust_2']);
});
it('save() upserts by id', () => {
const local = new InMemoryCustomerRepo([
{ id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' },
]);
local.save({ id: 'cust_1', name: 'Ada', tierId: 'business', apiKey: 'key-ada', multiplierOverride: 0.4 });
local.save({ id: 'cust_9', name: 'New', tierId: 'free', apiKey: 'key-new' });
expect(local.list()).toHaveLength(2);
const updated = local.findByApiKey('key-ada');
expect(updated?.tierId).toBe('business');
expect(updated?.multiplierOverride).toBe(0.4);
});
});

View file

@ -0,0 +1,19 @@
import { computeDelta } from '../src/billing/stripe';
describe('computeDelta', () => {
it('returns the difference when billable exceeds prior', () => {
expect(computeDelta(800, 500)).toBe(300);
});
it('returns 0 when billable equals prior', () => {
expect(computeDelta(500, 500)).toBe(0);
});
it('never returns a negative value when billable is below prior', () => {
expect(computeDelta(300, 500)).toBe(0);
});
it('returns the full billable amount when nothing was reported before', () => {
expect(computeDelta(500, 0)).toBe(500);
});
});

33
tests/credit.test.ts Normal file
View file

@ -0,0 +1,33 @@
import { applyMonthlyCredit } from '../src/billing/credit';
import { DEFAULT_TIERS } from '../src/pricing';
import { UsageSummary } from '../src/usage';
const freeTier = DEFAULT_TIERS.find((t) => t.id === 'free')!; // monthlyCreditCents: 100
const summary = (totalCents: number): UsageSummary => ({
customerId: 'cust_1',
totalCents,
calls: 1,
byEndpoint: {},
});
describe('applyMonthlyCredit', () => {
it('covers usage fully when under the monthly credit', () => {
const billed = applyMonthlyCredit(summary(60), freeTier);
expect(billed.includedCents).toBe(60);
expect(billed.billableCents).toBe(0);
expect(billed.totalCents).toBe(60);
});
it('bills only the overage when usage exceeds the credit', () => {
const billed = applyMonthlyCredit(summary(250), freeTier);
expect(billed.includedCents).toBe(100);
expect(billed.billableCents).toBe(150);
});
it('bills nothing when there is no usage', () => {
const billed = applyMonthlyCredit(summary(0), freeTier);
expect(billed.includedCents).toBe(0);
expect(billed.billableCents).toBe(0);
});
});

58
tests/db-billing.test.ts Normal file
View file

@ -0,0 +1,58 @@
import Database from 'better-sqlite3';
import { SqliteBillingReportRepo } from '../src/db/billing-repo';
describe('SqliteBillingReportRepo', () => {
it('returns 0 for an unknown customer/period', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
expect(repo.getReportedCents('cust_1', '2026-07')).toBe(0);
});
it('round-trips an upserted cumulative value', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
repo.upsertReportedCents('cust_1', '2026-07', 500);
expect(repo.getReportedCents('cust_1', '2026-07')).toBe(500);
});
it('keeps periods and customers isolated', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
repo.upsertReportedCents('cust_1', '2026-07', 500);
expect(repo.getReportedCents('cust_1', '2026-08')).toBe(0);
expect(repo.getReportedCents('cust_2', '2026-07')).toBe(0);
});
it('replaces the cumulative value on repeated upsert (latest wins, not summed)', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
repo.upsertReportedCents('cust_1', '2026-07', 500);
repo.upsertReportedCents('cust_1', '2026-07', 800);
expect(repo.getReportedCents('cust_1', '2026-07')).toBe(800);
});
describe('job locks', () => {
it('acquires a free lock and refuses a second acquire until released', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true);
expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(false);
repo.releaseLock('report-usage');
expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true);
});
it('takes over a lock whose age exceeds the TTL', () => {
let nowMs = 1_000_000;
const repo = new SqliteBillingReportRepo(new Database(':memory:'), () => nowMs);
expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true);
nowMs += 60_001; // lock is now stale
expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true);
});
it('keeps lock names independent', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
expect(repo.tryAcquireLock('report-usage', 60_000)).toBe(true);
expect(repo.tryAcquireLock('other-job', 60_000)).toBe(true);
});
it('releaseLock is a no-op for a lock that was never held', () => {
const repo = new SqliteBillingReportRepo(new Database(':memory:'));
expect(() => repo.releaseLock('never-held')).not.toThrow();
});
});
});

116
tests/db-customer.test.ts Normal file
View file

@ -0,0 +1,116 @@
import Database from 'better-sqlite3';
import { Customer } from '../src/auth';
import { SqliteCustomerRepo } from '../src/db/customer-repo';
const customer = (apiKey: string): Customer => ({
id: 'cust_1',
name: 'Ada',
tierId: 'pro',
apiKey,
stripeCustomerId: 'cus_123',
});
describe('SqliteCustomerRepo', () => {
it('finds a customer by API key', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
expect(repo.findByApiKey('key-ada')?.tierId).toBe('pro');
});
it('returns undefined for an unknown key', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
expect(repo.findByApiKey('wrong')).toBeUndefined();
});
it('lists all customers with their Stripe ids', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
const all = repo.list();
expect(all).toHaveLength(1);
expect(all[0].stripeCustomerId).toBe('cus_123');
});
it('seeds only when the table is empty', () => {
const db = new Database(':memory:');
new SqliteCustomerRepo(db, [customer('key-ada')]);
const again = new SqliteCustomerRepo(db, [customer('key-other')]);
expect(again.list().map((c) => c.apiKey)).toEqual(['key-ada']);
});
it('save() upserts including the multiplier override', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
repo.save({ ...customer('key-ada'), tierId: 'business', multiplierOverride: 0.4 });
const updated = repo.findByApiKey('key-ada');
expect(updated?.tierId).toBe('business');
expect(updated?.multiplierOverride).toBe(0.4);
expect(repo.list()).toHaveLength(1);
});
it('round-trips billingType and email', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
repo.save({ ...customer('key-ada'), billingType: 'purchase_order', email: 'ada@example.com' });
const updated = repo.findByApiKey('key-ada');
expect(updated?.billingType).toBe('purchase_order');
expect(updated?.email).toBe('ada@example.com');
});
it('migrates a legacy table, defaulting billingType to stripe', () => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE customers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, tier_id TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE, stripe_customer_id TEXT, multiplier_override REAL
)`);
db.prepare(
`INSERT INTO customers (id, name, tier_id, api_key) VALUES ('cust_1', 'Ada', 'pro', 'key-ada')`,
).run();
const repo = new SqliteCustomerRepo(db);
const migrated = repo.findByApiKey('key-ada');
expect(migrated?.billingType).toBe('stripe');
expect(migrated?.email).toBeUndefined();
// and new columns are writable after migration
repo.save({ ...migrated!, billingType: 'purchase_order' });
expect(repo.findByApiKey('key-ada')?.billingType).toBe('purchase_order');
});
it('finds a customer by email (case-insensitive)', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [
{ ...customer('key-ada'), email: 'Ada@Example.com' },
]);
expect(repo.findByEmail('ada@example.com')?.id).toBe('cust_1');
expect(repo.findByEmail('nobody@example.com')).toBeUndefined();
});
it('round-trips portal identity fields', () => {
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
repo.save({
...customer('key-ada'),
passwordHash: 'scrypt:16384:8:1:c2FsdA==:aGFzaA==',
totpSecret: 'ABC234',
totpEnabled: true,
balanceCents: 2500,
emailInvoicing: true,
});
const updated = repo.findByApiKey('key-ada');
expect(updated?.passwordHash).toBe('scrypt:16384:8:1:c2FsdA==:aGFzaA==');
expect(updated?.totpSecret).toBe('ABC234');
expect(updated?.totpEnabled).toBe(true);
expect(updated?.balanceCents).toBe(2500);
expect(updated?.emailInvoicing).toBe(true);
});
it('defaults portal identity fields for legacy rows', () => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE customers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, tier_id TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE, stripe_customer_id TEXT, multiplier_override REAL
)`);
db.prepare(
`INSERT INTO customers (id, name, tier_id, api_key) VALUES ('cust_1', 'Ada', 'pro', 'key-ada')`,
).run();
const repo = new SqliteCustomerRepo(db);
const migrated = repo.findByApiKey('key-ada');
expect(migrated?.passwordHash).toBeUndefined();
expect(migrated?.totpSecret).toBeUndefined();
expect(migrated?.totpEnabled).toBe(false);
expect(migrated?.balanceCents).toBe(0);
expect(migrated?.emailInvoicing).toBe(false);
});
});

72
tests/db-invoice.test.ts Normal file
View file

@ -0,0 +1,72 @@
import Database from 'better-sqlite3';
import { Invoice } from '../src/invoicing';
import { SqliteInvoiceRepo } from '../src/db/invoice-repo';
const invoice = (overrides: Partial<Invoice> = {}): Invoice => ({
id: 'INV-2026-07-0001',
customerId: 'cust_2',
period: '2026-07',
status: 'draft',
lines: [{ endpointId: 'storage', calls: 2, cents: 1500 }],
totalCents: 1500,
creditCents: 1000,
billableCents: 500,
billingType: 'stripe',
...overrides,
});
describe('SqliteInvoiceRepo', () => {
it('saves and retrieves an invoice with its lines', () => {
const repo = new SqliteInvoiceRepo(new Database(':memory:'));
repo.save(invoice());
const got = repo.get('INV-2026-07-0001');
expect(got?.billableCents).toBe(500);
expect(got?.lines).toEqual([{ endpointId: 'storage', calls: 2, cents: 1500 }]);
});
it('re-saving an invoice replaces its lines (draft regeneration)', () => {
const repo = new SqliteInvoiceRepo(new Database(':memory:'));
repo.save(invoice());
repo.save(invoice({ lines: [{ endpointId: 'transform', calls: 5, cents: 20 }], totalCents: 20 }));
const got = repo.get('INV-2026-07-0001');
expect(got?.lines).toEqual([{ endpointId: 'transform', calls: 5, cents: 20 }]);
expect(repo.list({})).toHaveLength(1);
});
it('filters by customer, period, and status', () => {
const repo = new SqliteInvoiceRepo(new Database(':memory:'));
repo.save(invoice());
repo.save(invoice({ id: 'INV-2026-07-0002', customerId: 'cust_3', status: 'issued' }));
repo.save(invoice({ id: 'INV-2026-08-0001', period: '2026-08' }));
expect(repo.list({ customerId: 'cust_3' })).toHaveLength(1);
expect(repo.list({ period: '2026-08' })).toHaveLength(1);
expect(repo.list({ status: 'issued' })).toHaveLength(1);
expect(repo.list({ period: '2026-07' })).toHaveLength(2);
expect(repo.list({})).toHaveLength(3);
});
it('increments sequences per period independently', () => {
const repo = new SqliteInvoiceRepo(new Database(':memory:'));
expect(repo.nextSequence('2026-07')).toBe(1);
repo.save(invoice());
expect(repo.nextSequence('2026-07')).toBe(2);
expect(repo.nextSequence('2026-08')).toBe(1);
});
it('persists lifecycle timestamps and PO numbers', () => {
const repo = new SqliteInvoiceRepo(new Database(':memory:'));
repo.save(
invoice({
billingType: 'purchase_order',
poNumber: 'PO-1',
status: 'issued',
issuedAtMs: 1000,
dueAtMs: 2000,
}),
);
const got = repo.get('INV-2026-07-0001');
expect(got?.poNumber).toBe('PO-1');
expect(got?.issuedAtMs).toBe(1000);
expect(got?.dueAtMs).toBe(2000);
});
});

63
tests/db-pricing.test.ts Normal file
View file

@ -0,0 +1,63 @@
import Database from 'better-sqlite3';
import { SqlitePricingStore } from '../src/db/pricing-store';
describe('SqlitePricingStore', () => {
it('seeds the default rate card and tiers when empty', () => {
const store = new SqlitePricingStore(new Database(':memory:'));
expect(store.getRateCard().endpoints.transform).toEqual({ kind: 'fixed', fixedCents: 4 });
expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
});
it('seeds only once', () => {
const db = new Database(':memory:');
const first = new SqlitePricingStore(db);
first.deleteEndpoint('transform');
const second = new SqlitePricingStore(db);
expect(second.getRateCard().endpoints.transform).toBeUndefined();
});
it('reseeds endpoints without reverting admin tier edits', () => {
const db = new Database(':memory:');
const first = new SqlitePricingStore(db);
for (const id of Object.keys(first.getRateCard().endpoints)) {
first.deleteEndpoint(id);
}
first.upsertTier({ id: 'pro', name: 'Pro', multiplier: 0.9, monthlyCreditCents: 1000 });
const second = new SqlitePricingStore(db);
expect(second.getRateCard().endpoints.transform).toBeDefined();
expect(second.getTiers().find((t) => t.id === 'pro')?.multiplier).toBe(0.9);
});
it('upserts and deletes endpoints', () => {
const store = new SqlitePricingStore(new Database(':memory:'));
store.upsertEndpoint('experimental', { kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20 });
expect(store.getRateCard().endpoints.experimental).toEqual({
kind: 'variable',
baseCents: 3,
perKbCents: 2,
perMbCents: 20,
});
store.deleteEndpoint('experimental');
expect(store.getRateCard().endpoints.experimental).toBeUndefined();
});
it('upserts tiers including default rules, and deletes them', () => {
const store = new SqlitePricingStore(new Database(':memory:'));
store.upsertTier({
id: 'edu',
name: 'Education',
multiplier: 0.4,
monthlyCreditCents: 500,
defaultRule: { kind: 'fixed', fixedCents: 6 },
});
expect(store.getTiers().find((t) => t.id === 'edu')?.defaultRule).toEqual({
kind: 'fixed',
fixedCents: 6,
});
store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 });
expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1);
expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.3);
store.deleteTier('edu');
expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined();
});
});

32
tests/db-session.test.ts Normal file
View file

@ -0,0 +1,32 @@
import Database from 'better-sqlite3';
import { SqliteSessionRepo } from '../src/db/session-repo';
describe('SqliteSessionRepo', () => {
it('creates and retrieves sessions within the TTL', () => {
const repo = new SqliteSessionRepo(new Database(':memory:'));
const s = repo.create('cust_1', 60_000);
expect(s.customerId).toBe('cust_1');
expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1');
});
it('persists sessions across instances (same db)', () => {
const db = new Database(':memory:');
const first = new SqliteSessionRepo(db);
const s = first.create('cust_1', 60_000);
const second = new SqliteSessionRepo(db);
expect(second.get(s.token, s.createdMs + 1_000)?.customerId).toBe('cust_1');
});
it('expires sessions after the TTL', () => {
const repo = new SqliteSessionRepo(new Database(':memory:'));
const s = repo.create('cust_1', 60_000);
expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined();
});
it('deletes sessions (logout)', () => {
const repo = new SqliteSessionRepo(new Database(':memory:'));
const s = repo.create('cust_1', 60_000);
repo.delete(s.token);
expect(repo.get(s.token, s.createdMs)).toBeUndefined();
});
});

40
tests/db-usage.test.ts Normal file
View file

@ -0,0 +1,40 @@
import Database from 'better-sqlite3';
import { SqliteUsageRepo } from '../src/db/usage-repo';
import { UsageEntry } from '../src/usage';
const entry = (over: Partial<UsageEntry> = {}): UsageEntry => ({
customerId: 'cust_1',
endpointId: 'transform',
cents: 4,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date('2026-07-27T10:00:00Z'),
...over,
});
describe('SqliteUsageRepo', () => {
it('records and lists entries per customer', () => {
const repo = new SqliteUsageRepo(new Database(':memory:'));
repo.record(entry());
repo.record(entry({ customerId: 'cust_2' }));
expect(repo.listFor('cust_1')).toHaveLength(1);
expect(repo.listFor('cust_2')).toHaveLength(1);
});
it('filters entries by since date', () => {
const repo = new SqliteUsageRepo(new Database(':memory:'));
repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') }));
repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') }));
expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1);
});
it('summarizes totals by endpoint', () => {
const repo = new SqliteUsageRepo(new Database(':memory:'));
repo.record(entry());
repo.record(entry({ endpointId: 'storage', cents: 112 }));
const s = repo.summaryFor('cust_1');
expect(s.calls).toBe(2);
expect(s.totalCents).toBe(116);
expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 });
});
});

86
tests/invoicing.test.ts Normal file
View file

@ -0,0 +1,86 @@
import { buildInvoice, Invoice } from '../src/invoicing';
import { Customer } from '../src/auth';
import { TierConfig } from '../src/pricing';
import { UsageEntry } from '../src/usage';
const pro: TierConfig = { id: 'pro', name: 'Pro', multiplier: 0.5, monthlyCreditCents: 1000 };
const customer: Customer = {
id: 'cust_2',
name: 'Grace',
tierId: 'pro',
apiKey: 'key-grace',
stripeCustomerId: 'cus_123',
};
const entry = (endpointId: string, cents: number): UsageEntry => ({
customerId: customer.id,
endpointId,
cents,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date('2026-07-10T12:00:00Z'),
});
describe('buildInvoice', () => {
it('groups usage entries into lines per endpoint, sorted', () => {
const inv = buildInvoice({
customer,
period: '2026-07',
sequence: 1,
tier: pro,
entries: [entry('transform', 3), entry('storage', 6), entry('transform', 3)],
});
expect(inv.lines).toEqual([
{ endpointId: 'storage', calls: 1, cents: 6 },
{ endpointId: 'transform', calls: 2, cents: 6 },
]);
expect(inv.totalCents).toBe(12);
});
it('applies a partial monthly credit', () => {
const inv = buildInvoice({
customer,
period: '2026-07',
sequence: 1,
tier: pro,
entries: [entry('storage', 1500)],
});
expect(inv.totalCents).toBe(1500);
expect(inv.creditCents).toBe(1000);
expect(inv.billableCents).toBe(500);
});
it('floors billable at zero when credit exceeds usage', () => {
const inv = buildInvoice({
customer,
period: '2026-07',
sequence: 1,
tier: pro,
entries: [entry('storage', 400)],
});
expect(inv.creditCents).toBe(400);
expect(inv.billableCents).toBe(0);
});
it('formats the id as INV-<period>-<padded sequence> and starts as draft', () => {
const inv = buildInvoice({ customer, period: '2026-07', sequence: 7, tier: pro, entries: [] });
expect(inv.id).toBe('INV-2026-07-0007');
expect(inv.status).toBe('draft');
expect(inv.billingType).toBe('stripe');
});
it('carries purchase-order billing fields', () => {
const poCustomer: Customer = { ...customer, billingType: 'purchase_order' };
const inv = buildInvoice({
customer: poCustomer,
period: '2026-07',
sequence: 2,
tier: pro,
entries: [entry('storage', 2000)],
poNumber: 'PO-9982',
});
expect(inv.billingType).toBe('purchase_order');
expect(inv.poNumber).toBe('PO-9982');
});
});

86
tests/meter.test.ts Normal file
View file

@ -0,0 +1,86 @@
import express from 'express';
import request from 'supertest';
import { apiKeyAuth, Customer, InMemoryCustomerRepo } from '../src/auth';
import { meter } from '../src/meter';
import { DEFAULT_PRICING } from '../src/pricing';
import { InMemoryUsageRepo } from '../src/usage';
const FREE_ADA: Customer = { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' };
function buildApp(seed: Customer[] = [FREE_ADA]) {
const customers = new InMemoryCustomerRepo(seed);
const usage = new InMemoryUsageRepo();
const app = express();
app.use(express.json());
app.use(apiKeyAuth(customers));
app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) =>
res.json({ quote: res.locals.quote }),
);
app.post('/storage', meter('storage', usage, DEFAULT_PRICING), (req, res) =>
res.json({ quote: res.locals.quote }),
);
app.post('/experimental', meter('experimental', usage, DEFAULT_PRICING), (req, res) =>
res.json({ quote: res.locals.quote }),
);
return { app, usage };
}
describe('meter middleware', () => {
it('quotes a fixed endpoint at the multiplied tier price and records usage', async () => {
const { app, usage } = buildApp();
const res = await request(app)
.post('/transform')
.set('x-api-key', 'key-ada')
.send({ text: 'hi' });
expect(res.status).toBe(200);
expect(res.body.quote.totalCents).toBe(4); // free tier, multiplier 1
const entries = usage.listFor('cust_1');
expect(entries).toHaveLength(1);
expect(entries[0].endpointId).toBe('transform');
expect(entries[0].cents).toBe(4);
});
it('charges per KB of metadata on variable endpoints', async () => {
const { app } = buildApp();
const res = await request(app)
.post('/storage')
.set('x-api-key', 'key-ada')
.send({ metadata: { note: 'x'.repeat(2048) } });
// JSON of metadata is 2059 bytes -> 3 KB -> list 10 + 3 * 1 = 13
expect(res.body.quote.totalCents).toBe(13);
});
it('applies a per-customer multiplier override', async () => {
const { app } = buildApp([
{ id: 'cust_vip', name: 'Vip', tierId: 'free', apiKey: 'key-vip', multiplierOverride: 0.5 },
]);
const res = await request(app)
.post('/transform')
.set('x-api-key', 'key-vip')
.send({ text: 'hi' });
expect(res.body.quote.totalCents).toBe(2); // list 4 x override 0.5
});
it('returns 401 when no customer is attached', async () => {
const usage = new InMemoryUsageRepo();
const app = express();
app.use(express.json());
app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) =>
res.json({}),
);
const res = await request(app).post('/transform').send({ text: 'hi' });
expect(res.status).toBe(401);
expect(usage.listFor('cust_1')).toHaveLength(0);
});
it('returns 403 for an endpoint with no price rule on the caller tier', async () => {
const { app, usage } = buildApp();
const res = await request(app)
.post('/experimental')
.set('x-api-key', 'key-ada')
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/No price rule/);
expect(usage.listFor('cust_1')).toHaveLength(0);
});
});

21
tests/paths.test.ts Normal file
View file

@ -0,0 +1,21 @@
import fs from 'fs';
import path from 'path';
import { PROJECT_ROOT } from '../src/paths';
// The server must work from any working directory (systemd, Docker, cron,
// `node dist/index.js` launched from elsewhere), so every runtime path is
// resolved from PROJECT_ROOT rather than process.cwd().
describe('PROJECT_ROOT', () => {
it('points at the project root regardless of cwd', () => {
expect(path.isAbsolute(PROJECT_ROOT)).toBe(true);
expect(fs.existsSync(path.join(PROJECT_ROOT, 'package.json'))).toBe(true);
});
it('resolves the OpenAPI spec served at /docs', () => {
expect(fs.existsSync(path.join(PROJECT_ROOT, 'openapi.yaml'))).toBe(true);
});
it('resolves the admin static assets served at /admin', () => {
expect(fs.existsSync(path.join(PROJECT_ROOT, 'admin', 'index.html'))).toBe(true);
});
});

320
tests/portal.test.ts Normal file
View file

@ -0,0 +1,320 @@
import request from 'supertest';
import { totp } from '../src/accounts';
import { buildApp } from '../src/app';
import { PaymentClient } from '../src/portal';
const devPayments: PaymentClient = {
reload: async (_customer, amountCents) => ({ mode: 'dev' as const, creditedCents: amountCents }),
};
function portalApp() {
return buildApp({
payments: devPayments,
qr: async (uri) => `data:image/png;base64,fake-qr-for:${uri}`,
});
}
async function signup(app: ReturnType<typeof buildApp>['app'], email = 'ada@example.com') {
const res = await request(app)
.post('/portal/api/signup')
.send({ name: 'Ada', email, password: 'super-secret-1' });
expect(res.status).toBe(201);
return res.body as { token: string; customer: { id: string; apiKey: string } };
}
describe('portal signup + login', () => {
it('signs up a new customer on the free tier with an API key', async () => {
const { app, customers } = portalApp();
const { token, customer } = await signup(app);
expect(customer.apiKey).toMatch(/^key-/);
const stored = customers.findByEmail('ada@example.com');
expect(stored?.tierId).toBe('free');
expect(stored?.passwordHash).toMatch(/^scrypt:/);
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
expect(me.status).toBe(200);
expect(me.body.tierId).toBe('free');
expect(me.body.balanceCents).toBe(0);
});
it('never leaks passwordHash or totpSecret through the API', async () => {
const { app } = portalApp();
const { token } = await signup(app);
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
expect(me.body.passwordHash).toBeUndefined();
expect(me.body.totpSecret).toBeUndefined();
});
it('rejects weak passwords and duplicate emails', async () => {
const { app } = portalApp();
const weak = await request(app)
.post('/portal/api/signup')
.send({ name: 'Ada', email: 'ada@example.com', password: 'short' });
expect(weak.status).toBe(400);
await signup(app);
const dup = await request(app)
.post('/portal/api/signup')
.send({ name: 'Other', email: 'ada@example.com', password: 'super-secret-2' });
expect(dup.status).toBe(409);
});
it('claims an admin-created customer by email on first signup', async () => {
const { app, customers } = portalApp();
const ada = customers.findByApiKey('key-ada')!;
customers.save({ ...ada, email: 'ada@example.com' });
const { customer } = await signup(app);
expect(customer.id).toBe('cust_1'); // same customer, now with a password
expect(customers.findByApiKey('key-ada')?.passwordHash).toMatch(/^scrypt:/);
});
it('logs in with email + password and rejects bad credentials', async () => {
const { app } = portalApp();
await signup(app);
const bad = await request(app)
.post('/portal/api/login')
.send({ email: 'ada@example.com', password: 'wrong-password' });
expect(bad.status).toBe(401);
const ok = await request(app)
.post('/portal/api/login')
.send({ email: 'ada@example.com', password: 'super-secret-1' });
expect(ok.status).toBe(200);
expect(ok.body.token).toBeTruthy();
});
it('requires a session for /me (401 without token)', async () => {
const { app } = portalApp();
expect((await request(app).get('/portal/api/me')).status).toBe(401);
});
});
describe('portal scoping', () => {
it('lists only the signed-in customers invoices', async () => {
const { app, customers, invoices } = portalApp();
const { token, customer } = await signup(app);
invoices.save({
id: 'INV-2026-07-0001',
customerId: customer.id,
period: '2026-07',
status: 'issued',
lines: [{ endpointId: 'transform', calls: 10, cents: 30 }],
totalCents: 30,
creditCents: 0,
billableCents: 30,
billingType: 'stripe',
});
invoices.save({
id: 'INV-2026-07-0002',
customerId: 'cust_2',
period: '2026-07',
status: 'issued',
lines: [],
totalCents: 999,
creditCents: 0,
billableCents: 999,
billingType: 'stripe',
});
const res = await request(app)
.get('/portal/api/invoices')
.set('authorization', `Bearer ${token}`);
expect(res.body.invoices.map((i: { id: string }) => i.id)).toEqual(['INV-2026-07-0001']);
expect(customers.list()).toHaveLength(4); // 3 seeds + signup
});
it('returns 404 for another customers invoice (json and html)', async () => {
const { app, invoices } = portalApp();
const { token } = await signup(app);
invoices.save({
id: 'INV-2026-07-0009',
customerId: 'cust_2',
period: '2026-07',
status: 'issued',
lines: [],
totalCents: 999,
creditCents: 0,
billableCents: 999,
billingType: 'stripe',
});
const json = await request(app)
.get('/portal/api/invoices/INV-2026-07-0009')
.set('authorization', `Bearer ${token}`);
expect(json.status).toBe(404);
const html = await request(app)
.get('/portal/api/invoices/INV-2026-07-0009?format=html')
.set('authorization', `Bearer ${token}`);
expect(html.status).toBe(404);
});
it('serves the customers own invoice as printable HTML', async () => {
const { app, invoices } = portalApp();
const { token, customer } = await signup(app);
invoices.save({
id: 'INV-2026-07-0001',
customerId: customer.id,
period: '2026-07',
status: 'issued',
lines: [{ endpointId: 'transform', calls: 10, cents: 30 }],
totalCents: 30,
creditCents: 0,
billableCents: 30,
billingType: 'stripe',
});
const res = await request(app)
.get('/portal/api/invoices/INV-2026-07-0001?format=html')
.set('authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.text).toContain('INV-2026-07-0001');
});
});
describe('portal api key + usage', () => {
it('regenerates the API key; the old key stops working', async () => {
const { app } = portalApp();
const { token, customer } = await signup(app);
const oldKey = customer.apiKey;
const res = await request(app)
.post('/portal/api/api-key')
.set('authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.apiKey).not.toBe(oldKey);
const stale = await request(app).get('/v1/status').set('x-api-key', oldKey);
expect(stale.status).toBe(401);
const fresh = await request(app).get('/v1/status').set('x-api-key', res.body.apiKey);
expect(fresh.status).toBe(200);
});
it('reports month-to-date usage with the tier credit applied', async () => {
const { app, usage, customers } = portalApp();
const { token, customer } = await signup(app);
usage.record({
customerId: customer.id,
endpointId: 'transform',
cents: 40,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(),
});
const res = await request(app).get('/portal/api/usage').set('authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.totalCents).toBe(40);
expect(res.body.includedCents).toBe(40); // free tier credit 100 covers it
expect(res.body.billableCents).toBe(0);
expect(customers.findByEmail('ada@example.com')).toBeTruthy();
});
});
describe('portal 2FA', () => {
it('setup → enable → login requires the TOTP code', async () => {
const { app, customers } = portalApp();
const { token } = await signup(app);
const setup = await request(app)
.post('/portal/api/2fa/setup')
.set('authorization', `Bearer ${token}`);
expect(setup.status).toBe(200);
expect(setup.body.secret).toMatch(/^[A-Z2-7]{32}$/);
expect(setup.body.uri).toContain('otpauth://totp/');
expect(setup.body.qr).toMatch(/^data:image\/png;base64,/);
// Enabling with a wrong code fails.
const badEnable = await request(app)
.post('/portal/api/2fa/enable')
.set('authorization', `Bearer ${token}`)
.send({ code: '000000' });
expect(badEnable.status).toBe(400);
const code = totp(setup.body.secret, Date.now());
const enable = await request(app)
.post('/portal/api/2fa/enable')
.set('authorization', `Bearer ${token}`)
.send({ code });
expect(enable.status).toBe(200);
expect(customers.findByEmail('ada@example.com')?.totpEnabled).toBe(true);
// Password alone no longer suffices.
const noCode = await request(app)
.post('/portal/api/login')
.send({ email: 'ada@example.com', password: 'super-secret-1' });
expect(noCode.status).toBe(401);
expect(noCode.body.error).toBe('totp_required');
const withCode = await request(app)
.post('/portal/api/login')
.send({
email: 'ada@example.com',
password: 'super-secret-1',
totpCode: totp(setup.body.secret, Date.now()),
});
expect(withCode.status).toBe(200);
});
it('disables 2FA with a valid code', async () => {
const { app, customers } = portalApp();
const { token } = await signup(app);
const setup = await request(app)
.post('/portal/api/2fa/setup')
.set('authorization', `Bearer ${token}`);
await request(app)
.post('/portal/api/2fa/enable')
.set('authorization', `Bearer ${token}`)
.send({ code: totp(setup.body.secret, Date.now()) });
const disable = await request(app)
.post('/portal/api/2fa/disable')
.set('authorization', `Bearer ${token}`)
.send({ code: totp(setup.body.secret, Date.now()) });
expect(disable.status).toBe(200);
const stored = customers.findByEmail('ada@example.com');
expect(stored?.totpEnabled).toBe(false);
expect(stored?.totpSecret).toBeUndefined();
});
});
describe('portal billing', () => {
it('reloads the prepaid balance via the payment client', async () => {
const { app } = portalApp();
const { token } = await signup(app);
const res = await request(app)
.post('/portal/api/reload')
.set('authorization', `Bearer ${token}`)
.send({ amountCents: 2500 });
expect(res.status).toBe(200);
expect(res.body.balanceCents).toBe(2500);
expect(res.body.mode).toBe('dev');
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
expect(me.body.balanceCents).toBe(2500);
});
it('rejects out-of-range reload amounts', async () => {
const { app } = portalApp();
const { token } = await signup(app);
for (const amountCents of [0, 50, -100, 2_000_000, 12.5]) {
const res = await request(app)
.post('/portal/api/reload')
.set('authorization', `Bearer ${token}`)
.send({ amountCents });
expect(res.status).toBe(400);
}
});
it('serves the live rate card and tiers for the pricing page', async () => {
const { app } = portalApp();
const { token } = await signup(app);
const res = await request(app)
.get('/portal/api/pricing')
.set('authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.rateCard.endpoints.transform).toBeTruthy();
expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(
expect.arrayContaining(['free', 'pro', 'business']),
);
});
it('toggles email invoicing', async () => {
const { app } = portalApp();
const { token } = await signup(app);
const res = await request(app)
.put('/portal/api/email-invoicing')
.set('authorization', `Bearer ${token}`)
.send({ enabled: true });
expect(res.status).toBe(200);
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
expect(me.body.emailInvoicing).toBe(true);
});
});

121
tests/pricing.test.ts Normal file
View file

@ -0,0 +1,121 @@
import {
ConfigTierCatalog,
DEFAULT_PRICING,
DEFAULT_TIERS,
InMemoryPricingStore,
quoteCall,
} from '../src/pricing';
const noUsage = { metadataBytes: 0, attachmentBytes: 0 };
describe('quoteCall', () => {
it('prices free endpoints at 0 on every tier, ignoring the multiplier', () => {
for (const tier of DEFAULT_TIERS) {
expect(quoteCall(DEFAULT_PRICING, tier.id, 'status', noUsage).totalCents).toBe(0);
expect(quoteCall(DEFAULT_PRICING, tier.id, 'storage-list', noUsage).totalCents).toBe(0);
}
});
it('applies the tier multiplier to fixed list prices', () => {
// transform list price: 4 cents
expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage).totalCents).toBe(4);
expect(quoteCall(DEFAULT_PRICING, 'pro', 'transform', noUsage).totalCents).toBe(2);
expect(quoteCall(DEFAULT_PRICING, 'business', 'transform', noUsage).totalCents).toBe(1);
});
it('prices storage as base + per-KB metadata + per-MB attachments at list rates', () => {
const usage = { metadataBytes: 2048, attachmentBytes: 2 * 1024 * 1024 };
// list: 10 + 2 * 1 + 2 * 50 = 112
expect(quoteCall(DEFAULT_PRICING, 'free', 'storage', usage).totalCents).toBe(112);
expect(quoteCall(DEFAULT_PRICING, 'pro', 'storage', usage).totalCents).toBe(56);
expect(quoteCall(DEFAULT_PRICING, 'business', 'storage', usage).totalCents).toBe(28);
});
it('rounds partial KB and MB up before applying the multiplier', () => {
const q = quoteCall(DEFAULT_PRICING, 'free', 'storage', {
metadataBytes: 1,
attachmentBytes: 1,
});
// list: 10 + 1 KB * 1 + 1 MB * 50 = 61
expect(q.listCents).toBe(61);
expect(q.totalCents).toBe(61);
});
it('exposes the list-price breakdown and the multiplied total', () => {
const q = quoteCall(DEFAULT_PRICING, 'pro', 'storage', {
metadataBytes: 1024,
attachmentBytes: 0,
});
expect(q.breakdown).toEqual({ baseCents: 10, metadataCents: 1, attachmentCents: 0 });
expect(q.listCents).toBe(11);
expect(q.totalCents).toBe(6); // Math.round(11 * 0.5)
});
it('lets a per-customer multiplier override beat the tier multiplier', () => {
expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage, 0.5).totalCents).toBe(2);
expect(quoteCall(DEFAULT_PRICING, 'free', 'status', noUsage, 0.5).totalCents).toBe(0);
});
it('falls back to the tier default rule for endpoints not on the rate card', () => {
// pro default rule: fixed 8 list -> round(8 * 0.5) = 4
expect(quoteCall(DEFAULT_PRICING, 'pro', 'experimental', noUsage).totalCents).toBe(4);
});
it('throws for an endpoint with no rate-card entry and no tier default', () => {
expect(() => quoteCall(DEFAULT_PRICING, 'free', 'experimental', noUsage)).toThrow(
'No price rule',
);
});
it('throws for an unknown tier', () => {
expect(() => quoteCall(DEFAULT_PRICING, 'platinum', 'status', noUsage)).toThrow(
'Unknown tier',
);
});
});
describe('ConfigTierCatalog', () => {
it('finds tiers by id and lists them', () => {
const catalog = new ConfigTierCatalog(DEFAULT_TIERS);
expect(catalog.find('pro')?.multiplier).toBe(0.5);
expect(catalog.find('nope')).toBeUndefined();
expect(catalog.list().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
});
it('supports adding a customer type as pure config', () => {
const catalog = new ConfigTierCatalog([
...DEFAULT_TIERS,
{ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 },
]);
expect(catalog.find('edu')?.name).toBe('Education');
});
});
describe('InMemoryPricingStore', () => {
it('seeds from the default rate card and tiers', () => {
const store = new InMemoryPricingStore();
expect(store.getRateCard().endpoints.status).toEqual({ kind: 'free' });
expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
});
it('upserts and deletes endpoints', () => {
const store = new InMemoryPricingStore();
store.upsertEndpoint('experimental', { kind: 'fixed', fixedCents: 9 });
expect(store.getRateCard().endpoints.experimental).toEqual({
kind: 'fixed',
fixedCents: 9,
});
store.deleteEndpoint('experimental');
expect(store.getRateCard().endpoints.experimental).toBeUndefined();
});
it('upserts and deletes tiers', () => {
const store = new InMemoryPricingStore();
store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 });
expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.4);
store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 });
expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1);
store.deleteTier('edu');
expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined();
});
});

View file

@ -0,0 +1,245 @@
import Database from 'better-sqlite3';
import { Customer } from '../src/auth';
import { METER_EVENT_NAME, MeterEventClient } from '../src/billing/stripe';
import { SqliteBillingReportRepo } from '../src/db/billing-repo';
import { reportMonthlyUsage } from '../src/jobs/report-usage';
import { TierConfig } from '../src/pricing';
import { UsageEntry } from '../src/usage';
const SINCE = new Date('2026-07-01T00:00:00Z');
const customer: Customer = {
id: 'cust_1',
name: 'Acme',
tierId: 'pro',
apiKey: 'key_1',
stripeCustomerId: 'cus_123',
};
const customerB: Customer = {
id: 'cust_2',
name: 'Beta',
tierId: 'pro',
apiKey: 'key_2',
stripeCustomerId: 'cus_456',
};
const tier: TierConfig = { id: 'pro', name: 'Pro', multiplier: 1, monthlyCreditCents: 0 };
const entry = (cents: number, customerId = 'cust_1', timestamp = '2026-07-10T00:00:00Z'): UsageEntry => ({
customerId,
endpointId: 'storage',
cents,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(timestamp),
});
interface MeterCall {
eventName: string;
customerId: string;
value: string;
identifier?: string;
}
function makeHarness(opts: {
entries: UsageEntry[];
customers?: Customer[];
throwFor?: (stripeCustomerId: string) => boolean;
since?: Date;
onMeterEvent?: (call: MeterCall) => void;
now?: () => number;
existingRepo?: SqliteBillingReportRepo;
}) {
const calls: MeterCall[] = [];
let shouldThrow = false;
const client: MeterEventClient = {
createMeterEvent: async (params) => {
if (shouldThrow || opts.throwFor?.(params.customerId)) throw new Error('stripe down');
calls.push(params);
opts.onMeterEvent?.(params);
},
};
const billingRepo =
opts.existingRepo ?? new SqliteBillingReportRepo(new Database(':memory:'), opts.now);
const logs: string[] = [];
const run = (overrides: { entries?: UsageEntry[]; since?: Date } = {}) =>
reportMonthlyUsage({
client,
usage: {
listFor: (customerId: string) =>
(overrides.entries ?? opts.entries).filter((e) => e.customerId === customerId),
},
customers: { list: () => opts.customers ?? [customer] },
tiers: [tier],
billingRepo,
locks: billingRepo,
since: overrides.since ?? opts.since ?? SINCE,
log: (m) => logs.push(m),
});
return {
run,
calls,
logs,
billingRepo,
setShouldThrow: (v: boolean) => {
shouldThrow = v;
},
};
}
describe('reportMonthlyUsage idempotency', () => {
it('reports the full billable amount on first run and upserts the ledger', async () => {
const h = makeHarness({ entries: [entry(500)] });
await h.run();
expect(h.calls).toEqual([
{
eventName: METER_EVENT_NAME,
customerId: 'cus_123',
value: '500',
identifier: 'cus_123:2026-07:500',
},
]);
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500);
});
it('sends no meter event when the same month is re-run with unchanged usage', async () => {
const h = makeHarness({ entries: [entry(500)] });
await h.run();
await h.run();
expect(h.calls).toHaveLength(1);
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500);
expect(h.logs[1]).toBe('skip cust_1 2026-07 (already reported 500c)');
});
it('reports only the delta when usage grows within the same period', async () => {
const entries = [entry(500)];
const h = makeHarness({ entries });
await h.run();
entries.push(entry(300));
await h.run();
expect(h.calls).toEqual([
{
eventName: METER_EVENT_NAME,
customerId: 'cus_123',
value: '500',
identifier: 'cus_123:2026-07:500',
},
{
eventName: METER_EVENT_NAME,
customerId: 'cus_123',
value: '300',
identifier: 'cus_123:2026-07:800',
},
]);
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(800);
});
it('does not touch the ledger when the Stripe call fails, so a retry reports the full delta', async () => {
const h = makeHarness({ entries: [entry(500)] });
h.setShouldThrow(true);
await expect(h.run()).rejects.toThrow('stripe down');
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(0);
h.setShouldThrow(false);
await h.run();
expect(h.calls).toHaveLength(1);
expect(h.calls[0].value).toBe('500');
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500);
});
it('logs "nothing to report" instead of "already reported 0c" for a never-reported customer', async () => {
const h = makeHarness({ entries: [] });
await h.run();
expect(h.calls).toHaveLength(0);
expect(h.logs).toEqual(['skip cust_1 2026-07 (nothing to report)']);
});
it('reports the full amount again after month rollover (prior period does not leak)', async () => {
const h = makeHarness({ entries: [entry(500)] });
await h.run();
expect(h.calls[0]).toMatchObject({ value: '500', identifier: 'cus_123:2026-07:500' });
const augustEntries = [entry(700, 'cust_1', '2026-08-10T00:00:00Z')];
await h.run({ entries: augustEntries, since: new Date('2026-08-01T00:00:00Z') });
expect(h.calls).toHaveLength(2);
expect(h.calls[1]).toEqual({
eventName: METER_EVENT_NAME,
customerId: 'cus_123',
value: '700',
identifier: 'cus_123:2026-08:700',
});
expect(h.billingRepo.getReportedCents('cust_1', '2026-08')).toBe(700);
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500);
});
it('keeps customer A reported in the ledger when customer Bs Stripe call fails', async () => {
const h = makeHarness({
entries: [entry(500), entry(300, 'cust_2')],
customers: [customer, customerB],
throwFor: (id) => id === 'cus_456',
});
await expect(h.run()).rejects.toThrow('stripe down');
expect(h.calls).toHaveLength(1);
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500);
expect(h.billingRepo.getReportedCents('cust_2', '2026-07')).toBe(0);
// Retry: A is skipped (delta 0), B reports its full delta.
// Reuse the same repo so the ledger persists across "process restarts".
const h2 = makeHarness({
entries: [entry(500), entry(300, 'cust_2')],
customers: [customer, customerB],
existingRepo: h.billingRepo,
});
await h2.run();
expect(h2.calls).toEqual([
{
eventName: METER_EVENT_NAME,
customerId: 'cus_456',
value: '300',
identifier: 'cus_456:2026-07:300',
},
]);
});
it('aborts a second invocation while the lock is held, sending nothing', async () => {
let innerLogs: string[] = [];
let innerCalls = 0;
const h = makeHarness({
entries: [entry(500)],
onMeterEvent: () => {
// Simulate a concurrent invocation (e.g. manual run while cron is in flight).
void innerRun();
},
});
const innerRun = async () => {
innerCalls++;
await h.run();
};
// Capture logs of the inner run separately by swapping the log sink is
// overkill; the shared harness appends to the same array, which is fine:
// the inner run must only add the abort line and no meter call.
await h.run();
innerLogs = h.logs.filter((l) => l.includes('abort'));
expect(innerCalls).toBe(1);
expect(h.calls).toHaveLength(1); // only the outer run reported
expect(innerLogs).toHaveLength(1);
});
it('takes over a stale lock left by a crashed run', async () => {
let nowMs = 1_000_000;
const h = makeHarness({ entries: [entry(500)], now: () => nowMs });
// Simulate a crashed run that acquired the lock and never released it.
expect(h.billingRepo.tryAcquireLock('report-usage', 3_600_000)).toBe(true);
// Within the TTL the job aborts.
await h.run();
expect(h.calls).toHaveLength(0);
expect(h.logs.some((l) => l.includes('abort'))).toBe(true);
// After the TTL the lock is stale and the run proceeds.
nowMs += 3_600_001;
await h.run();
expect(h.calls).toHaveLength(1);
expect(h.billingRepo.getReportedCents('cust_1', '2026-07')).toBe(500);
});
});

100
tests/reports.test.ts Normal file
View file

@ -0,0 +1,100 @@
import { billingRows, toCsv, usageTrend } from '../src/reports';
import { Customer } from '../src/auth';
import { TierConfig } from '../src/pricing';
import { UsageEntry } from '../src/usage';
const tiers: TierConfig[] = [
{ id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 },
{ id: 'pro', name: 'Pro', multiplier: 0.5, monthlyCreditCents: 1000 },
];
const customers: Customer[] = [
{ id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' },
{ id: 'cust_2', name: 'Grace', tierId: 'pro', apiKey: 'key-grace' },
{ id: 'cust_3', name: 'Linus', tierId: 'pro', apiKey: 'key-linus', billingType: 'purchase_order' },
];
const entry = (customerId: string, cents: number, iso: string): UsageEntry => ({
customerId,
endpointId: 'storage',
cents,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(iso),
});
describe('billingRows', () => {
const entries = [
entry('cust_1', 150, '2026-07-05T10:00:00Z'),
entry('cust_2', 1500, '2026-07-06T10:00:00Z'),
entry('cust_2', 300, '2026-08-01T00:30:00Z'),
];
it('aggregates per customer with tier credits', () => {
const rows = billingRows({ entries, customers, tiers });
const ada = rows.find((r) => r.customerId === 'cust_1')!;
expect(ada).toMatchObject({ calls: 1, totalCents: 150, creditCents: 100, billableCents: 50 });
const grace = rows.find((r) => r.customerId === 'cust_2')!;
expect(grace).toMatchObject({ calls: 2, totalCents: 1800, creditCents: 1000, billableCents: 800 });
});
it('includes zero-usage customers and exposes billingType', () => {
const rows = billingRows({ entries, customers, tiers });
const linus = rows.find((r) => r.customerId === 'cust_3')!;
expect(linus).toMatchObject({ calls: 0, totalCents: 0, billableCents: 0, billingType: 'purchase_order' });
});
it('filters by date range (inclusive from, exclusive to)', () => {
const rows = billingRows({
entries,
customers,
tiers,
from: new Date('2026-07-01T00:00:00Z'),
to: new Date('2026-08-01T00:00:00Z'),
});
const grace = rows.find((r) => r.customerId === 'cust_2')!;
expect(grace.calls).toBe(1); // August entry excluded
expect(grace.totalCents).toBe(1500);
});
it('filters by customerId and billingType', () => {
expect(billingRows({ entries, customers, tiers, customerId: 'cust_1' })).toHaveLength(1);
const po = billingRows({ entries, customers, tiers, billingType: 'purchase_order' });
expect(po.map((r) => r.customerId)).toEqual(['cust_3']);
});
});
describe('usageTrend', () => {
it('buckets by day', () => {
const trend = usageTrend(
[entry('cust_1', 10, '2026-07-05T09:00:00Z'), entry('cust_2', 20, '2026-07-05T20:00:00Z'), entry('cust_1', 5, '2026-07-06T10:00:00Z')],
'day',
);
expect(trend).toEqual([
{ bucket: '2026-07-05', calls: 2, cents: 30 },
{ bucket: '2026-07-06', calls: 1, cents: 5 },
]);
});
it('buckets by ISO week (Monday key) across a month boundary', () => {
const trend = usageTrend(
[entry('cust_1', 10, '2026-07-31T10:00:00Z'), entry('cust_1', 20, '2026-08-01T10:00:00Z')],
'week',
);
expect(trend).toEqual([{ bucket: '2026-07-27', calls: 2, cents: 30 }]);
});
});
describe('toCsv', () => {
it('escapes commas, quotes, and newlines per RFC 4180', () => {
const csv = toCsv(
[{ name: 'Ada, "The Great"', note: 'line1\nline2', cents: 50 }],
[
{ key: 'name', label: 'Name' },
{ key: 'note', label: 'Note' },
{ key: 'cents', label: 'Cents' },
],
);
expect(csv).toBe('Name,Note,Cents\n"Ada, ""The Great""","line1\nline2",50');
});
});

39
tests/stripe.test.ts Normal file
View file

@ -0,0 +1,39 @@
import { MeterEventClient, reportUsage, METER_EVENT_NAME } from '../src/billing/stripe';
import { UsageEntry } from '../src/usage';
const entry = (cents: number): UsageEntry => ({
customerId: 'cust_1',
endpointId: 'storage',
cents,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(),
});
const fakeClient = () => {
const calls: { eventName: string; customerId: string; value: string }[] = [];
const client: MeterEventClient = {
createMeterEvent: async (params) => {
calls.push(params);
},
};
return { client, calls };
};
describe('reportUsage', () => {
it('reports usage minus the monthly credit as one meter event', async () => {
const { client, calls } = fakeClient();
const reported = await reportUsage(client, 'cus_123', [entry(112), entry(4)], 100);
expect(reported).toBe(16); // 116 - 100 credit
expect(calls).toEqual([
{ eventName: METER_EVENT_NAME, customerId: 'cus_123', value: '16' },
]);
});
it('reports nothing when the credit covers all usage', async () => {
const { client, calls } = fakeClient();
const reported = await reportUsage(client, 'cus_123', [entry(4)], 100);
expect(reported).toBe(0);
expect(calls).toHaveLength(0);
});
});

41
tests/usage.test.ts Normal file
View file

@ -0,0 +1,41 @@
import { InMemoryUsageRepo, UsageEntry } from '../src/usage';
const entry = (over: Partial<UsageEntry> = {}): UsageEntry => ({
customerId: 'cust_1',
endpointId: 'transform',
cents: 4,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date('2026-07-27T10:00:00Z'),
...over,
});
describe('InMemoryUsageRepo', () => {
it('records and lists entries per customer', () => {
const repo = new InMemoryUsageRepo();
repo.record(entry());
repo.record(entry({ customerId: 'cust_2' }));
expect(repo.listFor('cust_1')).toHaveLength(1);
expect(repo.listFor('cust_2')).toHaveLength(1);
});
it('filters entries by since date', () => {
const repo = new InMemoryUsageRepo();
repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') }));
repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') }));
expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1);
});
it('summarizes totals by endpoint', () => {
const repo = new InMemoryUsageRepo();
repo.record(entry());
repo.record(entry({ endpointId: 'storage', cents: 112 }));
repo.record(entry());
const s = repo.summaryFor('cust_1');
expect(s.customerId).toBe('cust_1');
expect(s.calls).toBe(3);
expect(s.totalCents).toBe(120);
expect(s.byEndpoint.transform).toEqual({ calls: 2, cents: 8 });
expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 });
});
});