281 lines
14 KiB
TypeScript
281 lines
14 KiB
TypeScript
/**
|
||
* 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 });
|
||
});
|
||
});
|