183 lines
7.0 KiB
TypeScript
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();
|
|
});
|
|
});
|