mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(admin): sum today's AI spend on each account's own local day
This commit is contained in:
1 parent
5dbc6fcec1
commit
3c6150e837
13 files changed
+187
-51
No files matched your search
@@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
coachUsage: {
|
||||
aggregate: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
// v1.42 — the operator key's model check reads the admin settings.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
@@ -59,7 +59,19 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
|
||||
const aggregate = vi.mocked(prisma.coachUsage.aggregate);
|
||||
const usageRows = vi.mocked(prisma.coachUsage.findMany);
|
||||
|
||||
/** A spend row as the reader selects it. */
|
||||
function usage(
|
||||
dateKey: string,
|
||||
totalTokens: number,
|
||||
operatorTokens: number,
|
||||
timezone: string | null = "UTC",
|
||||
) {
|
||||
return { dateKey, totalTokens, operatorTokens, user: { timezone } };
|
||||
}
|
||||
|
||||
const utcToday = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function group(
|
||||
providerType: string,
|
||||
@@ -91,9 +103,7 @@ describe("GET /api/admin/provider-health", () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin1" },
|
||||
} as never);
|
||||
aggregate.mockResolvedValue({
|
||||
_sum: { totalTokens: 0, operatorTokens: 0 },
|
||||
} as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("is admin-gated before it touches the ledger", async () => {
|
||||
@@ -231,31 +241,44 @@ describe("GET /api/admin/provider-health", () => {
|
||||
*/
|
||||
it("reports the day's spend split by who paid for it", async () => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
aggregate.mockResolvedValue({
|
||||
_sum: { totalTokens: 1_240_000, operatorTokens: 151_200 },
|
||||
} as never);
|
||||
usageRows.mockResolvedValue([
|
||||
usage(utcToday(), 1_000_000, 100_000),
|
||||
usage(utcToday(), 240_000, 51_200),
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.data.spendToday).toEqual({
|
||||
dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
totalTokens: 1_240_000,
|
||||
operatorTokens: 151_200,
|
||||
accounts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts each account on its own today and nothing from another day", async () => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
const yesterday = new Date(Date.now() - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
usageRows.mockResolvedValue([
|
||||
usage(utcToday(), 500, 100),
|
||||
// The same UTC account's yesterday: already closed, not today's spend.
|
||||
usage(yesterday, 9_999, 9_999),
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.spendToday).toEqual({
|
||||
totalTokens: 500,
|
||||
operatorTokens: 100,
|
||||
accounts: 1,
|
||||
});
|
||||
// Today only, across every account — the operator's ceiling is per day.
|
||||
expect(aggregate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a day with no spend as zero, not null", async () => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
aggregate.mockResolvedValue({
|
||||
_sum: { totalTokens: null, operatorTokens: null },
|
||||
} as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
@@ -281,9 +304,7 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin" },
|
||||
} as never);
|
||||
aggregate.mockResolvedValue({
|
||||
_sum: { totalTokens: 0, operatorTokens: 0 },
|
||||
} as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
findMany.mockResolvedValue([] as never);
|
||||
settings.mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { buildDateKey } from "@/lib/ai/coach/budget";
|
||||
import { readInstanceSpendToday } from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -73,14 +73,9 @@ interface ProviderHealthSummary {
|
||||
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAdmin();
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.provider-health.get" } });
|
||||
|
||||
// The ledger buckets each account by its own local day; the operator's
|
||||
// "today" is the day in the operator's own zone, which on a single-zone
|
||||
// instance is everyone's.
|
||||
const dateKey = await buildDateKey(user.id);
|
||||
|
||||
const groups = await prisma.providerHealth.groupBy({
|
||||
// `lastStatus` joins the grouping key rather than an aggregate: the
|
||||
// MAX of two HTTP statuses is not a status, it is arithmetic on a
|
||||
@@ -177,17 +172,19 @@ export const GET = apiHandler(async () => {
|
||||
// alarming until you see that 151 200 of them were the instance's own key
|
||||
// and the rest ran on the users' plans. The operator ceiling is enforced
|
||||
// against the second figure, so both belong on screen.
|
||||
const spend = await prisma.coachUsage.aggregate({
|
||||
where: { dateKey },
|
||||
_sum: { totalTokens: true, operatorTokens: true },
|
||||
});
|
||||
//
|
||||
// "Today" is each account's own calendar day, the day its ceilings are
|
||||
// enforced on (`readInstanceSpendToday`), not one date for the instance:
|
||||
// the budget day is per account local day, so a single date key would
|
||||
// count some accounts' yesterday or tomorrow and miss part of their today.
|
||||
const spend = await readInstanceSpendToday();
|
||||
|
||||
return apiSuccess({
|
||||
providers,
|
||||
spendToday: {
|
||||
dateKey,
|
||||
totalTokens: spend._sum.totalTokens ?? 0,
|
||||
operatorTokens: spend._sum.operatorTokens ?? 0,
|
||||
totalTokens: spend.total,
|
||||
operatorTokens: spend.operator,
|
||||
accounts: spend.accounts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user