mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04: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 StackedAllocationBar from "./StackedAllocationBar";
|
||||
import AccountQuotaRow from "./AccountQuotaRow";
|
||||
import UsageLogCard from "./UsageLogCard";
|
||||
|
||||
export interface PoolCardProps {
|
||||
pool: QuotaPool;
|
||||
@@ -179,6 +180,9 @@ export default function PoolCard({
|
||||
<BurnRateChart usage={usage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage log — collapsible, collapsed by default */}
|
||||
<UsageLogCard poolId={pool.id} keyLabels={keyLabels} />
|
||||
</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",
|
||||
"wizardPreviewMoreModels": "+{count} more",
|
||||
"accountQuotaTitle": "Account quota",
|
||||
"accountQuotaNone": "—"
|
||||
"accountQuotaNone": "—",
|
||||
"logTitle": "Usage log",
|
||||
"logEmpty": "No usage yet"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
|
||||
@@ -5406,7 +5406,9 @@
|
||||
"wizardSingleProviderNote": "Um pool usa um provedor só",
|
||||
"wizardPreviewMoreModels": "+{count} mais",
|
||||
"accountQuotaTitle": "Cota da conta",
|
||||
"accountQuotaNone": "—"
|
||||
"accountQuotaNone": "—",
|
||||
"logTitle": "Log de uso",
|
||||
"logEmpty": "Sem uso ainda"
|
||||
},
|
||||
"requestLogger": {
|
||||
"recording": "Recording",
|
||||
|
||||
@@ -36,6 +36,30 @@ interface BucketRow {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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.
|
||||
* 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