Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
b8d239e759
120 changed files with 19867 additions and 0 deletions
114
tests/accounts.test.ts
Normal file
114
tests/accounts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
207
tests/admin-accounting.test.ts
Normal file
207
tests/admin-accounting.test.ts
Normal 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
115
tests/admin-users.test.ts
Normal 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
156
tests/admin.test.ts
Normal 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
152
tests/app.test.ts
Normal 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
51
tests/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
tests/billing-delta.test.ts
Normal file
19
tests/billing-delta.test.ts
Normal 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
33
tests/credit.test.ts
Normal 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
58
tests/db-billing.test.ts
Normal 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
116
tests/db-customer.test.ts
Normal 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
72
tests/db-invoice.test.ts
Normal 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
63
tests/db-pricing.test.ts
Normal 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
32
tests/db-session.test.ts
Normal 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
40
tests/db-usage.test.ts
Normal 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
86
tests/invoicing.test.ts
Normal 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
86
tests/meter.test.ts
Normal 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
21
tests/paths.test.ts
Normal 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
320
tests/portal.test.ts
Normal 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 customer’s 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 customer’s 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 customer’s 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
121
tests/pricing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
245
tests/report-usage-idempotency.test.ts
Normal file
245
tests/report-usage-idempotency.test.ts
Normal 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 B’s 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
100
tests/reports.test.ts
Normal 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
39
tests/stripe.test.ts
Normal 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
41
tests/usage.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue