Files
maestro/src/db/repository.gateway-usage.test.ts
T

183 lines
7.0 KiB
TypeScript

/**
* AAO Gateway Phase 2b — gateway_key_usage repository tests.
*
* Coverage:
* - getGatewayKeyUsage returns null on miss, row on hit
* - incrementGatewayKeyUsage UPSERTs first call, accumulates on second
* - incrementGatewayKeyUsage clamps negative deltas to zero
* - listGatewayKeyUsagesByKey orders by period_start DESC
* - Cascade delete removes usage rows when key is deleted
* - tokens_budget / rate_limit_rpm fields persist & round-trip
* - updateGatewayVirtualKey patches fields independently and supports null reset
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { Repository } from './repository.js';
import { hashKey } from '../gateway/key-format.js';
function makeRepo(): Repository {
// ':memory:' triggers Repository.initSchema (creates all tables fresh).
return new Repository(':memory:');
}
function seedKey(repo: Repository, raw: string, team = 'alpha'): string {
return repo.createGatewayVirtualKey({
keyHash: hashKey(raw),
keyPrefix: raw.slice(0, 14),
team,
}).id;
}
describe('gateway_key_usage repository', () => {
let repo: Repository;
beforeEach(() => {
repo = makeRepo();
});
it('getGatewayKeyUsage returns null when no row exists', () => {
const id = seedKey(repo, 'sk-aao-test-1');
expect(repo.getGatewayKeyUsage(id, '2026-05')).toBeNull();
});
it('incrementGatewayKeyUsage creates row on first call, accumulates on second', () => {
const id = seedKey(repo, 'sk-aao-test-2');
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 100, tokensOut: 50, requests: 1 });
let usage = repo.getGatewayKeyUsage(id, '2026-05');
expect(usage).not.toBeNull();
expect(usage!.tokensIn).toBe(100);
expect(usage!.tokensOut).toBe(50);
expect(usage!.requests).toBe(1);
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 200, tokensOut: 80, requests: 1 });
usage = repo.getGatewayKeyUsage(id, '2026-05');
expect(usage!.tokensIn).toBe(300);
expect(usage!.tokensOut).toBe(130);
expect(usage!.requests).toBe(2);
});
it('different periods get separate rows', () => {
const id = seedKey(repo, 'sk-aao-test-3');
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', tokensIn: 10, requests: 1 });
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 20, requests: 1 });
expect(repo.getGatewayKeyUsage(id, '2026-04')!.tokensIn).toBe(10);
expect(repo.getGatewayKeyUsage(id, '2026-05')!.tokensIn).toBe(20);
});
it('clamps negative deltas to zero', () => {
const id = seedKey(repo, 'sk-aao-test-4');
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 100 });
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: -500, tokensOut: -10, requests: -1 });
const usage = repo.getGatewayKeyUsage(id, '2026-05')!;
expect(usage.tokensIn).toBe(100); // unchanged
expect(usage.tokensOut).toBe(0);
expect(usage.requests).toBe(0);
});
it('listGatewayKeyUsagesByKey returns rows newest period first', () => {
const id = seedKey(repo, 'sk-aao-test-5');
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-03', requests: 1 });
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', requests: 1 });
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', requests: 1 });
const list = repo.listGatewayKeyUsagesByKey(id);
expect(list.map(u => u.periodStart)).toEqual(['2026-05', '2026-04', '2026-03']);
});
it('listGatewayKeyUsagesByKey honors limit', () => {
const id = seedKey(repo, 'sk-aao-test-6');
for (const m of ['2026-01', '2026-02', '2026-03', '2026-04']) {
repo.incrementGatewayKeyUsage({ keyId: id, period: m, requests: 1 });
}
const list = repo.listGatewayKeyUsagesByKey(id, { limit: 2 });
expect(list).toHaveLength(2);
expect(list[0]!.periodStart).toBe('2026-04');
});
it('cascade-deletes usage rows when key is hard-deleted', () => {
const id = seedKey(repo, 'sk-aao-test-7');
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 1 });
expect(repo.getGatewayKeyUsage(id, '2026-05')).not.toBeNull();
// The Repository's defense-in-depth guard refuses to delete
// config-import rows. Seeded above as 'admin' so this is legal.
repo.deleteGatewayVirtualKey(id);
expect(repo.getGatewayKeyUsage(id, '2026-05')).toBeNull();
});
});
describe('gateway_virtual_keys budget/rate fields', () => {
let repo: Repository;
beforeEach(() => {
repo = makeRepo();
});
it('round-trips tokensBudget and rateLimitRpm on create', () => {
const created = repo.createGatewayVirtualKey({
keyHash: hashKey('sk-aao-create-budget'),
keyPrefix: 'sk-aao-create',
team: 'alpha',
tokensBudget: 1_000_000,
rateLimitRpm: 60,
});
expect(created.tokensBudget).toBe(1_000_000);
expect(created.rateLimitRpm).toBe(60);
const refreshed = repo.findGatewayVirtualKeyById(created.id)!;
expect(refreshed.tokensBudget).toBe(1_000_000);
expect(refreshed.rateLimitRpm).toBe(60);
});
it('defaults to null when budget/rate omitted', () => {
const created = repo.createGatewayVirtualKey({
keyHash: hashKey('sk-aao-no-limits'),
keyPrefix: 'sk-aao-no-lim',
team: 'beta',
});
expect(created.tokensBudget).toBeNull();
expect(created.rateLimitRpm).toBeNull();
});
it('coerces zero or negative limits to null (defensive)', () => {
const created = repo.createGatewayVirtualKey({
keyHash: hashKey('sk-aao-bad-limits'),
keyPrefix: 'sk-aao-bad',
team: 'gamma',
tokensBudget: 0,
rateLimitRpm: -5,
});
expect(created.tokensBudget).toBeNull();
expect(created.rateLimitRpm).toBeNull();
});
it('updateGatewayVirtualKey patches only specified fields', () => {
const created = repo.createGatewayVirtualKey({
keyHash: hashKey('sk-aao-patch'),
keyPrefix: 'sk-aao-patch',
team: 'alpha',
tokensBudget: 1000,
rateLimitRpm: 60,
allowedModels: ['qwen3:8b'],
});
// Patch only tokensBudget — other fields untouched
const after1 = repo.updateGatewayVirtualKey(created.id, { tokensBudget: 5000 });
expect(after1.tokensBudget).toBe(5000);
expect(after1.rateLimitRpm).toBe(60);
expect(after1.allowedModels).toEqual(['qwen3:8b']);
// Patch allowedModels alone
const after2 = repo.updateGatewayVirtualKey(created.id, { allowedModels: ['qwen3:14b'] });
expect(after2.allowedModels).toEqual(['qwen3:14b']);
expect(after2.tokensBudget).toBe(5000);
// Reset rate limit to null (unlimited)
const after3 = repo.updateGatewayVirtualKey(created.id, { rateLimitRpm: null });
expect(after3.rateLimitRpm).toBeNull();
// Reset allowedModels to null (no allowlist)
const after4 = repo.updateGatewayVirtualKey(created.id, { allowedModels: null });
expect(after4.allowedModels).toBeNull();
});
it('updateGatewayVirtualKey throws for unknown id', () => {
expect(() => repo.updateGatewayVirtualKey('does-not-exist', { tokensBudget: 1 })).toThrow();
});
});