/** * 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 }); }); });