mirror of
https://github.com/Routstr/routstrd.git
synced 2026-07-30 15:46:14 +00:00
test: cover /usage/summary builder
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { createMemoryUsageTrackingDriver } from "@routstr/sdk/storage";
|
||||
import type { UsageTrackingEntry } from "@routstr/sdk/storage";
|
||||
import type { ClientEntry } from "../../utils/clients";
|
||||
import { getUsageSummary } from "./usage-summary";
|
||||
|
||||
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All timestamps fall within 30 days of 2026-06-02 (today as of this writing).
|
||||
// tz = 300 (UTC-5 / EST). After shifting by -300min, UTC dates become:
|
||||
// d1: 2026-05-20T10:00Z → shifted 2026-05-20T05:00Z → local day "2026-05-20", hour 05
|
||||
// d2: 2026-05-21T03:00Z → shifted 2026-05-20T22:00Z → local day "2026-05-20", hour 22
|
||||
// d3: 2026-05-21T06:00Z → shifted 2026-05-21T01:00Z → local day "2026-05-21", hour 01
|
||||
|
||||
const D1 = new Date("2026-05-20T10:00:00Z").getTime(); // local 2026-05-20, hour 05
|
||||
const D2 = new Date("2026-05-21T03:00:00Z").getTime(); // local 2026-05-20, hour 22
|
||||
const D3 = new Date("2026-05-21T06:00:00Z").getTime(); // local 2026-05-21, hour 01
|
||||
|
||||
const BASE_ENTRY: Omit<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "client" | "totalTokens"> = {
|
||||
baseUrl: "https://api.openai.com/",
|
||||
requestId: "req-1",
|
||||
cost: 0.01,
|
||||
satsCost: 10,
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
function makeEntry(
|
||||
id: string,
|
||||
overrides: Partial<UsageTrackingEntry> & Pick<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "totalTokens">,
|
||||
): UsageTrackingEntry {
|
||||
return {
|
||||
...BASE_ENTRY,
|
||||
...overrides,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
// Seed entries:
|
||||
// - 2 entries for model-a, client-1 (owned by npub1)
|
||||
// - 1 entry for model-b, client-2 (no npub)
|
||||
// - 1 entry for model-a, no client (null client)
|
||||
// - totalTokens spread across tiny (< 1000) and small (1000-10000) buckets
|
||||
|
||||
const ENTRIES: UsageTrackingEntry[] = [
|
||||
// model-a, client-1, day 2026-05-20 (D1), totalTokens=500 (tiny)
|
||||
makeEntry("e1", {
|
||||
id: "e1",
|
||||
timestamp: D1,
|
||||
modelId: "model-a",
|
||||
client: "client-1",
|
||||
totalTokens: 500,
|
||||
promptTokens: 350,
|
||||
completionTokens: 150,
|
||||
satsCost: 10,
|
||||
cost: 0.01,
|
||||
}),
|
||||
// model-a, client-1, day 2026-05-20 (D2), totalTokens=2000 (small)
|
||||
makeEntry("e2", {
|
||||
id: "e2",
|
||||
timestamp: D2,
|
||||
modelId: "model-a",
|
||||
client: "client-1",
|
||||
totalTokens: 2000,
|
||||
promptTokens: 1400,
|
||||
completionTokens: 600,
|
||||
satsCost: 20,
|
||||
cost: 0.02,
|
||||
}),
|
||||
// model-b, client-2, day 2026-05-21 (D3), totalTokens=800 (tiny)
|
||||
makeEntry("e3", {
|
||||
id: "e3",
|
||||
timestamp: D3,
|
||||
modelId: "model-b",
|
||||
client: "client-2",
|
||||
totalTokens: 800,
|
||||
promptTokens: 600,
|
||||
completionTokens: 200,
|
||||
satsCost: 8,
|
||||
cost: 0.008,
|
||||
}),
|
||||
// model-a, no client, day 2026-05-21 (D3), totalTokens=5000 (small)
|
||||
makeEntry("e4", {
|
||||
id: "e4",
|
||||
timestamp: D3 + 1000,
|
||||
modelId: "model-a",
|
||||
client: undefined,
|
||||
totalTokens: 5000,
|
||||
promptTokens: 3500,
|
||||
completionTokens: 1500,
|
||||
satsCost: 50,
|
||||
cost: 0.05,
|
||||
}),
|
||||
];
|
||||
|
||||
const CLIENTS: ClientEntry[] = [
|
||||
{
|
||||
clientId: "client-1",
|
||||
name: "Client One",
|
||||
apiKey: "sk-client1",
|
||||
createdAt: D1 - 86400000,
|
||||
ownerNpub: "npub1abc",
|
||||
},
|
||||
{
|
||||
clientId: "client-2",
|
||||
name: "Client Two",
|
||||
apiKey: "sk-client2",
|
||||
createdAt: D1 - 86400000,
|
||||
// no ownerNpub
|
||||
},
|
||||
];
|
||||
|
||||
const TZ = 300; // UTC-5 / EST
|
||||
|
||||
describe("getUsageSummary", () => {
|
||||
let driver: ReturnType<typeof createMemoryUsageTrackingDriver>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset module-level cache between tests by creating a fresh driver with a
|
||||
// distinct entry count each time — the cache key changes.
|
||||
driver = createMemoryUsageTrackingDriver(ENTRIES);
|
||||
});
|
||||
|
||||
it("returns correct totals", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
|
||||
expect(summary.totals.requests).toBe(4);
|
||||
expect(summary.totals.totalTokens).toBe(500 + 2000 + 800 + 5000);
|
||||
expect(summary.totals.promptTokens).toBe(350 + 1400 + 600 + 3500);
|
||||
expect(summary.totals.completionTokens).toBe(150 + 600 + 200 + 1500);
|
||||
expect(summary.totals.satsCost).toBe(10 + 20 + 8 + 50);
|
||||
expect(summary.totals.cost).toBeCloseTo(0.01 + 0.02 + 0.008 + 0.05, 5);
|
||||
});
|
||||
|
||||
it("returns models sorted desc by satsCost", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const { models } = summary;
|
||||
|
||||
// model-a: 3 entries (e1, e2, e4) total satsCost = 10+20+50 = 80
|
||||
// model-b: 1 entry (e3) satsCost = 8
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0]!.modelId).toBe("model-a");
|
||||
expect(models[0]!.satsCost).toBe(80);
|
||||
expect(models[0]!.requests).toBe(3);
|
||||
expect(models[1]!.modelId).toBe("model-b");
|
||||
expect(models[1]!.satsCost).toBe(8);
|
||||
});
|
||||
|
||||
it("returns days most-recent-first with correct tz-bucketing", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const { days } = summary;
|
||||
|
||||
// D1 (10:00Z) → local 2026-05-20, D2 (03:00Z) → local 2026-05-20, D3 (06:00Z) → local 2026-05-21
|
||||
// So: day 2026-05-20 has e1+e2, day 2026-05-21 has e3+e4
|
||||
expect(days.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Most-recent-first: 2026-05-21 first
|
||||
expect(days[0]!.date).toBe("2026-05-21");
|
||||
expect(days[0]!.requests).toBe(2); // e3, e4
|
||||
expect(days[0]!.satsCost).toBe(8 + 50);
|
||||
|
||||
expect(days[1]!.date).toBe("2026-05-20");
|
||||
expect(days[1]!.requests).toBe(2); // e1, e2
|
||||
expect(days[1]!.satsCost).toBe(10 + 20);
|
||||
});
|
||||
|
||||
it("returns empty hoursToday (test entries are not today)", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
// Test entries are in May 2026, not today (June 2026)
|
||||
expect(summary.hoursToday).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns correct sizeBuckets", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const { sizeBuckets } = summary;
|
||||
|
||||
// tiny [0, 1000): e1 (500) + e3 (800) = 2 entries
|
||||
expect(sizeBuckets.tiny.count).toBe(2);
|
||||
expect(sizeBuckets.tiny.cost).toBe(10 + 8);
|
||||
|
||||
// small [1000, 10000): e2 (2000) + e4 (5000) = 2 entries
|
||||
expect(sizeBuckets.small.count).toBe(2);
|
||||
expect(sizeBuckets.small.cost).toBe(20 + 50);
|
||||
|
||||
// medium, large, huge should be 0
|
||||
expect(sizeBuckets.medium.count).toBe(0);
|
||||
expect(sizeBuckets.large.count).toBe(0);
|
||||
expect(sizeBuckets.huge.count).toBe(0);
|
||||
});
|
||||
|
||||
it("returns per-client topModels for top non-null clients", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const { clients } = summary;
|
||||
|
||||
// client-1: e1 + e2 (model-a), satsCost = 30
|
||||
// client-2: e3 (model-b), satsCost = 8
|
||||
// unknown (null client): e4 (model-a), satsCost = 50
|
||||
// Sorted desc by satsCost: unknown (50), client-1 (30), client-2 (8)
|
||||
|
||||
const c1 = clients.find((c) => c.client === "client-1");
|
||||
expect(c1).toBeDefined();
|
||||
expect(c1!.requests).toBe(2);
|
||||
expect(c1!.satsCost).toBe(30);
|
||||
// client-1 is in top 3 non-null clients, should have topModels
|
||||
expect(c1!.topModels.length).toBeGreaterThan(0);
|
||||
expect(c1!.topModels[0]!.modelId).toBe("model-a");
|
||||
|
||||
const c2 = clients.find((c) => c.client === "client-2");
|
||||
expect(c2).toBeDefined();
|
||||
expect(c2!.requests).toBe(1);
|
||||
expect(c2!.satsCost).toBe(8);
|
||||
|
||||
// unknown client (null group) should have empty topModels
|
||||
const unknown = clients.find((c) => c.client === "unknown");
|
||||
expect(unknown).toBeDefined();
|
||||
expect(unknown!.topModels).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("folds client rows into npubs correctly", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const { npubs } = summary;
|
||||
|
||||
// Only client-1 has ownerNpub = "npub1abc"
|
||||
// npub1abc: e1 + e2 = requests 2, satsCost 30
|
||||
expect(npubs).toHaveLength(1);
|
||||
expect(npubs[0]!.npub).toBe("npub1abc");
|
||||
expect(npubs[0]!.requests).toBe(2);
|
||||
expect(npubs[0]!.satsCost).toBe(30);
|
||||
// Top models for npub1abc (clients: ["client-1"]): only model-a
|
||||
expect(npubs[0]!.topModels.length).toBeGreaterThan(0);
|
||||
expect(npubs[0]!.topModels[0]!.modelId).toBe("model-a");
|
||||
});
|
||||
|
||||
it("returns recent entries (up to 50)", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
expect(summary.recent).toHaveLength(4); // only 4 entries in driver
|
||||
// Should be sorted desc by timestamp
|
||||
expect(summary.recent[0]!.timestamp).toBeGreaterThanOrEqual(summary.recent[1]!.timestamp);
|
||||
});
|
||||
|
||||
it("returns providers correctly", async () => {
|
||||
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const { providers } = summary;
|
||||
|
||||
// All entries use the same baseUrl
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0]!.baseUrl).toBe("https://api.openai.com/");
|
||||
expect(providers[0]!.requests).toBe(4);
|
||||
});
|
||||
|
||||
it("caches results for same key within TTL", async () => {
|
||||
const summary1 = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
const summary2 = await getUsageSummary(driver, CLIENTS, TZ);
|
||||
// Same object reference means it was served from cache
|
||||
expect(summary1).toBe(summary2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user