maestro/src/bridge/usage-api.test.ts
oss-sync 641fe0177d
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (bfcd4d5)
2026-06-11 15:12:40 +00:00

281 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Usage dashboard API v2 (GET /api/usage/daily) tests.
*
* Coverage:
* - admin sees all users + byUser breakdown; non-admin scoped to own rows
* - no-auth (authActive=false) sees everyone (scope 'all')
* - default groupBy=source keeps gateway/direct totals; series carries segments
* - day / week / month bucketing
* - groupBy model / route / user / org produces the right series keys
* - tzOffset re-buckets UTC hours into the viewer's local calendar day
* - inclusive range, default range, from>to → 400, range-too-large → 400
* - invalid dates fall back to defaults (not 500)
*
* Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
*/
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { createUsageRouter } from './usage-api.js';
function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: string; role?: string } }) {
const app = express();
app.use((req, _res, next) => {
if (opts.user) (req as unknown as { user: unknown }).user = opts.user;
next();
});
app.use('/api/usage', createUsageRouter(repo, { authActive: opts.authActive }));
return app;
}
/** Seed at hour grain. `day` is expanded to noon UTC so it maps to the same
* local day under tzOffset=0 (the default the legacy assertions rely on). */
function seed(
repo: Repository,
rows: Array<{ day?: string; hour?: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>,
) {
for (const r of rows) {
repo.incrementLlmUsageHourly({
hour: r.hour ?? `${r.day}T12`,
userId: r.userId, source: r.source,
model: r.model ?? 'm', route: r.route ?? 'r',
tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1,
});
}
}
describe('GET /api/usage/daily', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
seed(repo, [
{ day: '2026-06-10', userId: 'u1', source: 'gateway', tin: 100, tout: 40 },
{ day: '2026-06-10', userId: 'u1', source: 'direct', model: 'x', route: 'h', tin: 10, tout: 5 },
{ day: '2026-06-11', userId: 'u1', source: 'gateway', tin: 7, tout: 3 },
{ day: '2026-06-11', userId: 'u2', source: 'direct', tin: 1000, tout: 500 },
]);
});
it('non-admin sees only their own rows (scope=self, no byUser)', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.status).toBe(200);
expect(res.body.scope).toBe('self');
expect(res.body.byUser).toBeUndefined();
// u1 only: gateway 100+40+7+3=150, direct 10+5=15
expect(res.body.totals.gateway).toMatchObject({ tokensIn: 107, tokensOut: 43, requests: 2 });
expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 });
});
it('default groupBy is source with gateway→direct keys', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.body.groupBy).toBe('source');
expect(res.body.keys).toEqual(['gateway', 'direct']);
// series carries per-key segments now
const b = res.body.series.find((x: { bucket: string }) => x.bucket === '2026-06-10');
expect(b.segments.gateway).toMatchObject({ tokensIn: 100, tokensOut: 40 });
expect(b.segments.direct).toMatchObject({ tokensIn: 10, tokensOut: 5 });
});
it('admin sees all users with a byUser breakdown', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.status).toBe(200);
expect(res.body.scope).toBe('all');
expect(res.body.totals.direct.tokensIn).toBe(1010); // u1 10 + u2 1000
const users = (res.body.byUser as Array<{ userId: string }>).map((u) => u.userId).sort();
expect(users).toEqual(['u1', 'u2']);
// sorted by total tokens desc → u2 (1500) first
expect(res.body.byUser[0].userId).toBe('u2');
});
it('resolves byUser display names (real users → name, sentinels verbatim)', async () => {
const u = repo.createUser({ email: 'alice@example.com', name: 'Alice', role: 'user', status: 'active' });
seed(repo, [
{ day: '2026-06-11', userId: u.id, source: 'direct', tin: 5, tout: 5 },
{ day: '2026-06-11', userId: 'local', source: 'direct', tin: 1, tout: 1 },
]);
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
const byId = Object.fromEntries((res.body.byUser as Array<{ userId: string; displayName: string }>).map((r) => [r.userId, r.displayName]));
expect(byId[u.id]).toBe('Alice');
expect(byId['local']).toBe('local'); // sentinel returned verbatim for UI localization
});
it('no-auth mode (authActive=false) sees everyone', async () => {
const app = makeApp(repo, { authActive: false }); // no req.user
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.status).toBe(200);
expect(res.body.scope).toBe('all');
expect(res.body.byUser.length).toBe(2);
});
it('day granularity yields one bucket per active day', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-11&granularity=day');
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10', '2026-06-11']);
});
it('month granularity collapses days into a YYYY-MM bucket', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30&granularity=month');
expect(res.body.series).toHaveLength(1);
expect(res.body.series[0].bucket).toBe('2026-06');
});
it('week granularity uses an ISO YYYY-Www bucket', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-08&to=2026-06-14&granularity=week');
// 2026-06-10 / -11 fall in ISO week 24 of 2026
expect(res.body.series).toHaveLength(1);
expect(res.body.series[0].bucket).toBe('2026-W24');
});
it('rejects from > to with 400', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-30&to=2026-06-01');
expect(res.status).toBe(400);
});
it('rejects an absurdly large range with 400', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2000-01-01&to=2026-06-30');
expect(res.status).toBe(400);
});
it('falls back to defaults for invalid dates (no 500)', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-99-99');
expect(res.status).toBe(200);
// default window is the last 30 days, ending today
expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe('GET /api/usage/daily — v2 group-by axes', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
seed(repo, [
{ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'big', route: 'pool-a', tin: 100, tout: 40 },
{ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'small', route: 'pool-b', tin: 20, tout: 10 },
{ day: '2026-06-11', userId: 'u2', source: 'direct', model: 'big', route: 'pool-a', tin: 5, tout: 5 },
]);
});
it('groupBy=model splits series by model, ordered by total desc', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
expect(res.body.groupBy).toBe('model');
expect(res.body.keys).toEqual(['big', 'small']); // big 150 > small 30
expect(res.body.totals.big).toMatchObject({ tokensIn: 105, tokensOut: 45 });
expect(res.body.totals.small).toMatchObject({ tokensIn: 20, tokensOut: 10 });
});
it('groupBy=route splits series by backend route', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=route');
expect(res.body.keys.sort()).toEqual(['pool-a', 'pool-b']);
expect(res.body.totals['pool-a']).toMatchObject({ tokensIn: 105, tokensOut: 45 });
});
it('groupBy=user keys by id and resolves display labels', async () => {
const alice = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' });
seed(repo, [{ day: '2026-06-11', userId: alice.id, source: 'direct', tin: 1, tout: 1 }]);
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=user');
expect(res.body.keys).toContain(alice.id);
expect(res.body.labels[alice.id]).toBe('Alice');
});
it('folds the tail beyond 12 series into a single "other" bucket', async () => {
const r2 = new Repository(':memory:');
// 14 distinct models with descending totals m00 (highest) .. m13 (lowest).
for (let i = 0; i < 14; i++) {
seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 100 - i, tout: 0 }]);
}
const app = makeApp(r2, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
expect(res.body.keys).toHaveLength(12); // 11 kept + 'other'
expect(res.body.keys[res.body.keys.length - 1]).toBe('other');
// folded tail = m11+m12+m13 inputs = (100-11)+(100-12)+(100-13) = 264
expect(res.body.totals.other).toMatchObject({ tokensIn: 264 });
// no key is duplicated
expect(new Set(res.body.keys).size).toBe(res.body.keys.length);
});
it('merges a real series literally named "other" with the folded tail (no dup key)', async () => {
const r2 = new Repository(':memory:');
// A high-volume model called 'other' that survives in the kept top-11…
seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'other', tin: 1000, tout: 0 }]);
// …plus 13 smaller models so folding still happens.
for (let i = 0; i < 13; i++) {
seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 50 - i, tout: 0 }]);
}
const app = makeApp(r2, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
// 'other' appears exactly once
expect(res.body.keys.filter((k: string) => k === 'other')).toHaveLength(1);
expect(new Set(res.body.keys).size).toBe(res.body.keys.length);
// its total is the real 1000 PLUS the folded tail, not overwritten
expect(res.body.totals.other.tokensIn).toBeGreaterThan(1000);
});
it('groupBy=org maps users to their org and buckets the orgless under no-org', async () => {
const db = repo.getDb();
db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run();
db.prepare("INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES ('u1','g1','Acme')").run();
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=org');
// u1 → Acme, u2 has no org → no-org
expect(res.body.keys.sort()).toEqual(['Acme', 'no-org']);
expect(res.body.totals['Acme']).toMatchObject({ tokensIn: 120, tokensOut: 50 });
expect(res.body.totals['no-org']).toMatchObject({ tokensIn: 5, tokensOut: 5 });
});
});
describe('GET /api/usage/daily — tzOffset local re-bucketing', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
// UTC 2026-06-10T20 → JST (+540) is 2026-06-11 05:00 → local day 06-11.
seed(repo, [{ hour: '2026-06-10T20', userId: 'u1', source: 'direct', tin: 9, tout: 1 }]);
});
it('UTC offset (tzOffset=0) lands the row on the UTC day', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=0');
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']);
expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 });
});
it('JST offset (tzOffset=540) shifts the row onto the local next day', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=540');
expect(res.body.tzOffset).toBe(540);
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-11']);
expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 });
});
it('the same UTC row is absent from the UTC day once viewed in JST', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=540');
// local day for the row is 06-11, so the 06-10 window is empty
expect(res.body.series).toHaveLength(0);
});
it('a negative offset (US Eastern, -300) shifts an early-UTC row to the previous local day', async () => {
const r2 = new Repository(':memory:');
// UTC 2026-06-11T02 5h = 2026-06-10T21 → local day 06-10.
seed(r2, [{ hour: '2026-06-11T02', userId: 'u1', source: 'direct', tin: 7, tout: 0 }]);
const app = makeApp(r2, { authActive: false });
const onUtcDay = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=-300');
expect(onUtcDay.body.series).toHaveLength(0); // not on 06-11 local
const onLocalDay = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=-300');
expect(onLocalDay.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']);
expect(onLocalDay.body.totals.direct).toMatchObject({ tokensIn: 7 });
});
});