mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
feat(quota): per-pool usage-log endpoint + card
This commit is contained in:
@@ -10,6 +10,7 @@ import AllocationTable from "./AllocationTable";
|
|||||||
import BurnRateChart from "./BurnRateChart";
|
import BurnRateChart from "./BurnRateChart";
|
||||||
import StackedAllocationBar from "./StackedAllocationBar";
|
import StackedAllocationBar from "./StackedAllocationBar";
|
||||||
import AccountQuotaRow from "./AccountQuotaRow";
|
import AccountQuotaRow from "./AccountQuotaRow";
|
||||||
|
import UsageLogCard from "./UsageLogCard";
|
||||||
|
|
||||||
export interface PoolCardProps {
|
export interface PoolCardProps {
|
||||||
pool: QuotaPool;
|
pool: QuotaPool;
|
||||||
@@ -179,6 +180,9 @@ export default function PoolCard({
|
|||||||
<BurnRateChart usage={usage} />
|
<BurnRateChart usage={usage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Usage log — collapsible, collapsed by default */}
|
||||||
|
<UsageLogCard poolId={pool.id} keyLabels={keyLabels} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { ConsumptionEvent } from "@/lib/db/quotaConsumption";
|
||||||
|
|
||||||
|
export interface UsageLogCardProps {
|
||||||
|
poolId: string;
|
||||||
|
/** Optional map from apiKeyId to display label */
|
||||||
|
keyLabels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(epochMs: number): string {
|
||||||
|
try {
|
||||||
|
return new Date(epochMs).toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(epochMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UsageLogCard — collapsible footer card that shows the N most-recent
|
||||||
|
* quota_consumption events for a pool, sourced from
|
||||||
|
* GET /api/quota/pools/[id]/log.
|
||||||
|
*
|
||||||
|
* Fail-soft: on error / loading / no data → renders an empty-state message.
|
||||||
|
* Never throws; never crashes the parent PoolCard.
|
||||||
|
*
|
||||||
|
* Collapsed by default so pool cards stay compact.
|
||||||
|
*/
|
||||||
|
export default function UsageLogCard({ poolId, keyLabels }: UsageLogCardProps) {
|
||||||
|
const t = useTranslations("quotaShare");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [events, setEvents] = useState<ConsumptionEvent[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let alive = true;
|
||||||
|
fetch(`/api/quota/pools/${poolId}/log`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const raw: unknown = data?.events;
|
||||||
|
setEvents(Array.isArray(raw) ? (raw as ConsumptionEvent[]) : [] ?? []);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) {
|
||||||
|
setEvents([] ?? []);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [open, poolId]);
|
||||||
|
|
||||||
|
const keyLabel = (apiKeyId: string): string =>
|
||||||
|
keyLabels?.[apiKeyId] ?? apiKeyId.slice(0, 10) + "…";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/30">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="flex items-center gap-1 text-[10px] uppercase tracking-wide font-bold text-text-muted hover:text-text-main w-full text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined text-[13px] transition-transform ${open ? "rotate-90" : ""}`}
|
||||||
|
>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
{t("logTitle")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{!loaded ? (
|
||||||
|
<div className="text-[11px] text-text-muted italic">{t("loading")}</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className="text-[11px] text-text-muted italic">{t("logEmpty")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{events.map((ev, i) => (
|
||||||
|
<div
|
||||||
|
key={`${ev.apiKeyId}-${ev.dimensionKey}-${ev.bucketIndex}-${i}`}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] text-text-muted"
|
||||||
|
>
|
||||||
|
<span className="tabular-nums text-text-muted/60 shrink-0 w-[52px]">
|
||||||
|
{formatTime(ev.updatedAt)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate max-w-[80px]" title={ev.apiKeyId}>
|
||||||
|
{keyLabel(ev.apiKeyId)}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted/50">·</span>
|
||||||
|
<span className="truncate max-w-[80px]">{ev.unit}</span>
|
||||||
|
<span className="text-text-muted/50">·</span>
|
||||||
|
<span className="tabular-nums text-text-main/80">
|
||||||
|
{ev.consumed.toFixed(0)} {ev.window}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/api/quota/pools/[id]/log/route.ts
Normal file
45
src/app/api/quota/pools/[id]/log/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/quota/pools/[id]/log — recent consumption events for a pool
|
||||||
|
*
|
||||||
|
* Returns the most-recent rows from quota_consumption whose dimension_key
|
||||||
|
* starts with "<poolId>:" (i.e. all buckets that belong to this pool).
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* limit — integer, default 50, max 200
|
||||||
|
*
|
||||||
|
* Auth: requireManagementAuth (management-gated, NOT local-only — read-only data, no spawning)
|
||||||
|
* Sanitization: all error responses via buildErrorBody (Hard Rule #12)
|
||||||
|
*
|
||||||
|
* Part of: Task 7 — Quota Share UX polish (plan 2026-05-31).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||||
|
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||||
|
import { listConsumptionForPool } from "@/lib/db/quotaConsumption";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: RouteParams): Promise<Response> {
|
||||||
|
const authError = await requireManagementAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const rawLimit = url.searchParams.get("limit");
|
||||||
|
const parsedLimit = rawLimit !== null ? parseInt(rawLimit, 10) : 50;
|
||||||
|
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||||
|
? Math.min(parsedLimit, 200)
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
const events = listConsumptionForPool(id, limit);
|
||||||
|
return NextResponse.json({ events });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to get pool log";
|
||||||
|
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7896,7 +7896,9 @@
|
|||||||
"wizardSingleProviderNote": "A pool uses a single provider",
|
"wizardSingleProviderNote": "A pool uses a single provider",
|
||||||
"wizardPreviewMoreModels": "+{count} more",
|
"wizardPreviewMoreModels": "+{count} more",
|
||||||
"accountQuotaTitle": "Account quota",
|
"accountQuotaTitle": "Account quota",
|
||||||
"accountQuotaNone": "—"
|
"accountQuotaNone": "—",
|
||||||
|
"logTitle": "Usage log",
|
||||||
|
"logEmpty": "No usage yet"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"title": "Plugins",
|
"title": "Plugins",
|
||||||
|
|||||||
@@ -5406,7 +5406,9 @@
|
|||||||
"wizardSingleProviderNote": "Um pool usa um provedor só",
|
"wizardSingleProviderNote": "Um pool usa um provedor só",
|
||||||
"wizardPreviewMoreModels": "+{count} mais",
|
"wizardPreviewMoreModels": "+{count} mais",
|
||||||
"accountQuotaTitle": "Cota da conta",
|
"accountQuotaTitle": "Cota da conta",
|
||||||
"accountQuotaNone": "—"
|
"accountQuotaNone": "—",
|
||||||
|
"logTitle": "Log de uso",
|
||||||
|
"logEmpty": "Sem uso ainda"
|
||||||
},
|
},
|
||||||
"requestLogger": {
|
"requestLogger": {
|
||||||
"recording": "Recording",
|
"recording": "Recording",
|
||||||
|
|||||||
@@ -36,6 +36,30 @@ interface BucketRow {
|
|||||||
consumed: number;
|
consumed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single consumption event row, as returned by listConsumptionForPool.
|
||||||
|
* Derived from the quota_consumption table; the poolId is stripped from
|
||||||
|
* dimension_key (format "<poolId>:<unit>:<window>") and the unit/window
|
||||||
|
* fragments are surfaced separately.
|
||||||
|
*/
|
||||||
|
export interface ConsumptionEvent {
|
||||||
|
apiKeyId: string;
|
||||||
|
/** The full dimension_key string: "<poolId>:<unit>:<window>" */
|
||||||
|
dimensionKey: string;
|
||||||
|
/** The <unit> segment of dimension_key (e.g. "tokens", "requests", "usd") */
|
||||||
|
unit: string;
|
||||||
|
/** The <window> segment of dimension_key (e.g. "hourly", "daily") */
|
||||||
|
window: string;
|
||||||
|
bucketIndex: number;
|
||||||
|
consumed: number;
|
||||||
|
/** epoch ms */
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -123,6 +147,63 @@ export function getPair(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// List recent consumption for a pool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ConsumptionRow {
|
||||||
|
api_key_id: string;
|
||||||
|
dimension_key: string;
|
||||||
|
bucket_index: number;
|
||||||
|
consumed: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the most-recent consumption rows for a given pool, ordered by
|
||||||
|
* updated_at DESC. The poolId is the first segment of dimension_key
|
||||||
|
* (format "<poolId>:<unit>:<window>"), so we filter with a LIKE prefix.
|
||||||
|
*
|
||||||
|
* @param poolId The quota pool identifier.
|
||||||
|
* @param limit Maximum rows to return (caller should clamp; default 50).
|
||||||
|
* @returns Array of ConsumptionEvent (may be empty if no data yet).
|
||||||
|
*/
|
||||||
|
export function listConsumptionForPool(poolId: string, limit: number): ConsumptionEvent[] {
|
||||||
|
const safeLimit = Math.max(1, Math.min(limit, 500));
|
||||||
|
// dimension_key format: "<poolId>:<unit>:<window>"
|
||||||
|
// The LIKE pattern uses "%" — escape literal "%" or "_" in poolId defensively.
|
||||||
|
const prefix = poolId.replace(/[%_\\]/g, "\\$&") + ":%";
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare<ConsumptionRow>(
|
||||||
|
`SELECT api_key_id, dimension_key, bucket_index, consumed, updated_at
|
||||||
|
FROM quota_consumption
|
||||||
|
WHERE dimension_key LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(prefix, safeLimit);
|
||||||
|
|
||||||
|
return rows.map((r) => {
|
||||||
|
const parts = r.dimension_key.split(":");
|
||||||
|
// parts[0] = poolId, parts[1] = unit, parts[2..] = window (rejoin in case window has colons)
|
||||||
|
const unit = parts[1] ?? "";
|
||||||
|
const window = parts.slice(2).join(":") ?? "";
|
||||||
|
return {
|
||||||
|
apiKeyId: r.api_key_id,
|
||||||
|
dimensionKey: r.dimension_key,
|
||||||
|
unit,
|
||||||
|
window,
|
||||||
|
bucketIndex: r.bucket_index,
|
||||||
|
consumed: r.consumed,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GC
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete rows whose updated_at is strictly less than maxUpdatedAtMs.
|
* Delete rows whose updated_at is strictly less than maxUpdatedAtMs.
|
||||||
* Used by GC background job to clean up stale bucket rows.
|
* Used by GC background job to clean up stale bucket rows.
|
||||||
|
|||||||
273
tests/unit/quota-pool-log-route.test.ts
Normal file
273
tests/unit/quota-pool-log-route.test.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* tests/unit/quota-pool-log-route.test.ts
|
||||||
|
*
|
||||||
|
* Task 7 — source-level assertions for the usage-log endpoint + card.
|
||||||
|
*
|
||||||
|
* Follows the same pattern as:
|
||||||
|
* tests/unit/budget-route-auth.test.ts (source-scan)
|
||||||
|
* tests/unit/quota-account-quota-row.test.ts (source-scan)
|
||||||
|
*
|
||||||
|
* We use source-scan assertions (readFileSync) so the test works with
|
||||||
|
* Node.js native test runner without a Next.js / DOM setup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
|
const ROUTE_PATH = join(
|
||||||
|
ROOT,
|
||||||
|
"src/app/api/quota/pools/[id]/log/route.ts"
|
||||||
|
);
|
||||||
|
|
||||||
|
const USAGE_LOG_CARD_PATH = join(
|
||||||
|
ROOT,
|
||||||
|
"src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx"
|
||||||
|
);
|
||||||
|
|
||||||
|
const POOL_CARD_PATH = join(
|
||||||
|
ROOT,
|
||||||
|
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx"
|
||||||
|
);
|
||||||
|
|
||||||
|
const CONSUMPTION_DB_PATH = join(
|
||||||
|
ROOT,
|
||||||
|
"src/lib/db/quotaConsumption.ts"
|
||||||
|
);
|
||||||
|
|
||||||
|
const EN_PATH = join(ROOT, "src/i18n/messages/en.json");
|
||||||
|
const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json");
|
||||||
|
|
||||||
|
// ── Load sources ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const routeSrc = readFileSync(ROUTE_PATH, "utf8");
|
||||||
|
const usageLogCardSrc = readFileSync(USAGE_LOG_CARD_PATH, "utf8");
|
||||||
|
const poolCardSrc = readFileSync(POOL_CARD_PATH, "utf8");
|
||||||
|
const consumptionDbSrc = readFileSync(CONSUMPTION_DB_PATH, "utf8");
|
||||||
|
|
||||||
|
// ── Route: auth ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("log route: imports requireManagementAuth", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("requireManagementAuth"),
|
||||||
|
"route must import and call requireManagementAuth"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: calls requireManagementAuth before any data access", () => {
|
||||||
|
// Find the GET handler body (after "export async function GET")
|
||||||
|
const handlerIdx = routeSrc.indexOf("export async function GET");
|
||||||
|
assert.ok(handlerIdx >= 0, "GET handler must exist");
|
||||||
|
const handlerBody = routeSrc.slice(handlerIdx);
|
||||||
|
// Within the handler body, auth call must come before listConsumptionForPool call
|
||||||
|
const authIdx = handlerBody.indexOf("requireManagementAuth(request)");
|
||||||
|
const listIdx = handlerBody.indexOf("listConsumptionForPool(");
|
||||||
|
assert.ok(authIdx >= 0, "requireManagementAuth call must be in GET handler");
|
||||||
|
assert.ok(listIdx >= 0, "listConsumptionForPool call must be in GET handler");
|
||||||
|
assert.ok(
|
||||||
|
authIdx < listIdx,
|
||||||
|
"auth check must come before listConsumptionForPool call"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: returns early when authError is truthy", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("if (authError) return authError"),
|
||||||
|
"route must return authError immediately"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Route: error sanitization ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("log route: uses buildErrorBody from open-sse/utils/error", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("buildErrorBody"),
|
||||||
|
"route must use buildErrorBody for error responses — Hard Rule #12"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("open-sse/utils/error") || routeSrc.includes("@omniroute/open-sse/utils/error"),
|
||||||
|
"route must import buildErrorBody from open-sse/utils/error"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: does NOT put raw err.stack in response (stack trace guard)", () => {
|
||||||
|
// Ensure err.stack is never used in the response body
|
||||||
|
assert.ok(
|
||||||
|
!routeSrc.includes("err.stack"),
|
||||||
|
"route must not leak err.stack in response"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Route: response shape ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("log route: returns { events } shape", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("{ events }") || routeSrc.includes("{events}") || routeSrc.includes("events:"),
|
||||||
|
"route must return { events } in the response body"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: calls listConsumptionForPool", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("listConsumptionForPool"),
|
||||||
|
"route must call listConsumptionForPool from quotaConsumption"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: parses limit query param with default 50", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("50"),
|
||||||
|
"route must use 50 as the default limit"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: clamps limit to max 200", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes("200"),
|
||||||
|
"route must clamp limit to a maximum of 200"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("log route: has dynamic export (force-dynamic)", () => {
|
||||||
|
assert.ok(
|
||||||
|
routeSrc.includes('dynamic = "force-dynamic"') || routeSrc.includes("dynamic = 'force-dynamic'"),
|
||||||
|
"route must export dynamic = 'force-dynamic'"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DB module: listConsumptionForPool ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test("quotaConsumption: exports listConsumptionForPool", () => {
|
||||||
|
assert.ok(
|
||||||
|
consumptionDbSrc.includes("export function listConsumptionForPool"),
|
||||||
|
"quotaConsumption.ts must export listConsumptionForPool"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotaConsumption: listConsumptionForPool filters by poolId prefix", () => {
|
||||||
|
assert.ok(
|
||||||
|
consumptionDbSrc.includes("LIKE"),
|
||||||
|
"listConsumptionForPool must use LIKE to filter by poolId prefix in dimension_key"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotaConsumption: exports ConsumptionEvent type", () => {
|
||||||
|
assert.ok(
|
||||||
|
consumptionDbSrc.includes("ConsumptionEvent"),
|
||||||
|
"quotaConsumption.ts must export the ConsumptionEvent type"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── UsageLogCard structural assertions ────────────────────────────────────────
|
||||||
|
|
||||||
|
test("UsageLogCard: fetches /api/quota/pools/${poolId}/log endpoint", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("/api/quota/pools/") && usageLogCardSrc.includes("/log"),
|
||||||
|
"UsageLogCard must fetch the /api/quota/pools/[id]/log endpoint"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UsageLogCard: is collapsible (toggle state)", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("useState") &&
|
||||||
|
(usageLogCardSrc.includes("open") || usageLogCardSrc.includes("expanded") || usageLogCardSrc.includes("collapsed")),
|
||||||
|
"UsageLogCard must be collapsible with a toggle state"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UsageLogCard: renders logTitle i18n key", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("logTitle"),
|
||||||
|
"UsageLogCard must render the t('logTitle') key"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UsageLogCard: renders logEmpty i18n key for empty state", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("logEmpty"),
|
||||||
|
"UsageLogCard must render the t('logEmpty') key"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UsageLogCard: fail-soft — guards events with ?? []", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("?? []"),
|
||||||
|
"UsageLogCard must guard events with ?? [] for fail-soft behavior"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UsageLogCard: uses useTranslations for i18n", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("useTranslations"),
|
||||||
|
"UsageLogCard must use useTranslations from next-intl"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UsageLogCard: cleans up fetch with alive flag", () => {
|
||||||
|
assert.ok(
|
||||||
|
usageLogCardSrc.includes("alive = false"),
|
||||||
|
"UsageLogCard must set alive = false in useEffect cleanup to prevent state-after-unmount"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PoolCard mounts UsageLogCard ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("PoolCard: imports UsageLogCard", () => {
|
||||||
|
assert.ok(
|
||||||
|
poolCardSrc.includes("UsageLogCard"),
|
||||||
|
"PoolCard.tsx must import and render UsageLogCard"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PoolCard: mounts <UsageLogCard in JSX", () => {
|
||||||
|
assert.ok(
|
||||||
|
poolCardSrc.includes("<UsageLogCard"),
|
||||||
|
"PoolCard.tsx must render <UsageLogCard .../>"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PoolCard: passes poolId prop to UsageLogCard", () => {
|
||||||
|
assert.ok(
|
||||||
|
poolCardSrc.includes("poolId="),
|
||||||
|
"PoolCard.tsx must pass poolId prop to UsageLogCard"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── i18n parity ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LOG_KEYS = ["logTitle", "logEmpty"] as const;
|
||||||
|
|
||||||
|
test("i18n: logTitle and logEmpty present in en.json", () => {
|
||||||
|
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, Record<string, string>>;
|
||||||
|
for (const k of LOG_KEYS) {
|
||||||
|
assert.equal(
|
||||||
|
typeof en["quotaShare"]?.[k],
|
||||||
|
"string",
|
||||||
|
`en.json missing quotaShare.${k}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("i18n: logTitle and logEmpty present in pt-BR.json", () => {
|
||||||
|
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<string, Record<string, string>>;
|
||||||
|
for (const k of LOG_KEYS) {
|
||||||
|
assert.equal(
|
||||||
|
typeof pt["quotaShare"]?.[k],
|
||||||
|
"string",
|
||||||
|
`pt-BR.json missing quotaShare.${k}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("i18n: parity between en and pt-BR for log keys", () => {
|
||||||
|
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, Record<string, string>>;
|
||||||
|
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<string, Record<string, string>>;
|
||||||
|
for (const k of LOG_KEYS) {
|
||||||
|
assert.ok(k in (en["quotaShare"] ?? {}), `en.json missing quotaShare.${k}`);
|
||||||
|
assert.ok(k in (pt["quotaShare"] ?? {}), `pt-BR.json missing quotaShare.${k}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user