Merge pull request #3032 from diegosouzapw/feat/quota-share-v2

feat(quota): Quota Share v2 — nav move, 3-col grouped layout, endpoints+key preview, full pool edit
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-01 07:03:14 -03:00
committed by GitHub
20 changed files with 1863 additions and 401 deletions

View File

@@ -6,16 +6,16 @@ import { Button } from "@/shared/components";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { maskEmailLikeValue } from "@/shared/utils/maskEmail";
import type { QuotaPool, PoolAllocation } from "@/lib/quota/dimensions";
import type { QuotaPool } from "@/lib/quota/dimensions";
import { usePools } from "./hooks/usePools";
import { usePoolUsage } from "./hooks/usePoolUsage";
import { useLocalStoragePoolMigration } from "./hooks/useLocalStoragePoolMigration";
import { usePoolsUsageAggregate } from "./hooks/usePoolsUsageAggregate";
import QuotaConceptCard from "./components/QuotaConceptCard";
import QuotaEndpointsCard from "./components/QuotaEndpointsCard";
import PoolCard from "./components/PoolCard";
import PoolWizard from "./components/PoolWizard";
import EditAllocationsModal from "./components/EditAllocationsModal";
// ────────────────────────────────────────────────────────────────────────────
// Local types (display layer only)
@@ -142,7 +142,7 @@ export default function QuotaSharePageClient() {
// ── Group state ───────────────────────────────────────────────────────────
const [groups, setGroups] = useState<QuotaGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>("group-demo");
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
const [newGroupInput, setNewGroupInput] = useState("");
const [showNewGroupInput, setShowNewGroupInput] = useState(false);
const [renaming, setRenaming] = useState(false);
@@ -286,26 +286,43 @@ export default function QuotaSharePageClient() {
[pools, aggregate]
);
// Pools filtered by selected group
// Pools filtered by selected group (kept for stats/empty-state checks)
const filteredPools = useMemo(
() => pools.filter((p) => (p as unknown as { groupId?: string }).groupId === selectedGroupId || (!( p as unknown as { groupId?: string }).groupId && selectedGroupId === "group-demo")),
() =>
selectedGroupId === "all"
? pools
: pools.filter(
(p) =>
((p as unknown as { groupId?: string }).groupId ?? "group-demo") === selectedGroupId
),
[pools, selectedGroupId]
);
// ── Mutations ─────────────────────────────────────────────────────────────
const handleSaveAllocations = useCallback(
async (pool: QuotaPool, allocations: PoolAllocation[]) => {
await fetch(`/api/quota/pools/${pool.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allocations }),
});
await mutate();
},
[mutate]
// Groups to render as stacked sections
const groupsToRender = useMemo(
() => (selectedGroupId === "all" ? groups : groups.filter((g) => g.id === selectedGroupId)),
[groups, selectedGroupId]
);
// ── Computed exclusivity for the pool being edited ───────────────────────
//
// A pool is exclusive when it has ≥1 allocation AND every allocated key
// currently has the pool id in its allowedQuotas array.
const editingExclusive = useMemo(
() =>
!!editing &&
editing.allocations.length > 0 &&
editing.allocations.every((a) => {
const k = apiKeys.find((kk) => kk.id === a.apiKeyId);
const aq = (k as { allowedQuotas?: string[] } | undefined)?.allowedQuotas;
return Array.isArray(aq) && aq.includes(editing.id);
}),
[editing, apiKeys]
);
// ── Mutations ─────────────────────────────────────────────────────────────
const handleRemovePool = useCallback(
async (id: string) => {
if (!confirm(t("removeConfirm"))) return;
@@ -348,6 +365,7 @@ export default function QuotaSharePageClient() {
title={t("groupSelectHint")}
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-text-main min-w-[120px]"
>
<option value="all">{t("allGroups")}</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
@@ -394,7 +412,7 @@ export default function QuotaSharePageClient() {
{t("newGroup")}
</button>
)}
{selectedGroupId !== "group-demo" && (
{selectedGroupId !== "all" && (
<button
type="button"
onClick={() => void handleRenameGroup()}
@@ -410,6 +428,14 @@ export default function QuotaSharePageClient() {
{/* Concept card */}
<QuotaConceptCard />
{/* Endpoints card */}
<QuotaEndpointsCard
groups={groups}
pools={pools}
connections={connections}
apiKeys={apiKeys}
/>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label={t("kpiActivePools")} value={String(stats.activePools)} />
@@ -443,19 +469,7 @@ export default function QuotaSharePageClient() {
</div>
) : (
<>
{/* Group heading */}
{groups.find((g) => g.id === selectedGroupId) && (
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-text-muted">folder</span>
<span className="text-sm font-semibold text-text-main">
{groups.find((g) => g.id === selectedGroupId)?.name}
</span>
<span className="text-[11px] text-text-muted">
({filteredPools.length})
</span>
</div>
)}
{filteredPools.length === 0 ? (
{groupsToRender.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-surface py-10 text-center">
<p className="text-sm text-text-muted">{t("emptyDescription")}</p>
<Button variant="primary" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
@@ -464,21 +478,58 @@ export default function QuotaSharePageClient() {
</Button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{filteredPools.map((pool) => (
<PoolCardWithUsage
key={pool.id}
pool={pool}
keyLabels={keyLabels}
connectionLabel={connLabel(pool.connectionId)}
provider={connProvider(pool.connectionId)}
providers={[...new Set((pool.connectionIds ?? [pool.connectionId]).map(connProvider))]}
connectionIds={pool.connectionIds ?? [pool.connectionId]}
onEdit={() => setEditing(pool)}
onRemove={() => void handleRemovePool(pool.id)}
/>
))}
</div>
groupsToRender.map((g) => {
const groupPools = pools.filter(
(p) =>
((p as unknown as { groupId?: string }).groupId ?? "group-demo") === g.id
);
return (
<div key={g.id} className="flex flex-col gap-3">
{/* Per-group heading */}
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-text-muted">
folder
</span>
<span className="text-sm font-semibold text-text-main">{g.name}</span>
<span className="text-[11px] text-text-muted">({groupPools.length})</span>
</div>
{groupPools.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-surface py-6 text-center">
<p className="text-sm text-text-muted">{t("emptyDescription")}</p>
<Button
variant="primary"
size="sm"
className="mt-3"
onClick={() => setCreateOpen(true)}
>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
{t("newPool")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{groupPools.map((pool) => (
<PoolCardWithUsage
key={pool.id}
pool={pool}
keyLabels={keyLabels}
connectionLabel={connLabel(pool.connectionId)}
provider={connProvider(pool.connectionId)}
providers={[
...new Set(
(pool.connectionIds ?? [pool.connectionId]).map(connProvider)
),
]}
connectionIds={pool.connectionIds ?? [pool.connectionId]}
onEdit={() => setEditing(pool)}
onRemove={() => void handleRemovePool(pool.id)}
/>
))}
</div>
)}
</div>
);
})
)}
</>
)}
@@ -496,14 +547,23 @@ export default function QuotaSharePageClient() {
selectedGroupId={selectedGroupId}
/>
{editing && (
<EditAllocationsModal
pool={editing}
apiKeys={apiKeys}
onClose={() => setEditing(null)}
onSave={(allocations) => handleSaveAllocations(editing, allocations)}
/>
)}
{/* Edit wizard — separate instance from create to avoid shared state */}
<PoolWizard
open={!!editing}
onClose={() => setEditing(null)}
onSaved={() => {
void mutate();
setEditing(null);
}}
editPool={editing ?? undefined}
editPoolExclusive={editingExclusive}
connections={connections}
apiKeys={apiKeys}
plans={plans}
existingPoolConnectionIds={new Set(pools.filter((p) => p.id !== editing?.id).map((p) => p.connectionId))}
groups={groups}
selectedGroupId={selectedGroupId}
/>
</div>
);
}

View File

@@ -1,244 +0,0 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Modal } from "@/shared/components";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { maskEmailLikeValue } from "@/shared/utils/maskEmail";
import type { QuotaPool, PoolAllocation, Policy } from "@/lib/quota/dimensions";
interface ApiKey {
id: string;
name?: string;
}
interface EditAllocationsModalProps {
pool: QuotaPool;
apiKeys: ApiKey[];
onClose: () => void;
onSave: (allocations: PoolAllocation[]) => Promise<void>;
}
function shortId(id: string, max = 12) {
return id.length > max ? `${id.slice(0, max)}` : id;
}
const SLICE_PALETTE = [
"#a78bfa",
"#60a5fa",
"#34d399",
"#fbbf24",
"#f87171",
"#22d3ee",
"#f472b6",
"#94a3b8",
];
export default function EditAllocationsModal({
pool,
apiKeys,
onClose,
onSave,
}: EditAllocationsModalProps) {
const t = useTranslations("quotaShare");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const [drafts, setDrafts] = useState<PoolAllocation[]>(pool.allocations);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const totalWeight = drafts.reduce(
(s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0),
0
);
const availableKeys = apiKeys.filter((k) => !drafts.some((a) => a.apiKeyId === k.id));
const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || shortId(id);
const addKey = (id: string) => {
setDrafts((prev) => {
const next = [...prev, { apiKeyId: id, weight: 0, policy: "hard" as Policy }];
// Equal-split: after adding, recompute weights so no key starts at 0.
const n = next.length;
const each = Math.floor(100 / n);
const remainder = 100 - each * n;
return next.map((a, i) => ({ ...a, weight: each + (i < remainder ? 1 : 0) }));
});
};
const updateWeight = (id: string, value: number) => {
setDrafts((prev) =>
prev.map((a) =>
a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a
)
);
};
const updatePolicy = (id: string, policy: Policy) => {
setDrafts((prev) => prev.map((a) => (a.apiKeyId === id ? { ...a, policy } : a)));
};
const updateCapValue = (id: string, capValue: number | undefined) => {
setDrafts((prev) => prev.map((a) => (a.apiKeyId === id ? { ...a, capValue } : a)));
};
const removeKey = (id: string) => {
setDrafts((prev) => prev.filter((a) => a.apiKeyId !== id));
};
const equalSplit = () => {
if (drafts.length === 0) return;
const each = Math.floor(100 / drafts.length);
const remainder = 100 - each * drafts.length;
setDrafts((prev) => prev.map((a, i) => ({ ...a, weight: each + (i < remainder ? 1 : 0) })));
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await onSave(drafts);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
return (
<Modal isOpen onClose={onClose} title={t("editTitle")} size="lg">
<div className="space-y-3">
<div className="text-xs text-text-muted">
{t("pool")}: <strong className="text-text-main">{emailsVisible ? pool.name : maskEmailLikeValue(pool.name)}</strong>
</div>
{/* Group allocation note */}
<p className="text-[11px] text-text-muted bg-bg-subtle/40 px-3 py-2 rounded border border-border/40">
<span className="material-symbols-outlined text-[13px] align-middle mr-1 text-primary/70">info</span>
{t("groupAllocationNote")}
</p>
{drafts.length === 0 ? (
<div className="text-[12px] text-text-muted italic py-4 text-center bg-bg-subtle/40 rounded-md">
{t("noKeysAdded")}
</div>
) : (
<div className="space-y-2">
{drafts.map((a, i) => {
const color = SLICE_PALETTE[i % SLICE_PALETTE.length];
return (
<div
key={a.apiKeyId}
className="grid items-center gap-2"
style={{ gridTemplateColumns: "12px minmax(0,1fr) 70px 80px 90px 24px" }}
>
<span
className="inline-block w-3 h-3 rounded-sm"
style={{ background: color }}
/>
<span className="text-[12px] font-mono truncate">{keyLabel(a.apiKeyId)}</span>
<input
type="number"
min={0}
max={100}
value={a.weight}
onChange={(e) => updateWeight(a.apiKeyId, Number(e.target.value))}
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-right tabular-nums"
title="Weight %"
/>
{/* Cap absolute */}
<input
type="number"
min={0}
value={a.capValue ?? ""}
onChange={(e) =>
updateCapValue(a.apiKeyId, e.target.value ? Number(e.target.value) : undefined)
}
placeholder={t("policyCapAbsolutePlaceholder")}
className="px-2 py-1 rounded border border-border bg-bg-base text-xs tabular-nums"
title={t("policyCapAbsoluteLabel")}
/>
{/* Policy per key */}
<select
value={a.policy}
onChange={(e) => updatePolicy(a.apiKeyId, e.target.value as Policy)}
className="px-1 py-1 rounded border border-border bg-bg-base text-xs"
>
<option value="hard">{t("policyHard")}</option>
<option value="soft">{t("policySoft")}</option>
<option value="burst">{t("policyBurst")}</option>
</select>
<button
type="button"
onClick={() => removeKey(a.apiKeyId)}
className="p-0.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-between text-[11px] pt-2 border-t border-border/40">
<span
className={`font-bold tabular-nums ${
totalWeight === 100
? "text-emerald-400"
: totalWeight > 100
? "text-red-400"
: "text-amber-400"
}`}
>
{t("totalLabel", { percent: totalWeight })}{" "}
{totalWeight > 100 && t("totalExceeded")}
</span>
<div className="flex items-center gap-2">
{availableKeys.length > 0 && (
<select
value=""
onChange={(e) => e.target.value && addKey(e.target.value)}
className="px-2 py-1 rounded border border-border bg-bg-base text-xs"
>
<option value="">{t("addKey")}</option>
{availableKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name || shortId(k.id)}
</option>
))}
</select>
)}
<Button
variant="secondary"
size="sm"
onClick={equalSplit}
disabled={drafts.length === 0}
>
{t("equalSplit")}
</Button>
</div>
</div>
{error && (
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-border/40">
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
{t("cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={totalWeight > 100 || saving}
>
{saving ? t("loading") : t("save")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -25,6 +25,7 @@ import { maskEmailLikeValue } from "@/shared/utils/maskEmail";
import { getKnownPlan } from "@/lib/quota/planRegistry";
import { quotaModelName } from "@/lib/quota/quotaModelNaming";
import type { Policy, PoolAllocation, QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions";
import type { QuotaPool } from "@/lib/db/quotaPools";
// ────────────────────────────────────────────────────────────────────────────
// Types (mirror what CreatePoolModal/EditAllocationsModal expect)
@@ -64,6 +65,10 @@ export interface PoolWizardProps {
existingPoolConnectionIds: Set<string>;
groups?: QuotaGroup[];
selectedGroupId?: string;
/** When provided, the wizard enters edit mode — pre-fills from the pool and PATCHes instead of POSTing. */
editPool?: QuotaPool;
/** Whether the pool being edited is currently exclusive. Used to pre-fill the exclusive checkbox in edit mode. */
editPoolExclusive?: boolean;
}
// ────────────────────────────────────────────────────────────────────────────
@@ -169,6 +174,8 @@ export default function PoolWizard({
existingPoolConnectionIds,
groups = [],
selectedGroupId: initialGroupId = "group-demo",
editPool,
editPoolExclusive,
}: PoolWizardProps) {
const t = useTranslations("quotaShare");
const tPlans = useTranslations("quotaPlans");
@@ -255,6 +262,7 @@ export default function PoolWizard({
useEffect(() => {
if (!open) {
// Closing: always reset to defaults.
setStep(1);
setConnectionIds([]);
setPoolName("");
@@ -266,8 +274,36 @@ export default function PoolWizard({
setError(null);
setSaving(false);
setGroupId(initialGroupId);
} else if (editPool) {
// Opening in edit mode: pre-fill from the existing pool.
const ids =
Array.isArray(editPool.connectionIds) && editPool.connectionIds.length > 0
? editPool.connectionIds
: [editPool.connectionId];
setConnectionIds(ids);
setPoolName(editPool.name);
setGroupId(editPool.groupId ?? initialGroupId);
setAllocations(editPool.allocations ?? []);
// Preserve the pool's current exclusivity state so editing an exclusive pool
// doesn't silently un-exclusive it. Falls back to false only when not provided.
setExclusive(editPoolExclusive ?? false);
// Pre-load plan dimensions for the primary connection (same as create path).
// dimensionsEdited stays false so the PUT is skipped unless the user actively edits.
const primaryId = ids[0];
const existingPlan = plans[primaryId];
if (existingPlan && existingPlan.dimensions.length > 0) {
setEditDimensions([...existingPlan.dimensions]);
} else {
setEditDimensions([]);
}
setDimensionsEdited(false);
setError(null);
setSaving(false);
setStep(1);
}
}, [open, initialGroupId]);
// When open && !editPool (create mode): the existing create-reset on close handles defaults.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, editPool, initialGroupId]);
// ── Step 2 — dimension editors ────────────────────────────────────────────
@@ -374,53 +410,95 @@ export default function PoolWizard({
setError(null);
try {
// 1. POST /api/quota/pools → get new pool id
const createRes = await fetch("/api/quota/pools", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionId: primaryConnectionId,
connectionIds,
name: effectivePoolName,
allocations: [],
groupId,
}),
});
if (!createRes.ok) {
const errBody = await createRes.json().catch(() => null);
throw new Error(
errBody?.error?.message || `POST /api/quota/pools failed: HTTP ${createRes.status}`
);
}
const createData = (await createRes.json()) as { pool: { id: string } };
const newPoolId = createData.pool.id;
if (!editPool) {
// ── Create mode: POST → optional PUT → PATCH ──────────────────────
// 2. PUT /api/quota/plans/[primaryConnectionId] — only when user edited dimensions
if (dimensionsEdited && editDimensions.length > 0) {
const planRes = await fetch(`/api/quota/plans/${primaryConnectionId}`, {
method: "PUT",
// 1. POST /api/quota/pools → get new pool id
const createRes = await fetch("/api/quota/pools", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dimensions: editDimensions }),
body: JSON.stringify({
connectionId: primaryConnectionId,
connectionIds,
name: effectivePoolName,
allocations: [],
groupId,
}),
});
if (!planRes.ok) {
const errBody = await planRes.json().catch(() => null);
if (!createRes.ok) {
const errBody = await createRes.json().catch(() => null);
throw new Error(
errBody?.error?.message || `PUT /api/quota/plans failed: HTTP ${planRes.status}`
errBody?.error?.message || `POST /api/quota/pools failed: HTTP ${createRes.status}`
);
}
}
const createData = (await createRes.json()) as { pool: { id: string } };
const newPoolId = createData.pool.id;
// 3. PATCH /api/quota/pools/[id] — allocations + exclusive flag
const patchRes = await fetch(`/api/quota/pools/${newPoolId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allocations, exclusive }),
});
if (!patchRes.ok) {
const errBody = await patchRes.json().catch(() => null);
throw new Error(
errBody?.error?.message || `PATCH /api/quota/pools failed: HTTP ${patchRes.status}`
);
// 2. PUT /api/quota/plans/[primaryConnectionId] — only when user edited dimensions
if (dimensionsEdited && editDimensions.length > 0) {
const planRes = await fetch(`/api/quota/plans/${primaryConnectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dimensions: editDimensions }),
});
if (!planRes.ok) {
const errBody = await planRes.json().catch(() => null);
throw new Error(
errBody?.error?.message || `PUT /api/quota/plans failed: HTTP ${planRes.status}`
);
}
}
// 3. PATCH /api/quota/pools/[id] — allocations + exclusive flag
const patchRes = await fetch(`/api/quota/pools/${newPoolId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allocations, exclusive }),
});
if (!patchRes.ok) {
const errBody = await patchRes.json().catch(() => null);
throw new Error(
errBody?.error?.message || `PATCH /api/quota/pools failed: HTTP ${patchRes.status}`
);
}
} else {
// ── Edit mode: single PATCH (folds name, groupId, connectionIds, allocations, exclusive)
// + optional PUT for plan dimensions when user edited them.
// 1. PATCH /api/quota/pools/[id] — all editable fields in one call
const editPatchRes = await fetch(`/api/quota/pools/${editPool.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: effectivePoolName,
groupId,
connectionIds,
allocations,
exclusive,
}),
});
if (!editPatchRes.ok) {
const errBody = await editPatchRes.json().catch(() => null);
throw new Error(
errBody?.error?.message ||
`PATCH /api/quota/pools failed: HTTP ${editPatchRes.status}`
);
}
// 2. PUT /api/quota/plans/[primaryConnectionId] — only when user actually edited dimensions
if (dimensionsEdited && editDimensions.length > 0) {
const planRes = await fetch(`/api/quota/plans/${primaryConnectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dimensions: editDimensions }),
});
if (!planRes.ok) {
const errBody = await planRes.json().catch(() => null);
throw new Error(
errBody?.error?.message || `PUT /api/quota/plans failed: HTTP ${planRes.status}`
);
}
}
}
onSaved();
@@ -437,7 +515,7 @@ export default function PoolWizard({
if (!open) return null;
return (
<Modal isOpen onClose={onClose} title={t("wizardTitle")} size="lg">
<Modal isOpen onClose={onClose} title={editPool ? t("editPoolTitle") : t("wizardTitle")} size="lg">
<div className="flex flex-col" style={{ minHeight: 420 }}>
<Stepper currentStep={step} />
@@ -894,7 +972,7 @@ export default function PoolWizard({
onClick={() => void handleFinish()}
disabled={totalWeight > 100 || saving}
>
{saving ? t("loading") : t("wizardCreatePool")}
{saving ? t("loading") : editPool ? t("saveChanges") : t("wizardCreatePool")}
</Button>
</div>
</div>

View File

@@ -0,0 +1,289 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { maskEmailLikeValue } from "@/shared/utils/maskEmail";
import Card from "@/shared/components/Card";
import { quotaModelName, quotaGroupSlug } from "@/lib/quota/quotaModelNaming";
// ─────────────────────────────────────────────────────────────────────────────
// Local types (mirrors QuotaSharePageClient)
// ─────────────────────────────────────────────────────────────────────────────
interface QuotaGroup {
id: string;
name: string;
createdAt: string;
}
interface QuotaPool {
id: string;
connectionId: string;
connectionIds?: string[];
groupId?: string;
[key: string]: unknown;
}
interface Connection {
id: string;
provider: string;
name?: string;
displayName?: string;
email?: string;
}
interface ApiKey {
id: string;
name?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Representative model list per provider (same constant as PoolWizard)
// ─────────────────────────────────────────────────────────────────────────────
const PREVIEW_MODELS_BY_PROVIDER: Record<string, string[]> = {
openai: ["gpt-4o", "gpt-4o-mini", "o3", "gpt-4-turbo"],
anthropic: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-3-5"],
gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
cx: ["gpt-5.4-mini", "gpt-5.4", "o3"],
codex: ["codex-mini-latest", "codex-latest"],
glm: ["glm-4", "glm-4v", "glm-z1-airx"],
minimax: ["minimax-m1", "minimax-text-01"],
kimi: ["moonshot-v1-8k", "moonshot-v1-32k"],
default: ["model-a", "model-b", "model-c"],
};
const MAX_MODELS_SHOWN = 3;
function getPreviewModels(provider: string): string[] {
return PREVIEW_MODELS_BY_PROVIDER[provider] ?? PREVIEW_MODELS_BY_PROVIDER["default"];
}
// ─────────────────────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────────────────────
interface QuotaEndpointsCardProps {
groups: QuotaGroup[];
pools: QuotaPool[];
connections: Connection[];
apiKeys: ApiKey[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export default function QuotaEndpointsCard({
groups,
pools,
connections,
apiKeys,
}: QuotaEndpointsCardProps) {
const t = useTranslations("quotaShare");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
const [previewModels, setPreviewModels] = useState<string[] | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
// ── Derive default model list from groups + pools + connections ──────────────
// For each group, collect all pools that belong to it, then for each pool's
// providers (from connectionIds), generate qtSd/<groupSlug>/<provider>/<model>
// using the same PREVIEW_MODELS_BY_PROVIDER map that PoolWizard uses.
//
// This is a REPRESENTATIVE list — the full list is served by /v1/models.
const defaultByGroup = useMemo<
Array<{
group: QuotaGroup;
entries: Array<{ provider: string; models: string[] }>;
}>
>(() => {
return groups.map((group) => {
const groupPools = pools.filter(
(p) => ((p as { groupId?: string }).groupId ?? "group-demo") === group.id
);
// Collect unique providers for this group
const providerSet = new Set<string>();
for (const pool of groupPools) {
const cids = pool.connectionIds ?? [pool.connectionId];
for (const cid of cids) {
const conn = connections.find((c) => c.id === cid);
if (conn) providerSet.add(conn.provider);
}
}
const entries = Array.from(providerSet).map((provider) => {
const previewModelIds = getPreviewModels(provider);
const models = previewModelIds
.slice(0, MAX_MODELS_SHOWN)
.map((m) => quotaModelName(group.name, provider, m));
return { provider, models };
});
return { group, entries };
});
}, [groups, pools, connections]);
// ── Key name with optional email masking ─────────────────────────────────────
const keyLabel = (key: ApiKey) => {
const raw = key.name || key.id.slice(0, 12) + "…";
return emailsVisible ? raw : maskEmailLikeValue(raw);
};
// ── Key selector handler ─────────────────────────────────────────────────────
const handleKeyChange = async (keyId: string) => {
setSelectedKeyId(keyId);
if (!keyId) {
setPreviewModels(null);
return;
}
setLoadingPreview(true);
try {
const res = await fetch(`/api/quota/keys/${keyId}/models`);
if (res.ok) {
const body = (await res.json()) as { models: string[] };
setPreviewModels(Array.isArray(body.models) ? body.models : []);
} else {
setPreviewModels([]);
}
} catch {
setPreviewModels([]);
} finally {
setLoadingPreview(false);
}
};
// ── Compute the combined default model count across all groups ────────────────
const hasAnyDefaultModels = defaultByGroup.some((g) =>
g.entries.some((e) => e.models.length > 0)
);
// ── Render ───────────────────────────────────────────────────────────────────
const hasData = groups.length > 0 && pools.length > 0;
return (
<Card padding="sm">
{/* Header row */}
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-[18px] text-primary shrink-0 mt-0.5">
api
</span>
<div>
<span className="text-sm font-semibold text-text-main">{t("endpointsTitle")}</span>
<p className="text-xs text-text-muted mt-0.5 max-w-lg">{t("endpointsHint")}</p>
</div>
</div>
{/* Key preview selector */}
{apiKeys.length > 0 && (
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-text-muted whitespace-nowrap">
{t("previewForKey")}
</label>
<select
value={selectedKeyId}
onChange={(e) => void handleKeyChange(e.target.value)}
className="px-2 py-1 rounded border border-border bg-bg-base text-xs text-text-main min-w-[140px]"
>
<option value="">{t("previewKeyNone")}</option>
{apiKeys.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k)}
</option>
))}
</select>
</div>
)}
</div>
{/* Base URL line */}
<div className="mt-3 flex items-center gap-2 rounded-md bg-bg-subtle/50 border border-border/40 px-3 py-2">
<span className="text-[10px] uppercase tracking-wide text-text-muted font-semibold shrink-0">
{t("endpointsBaseUrl")}
</span>
<code className="text-xs text-primary font-mono">POST /v1/chat/completions</code>
<span className="text-xs text-text-muted mx-1">·</span>
<code className="text-xs text-text-muted font-mono">
model: &quot;qtSd/&lt;group&gt;/&lt;provider&gt;/&lt;model&gt;&quot;
</code>
</div>
{/* Model listing */}
<div className="mt-3">
{loadingPreview ? (
<div className="text-xs text-text-muted animate-pulse py-2">{t("loading")}</div>
) : previewModels !== null ? (
// Per-key preview from the API
<div>
{previewModels.length === 0 ? (
<p className="text-xs text-text-muted italic">{t("noAllocations")}</p>
) : (
<ul className="space-y-0.5">
{previewModels.map((m) => (
<li key={m}>
<code className="text-[11px] font-mono text-text-main bg-bg-subtle/40 rounded px-1.5 py-0.5 inline-block">
{m}
</code>
</li>
))}
</ul>
)}
</div>
) : hasData && hasAnyDefaultModels ? (
// Default view: grouped by group → provider → model ids
<div className="space-y-3">
{defaultByGroup.map(({ group, entries }) => {
if (entries.length === 0) return null;
return (
<div key={group.id}>
<div className="flex items-center gap-1.5 mb-1">
<span className="material-symbols-outlined text-[13px] text-text-muted">
folder
</span>
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wide">
{quotaGroupSlug(group.name)}
</span>
</div>
<div className="space-y-1 pl-4">
{entries.map(({ provider, models }) => (
<div key={provider} className="space-y-0.5">
<span className="text-[10px] text-text-muted font-medium">{provider}</span>
<ul className="space-y-0.5">
{models.map((m) => (
<li key={m}>
<code className="text-[11px] font-mono text-text-main bg-bg-subtle/40 rounded px-1.5 py-0.5 inline-block">
{m}
</code>
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
) : (
// No pools yet — show the format placeholder
<div className="text-xs text-text-muted italic">
<code className="font-mono text-[11px]">
qtSd/&lt;groupSlug&gt;/&lt;provider&gt;/&lt;model&gt;
</code>
{" — "}
{t("emptyDescription")}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
/**
* GET /api/quota/keys/[id]/models
*
* Returns the `qtSd/` virtual model IDs that a given API key would see in
* /v1/models, by reusing the SAME catalog.ts approach:
* resolveQuotaKeyScope(key.allowedQuotas) → scope.poolSlugs
* filterModelsToQuotaPools(candidates, scope.poolSlugs)
*
* Candidates are the combo names from getCombos() mapped to { id: combo.name }
* (same shape catalog.ts passes to filterModelsToQuotaPools).
*
* Auth: requireManagementAuth (management-gated, not quota-key-gated).
* Error sanitization: buildErrorBody (Hard Rule #12).
* NOT LOCAL_ONLY — does not spawn processes.
*
* Part of: Task 2 — quota-share-v2 plan (2026-06-01).
*/
import { NextResponse } from "next/server";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeyById, getCombos } from "@/lib/localDb";
import { resolveQuotaKeyScope } from "@/lib/quota/quotaKey";
import { filterModelsToQuotaPools } from "@/lib/quota/quotaCombos";
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 key = await getApiKeyById(id);
if (!key) {
return NextResponse.json(buildErrorBody(404, "API key not found"), { status: 404 });
}
const allowedQuotas: string[] = Array.isArray(
(key as Record<string, unknown>).allowedQuotas,
)
? ((key as Record<string, unknown>).allowedQuotas as string[])
: [];
const scope = await resolveQuotaKeyScope(allowedQuotas);
// Build the candidate list from all combo names — same shape catalog.ts uses:
// objects with an `id` field (combo.name) passed to filterModelsToQuotaPools.
let allCombos: Awaited<ReturnType<typeof getCombos>> = [];
try {
allCombos = await getCombos();
} catch {
// DB unavailable — treat as empty combo list; models will be [].
}
const candidates = allCombos
.filter((c) => typeof c.name === "string" && c.name.length > 0)
.map((c) => ({ id: c.name as string }));
const models = filterModelsToQuotaPools(candidates, scope.poolSlugs).map((m) => m.id);
return NextResponse.json({ models });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to resolve key models";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}

View File

@@ -65,11 +65,40 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
}
}
// Combos must be reconciled when the pool's group OR connection set changes,
// since the qtSd/ combo name embeds <groupSlug>/<provider>. Each provider in a
// group is served by at most one pool, so removing this pool's CURRENT (old)
// group+provider combos BEFORE the update — then re-syncing AFTER — cleanly
// drops stale combos and mints the new ones, using the existing per-pool
// helpers. Without the pre-update removal, a group/provider switch would leave
// orphan qtSd/ combos a quota key still sees. Guarded + non-fatal.
const combosNeedResync =
body !== null &&
typeof body === "object" &&
("connectionIds" in body || "groupId" in body);
if (combosNeedResync) {
try {
const { removeQuotaCombosForPool } = await import("@/lib/quota/quotaCombos");
await removeQuotaCombosForPool(id); // pool still has its OLD group/provider here
} catch {
// Guard: combo cleanup failure must never break pool update.
}
}
const pool = updatePool(id, parsed.data);
if (!pool) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}
if (combosNeedResync) {
try {
const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos");
await syncQuotaCombos(id); // pool now has its NEW group/provider
} catch {
// Guard: combo-sync failure must never break pool update callers.
}
}
// Reconcile allowedQuotas on API keys when exclusive flag is explicitly set.
if (exclusivePresent) {
const nextApiKeyIds = (parsed.data.allocations ?? []).map((a) => a.apiKeyId);

View File

@@ -7877,6 +7877,8 @@
"stackedBarTitle": "Slices by API key",
"usedSuffix": "used {percent}%",
"wizardTitle": "New quota pool",
"editPoolTitle": "Edit pool",
"saveChanges": "Save changes",
"wizardStep1Label": "Account",
"wizardStep2Label": "Limit",
"wizardStep3Label": "Keys",
@@ -7910,7 +7912,13 @@
"groupSelectHint": "Filter pools by group",
"groupAllocationNote": "Allocations apply to all pools in this group via the shared quota layer.",
"groupNamePrompt": "Enter group name",
"wizardGroupLabel": "Group"
"wizardGroupLabel": "Group",
"allGroups": "All groups",
"endpointsTitle": "Available endpoints",
"endpointsHint": "Call these virtual models with any allocated key — routing + quota are handled per group.",
"previewForKey": "Preview for key",
"previewKeyNone": "(all endpoints)",
"endpointsBaseUrl": "Base URL"
},
"plugins": {
"title": "Plugins",

View File

@@ -5382,6 +5382,8 @@
"stackedBarTitle": "Fatias por API key",
"usedSuffix": "usou {percent}%",
"wizardTitle": "Novo pool de cota",
"editPoolTitle": "Editar pool",
"saveChanges": "Salvar alterações",
"wizardStep1Label": "Conta",
"wizardStep2Label": "Limite",
"wizardStep3Label": "Chaves",
@@ -5415,7 +5417,13 @@
"groupSelectHint": "Filtrar pools por grupo",
"groupAllocationNote": "As alocações se aplicam a todos os pools deste grupo através da camada de cota compartilhada.",
"groupNamePrompt": "Digite o nome do grupo",
"wizardGroupLabel": "Grupo"
"wizardGroupLabel": "Grupo",
"allGroups": "Todos os grupos",
"endpointsTitle": "Endpoints disponíveis",
"endpointsHint": "Chame estes modelos virtuais com qualquer chave alocada — roteamento + cota são tratados por grupo.",
"previewForKey": "Pré-visualizar p/ chave",
"previewKeyNone": "(todos os endpoints)",
"endpointsBaseUrl": "URL base"
},
"requestLogger": {
"recording": "Recording",

View File

@@ -195,6 +195,13 @@ const OMNI_PROXY_ITEMS: readonly SidebarItemDefinition[] = [
subtitleKey: "providerQuotaSubtitle",
icon: "tune",
},
{
id: "costs-quota-share",
href: "/dashboard/costs/quota-share",
i18nKey: "costsQuotaShare",
subtitleKey: "costsQuotaShareSubtitle",
icon: "pie_chart",
},
];
const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
@@ -454,13 +461,6 @@ const COSTS_ITEMS: readonly SidebarItemDefinition[] = [
subtitleKey: "costsBudgetSubtitle",
icon: "savings",
},
{
id: "costs-quota-share",
href: "/dashboard/costs/quota-share",
i18nKey: "costsQuotaShare",
subtitleKey: "costsQuotaShareSubtitle",
icon: "pie_chart",
},
];
const AUDIT_GROUP: SidebarItemGroup = {

View File

@@ -32,6 +32,8 @@ export const PoolUpdateSchema = z.object({
name: z.string().min(1).max(120).optional(),
allocations: z.array(PoolAllocationSchema).optional(),
exclusive: z.boolean().optional(),
groupId: z.string().optional(),
connectionIds: z.array(z.string().min(1)).min(1).optional(),
});
export type PoolUpdate = z.infer<typeof PoolUpdateSchema>;

View File

@@ -0,0 +1,132 @@
/**
* tests/unit/quota-edit-opens-wizard.test.ts
*
* Task 6 — source-scan assertions for:
* 1. QuotaSharePageClient no longer imports or renders EditAllocationsModal.
* 2. The `editing` pool is wired to a <PoolWizard via editPool prop.
* 3. editPoolExclusive is computed from apiKeys.allowedQuotas and passed through.
* 4. PoolWizard pre-fill uses editPoolExclusive (not a hard-coded false).
*
* Node native test runner — pure source analysis, no DOM needed.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const PAGE_CLIENT_PATH = path.join(
ROOT,
"src",
"app",
"(dashboard)",
"dashboard",
"costs",
"quota-share",
"QuotaSharePageClient.tsx"
);
const WIZARD_PATH = path.join(
ROOT,
"src",
"app",
"(dashboard)",
"dashboard",
"costs",
"quota-share",
"components",
"PoolWizard.tsx"
);
const pageSrc = fs.readFileSync(PAGE_CLIENT_PATH, "utf-8");
const wizardSrc = fs.readFileSync(WIZARD_PATH, "utf-8");
// ── QuotaSharePageClient: EditAllocationsModal must be gone ───────────────────
test("QuotaSharePageClient: does NOT import EditAllocationsModal", () => {
assert.ok(
!pageSrc.includes("EditAllocationsModal"),
"EditAllocationsModal must not appear in QuotaSharePageClient (retired Task 6)"
);
});
// ── QuotaSharePageClient: editing pool wired to PoolWizard ────────────────────
test("QuotaSharePageClient: passes editing pool as editPool= to PoolWizard", () => {
assert.ok(
pageSrc.includes("editPool={editing"),
"Expected editPool={editing...} prop on the edit PoolWizard instance"
);
});
test("QuotaSharePageClient: opens edit wizard when editing is set (open={!!editing})", () => {
assert.ok(
pageSrc.includes("open={!!editing}"),
"Expected open={!!editing} on the edit PoolWizard instance"
);
});
// ── QuotaSharePageClient: editingExclusive computed and passed ────────────────
test("QuotaSharePageClient: computes editingExclusive from allowedQuotas", () => {
assert.ok(
pageSrc.includes("editingExclusive"),
"Expected editingExclusive to be defined in QuotaSharePageClient"
);
assert.ok(
pageSrc.includes("allowedQuotas"),
"Expected allowedQuotas referenced in editingExclusive computation"
);
});
test("QuotaSharePageClient: passes editPoolExclusive={editingExclusive} to edit PoolWizard", () => {
assert.ok(
pageSrc.includes("editPoolExclusive={editingExclusive}"),
"Expected editPoolExclusive={editingExclusive} on the edit PoolWizard instance"
);
});
test("QuotaSharePageClient: editingExclusive requires >=1 allocation", () => {
// The guard `editing.allocations.length > 0` must be present
assert.ok(
pageSrc.includes("allocations.length > 0"),
"Expected allocations.length > 0 guard in editingExclusive"
);
});
test("QuotaSharePageClient: editingExclusive checks every allocated key has pool id in allowedQuotas", () => {
assert.ok(
pageSrc.includes("aq.includes(editing.id)"),
"Expected aq.includes(editing.id) check in editingExclusive"
);
});
// ── PoolWizard: editPoolExclusive prop declared and used ─────────────────────
test("PoolWizard.tsx: declares editPoolExclusive prop in PoolWizardProps", () => {
assert.ok(
wizardSrc.includes("editPoolExclusive?"),
"Expected editPoolExclusive? field in PoolWizardProps"
);
});
test("PoolWizard.tsx: pre-fill uses editPoolExclusive ?? false (not setExclusive(false))", () => {
// The old hard-coded false must be replaced by the prop
assert.ok(
wizardSrc.includes("editPoolExclusive ?? false"),
"Expected setExclusive(editPoolExclusive ?? false) in PoolWizard pre-fill"
);
// The literal `setExclusive(false)` must NOT appear in the edit-mode pre-fill block
// (it may still appear in the close-reset block, which is correct)
const editFillIdx = wizardSrc.indexOf("} else if (editPool)");
assert.ok(editFillIdx >= 0, "Expected '} else if (editPool)' block in PoolWizard");
const closingBrace = wizardSrc.indexOf("\n }", editFillIdx);
const editFillBlock = wizardSrc.slice(editFillIdx, closingBrace > 0 ? closingBrace + 6 : editFillIdx + 1200);
assert.ok(
!editFillBlock.includes("setExclusive(false)"),
"setExclusive(false) must not appear in the editPool pre-fill block — must use editPoolExclusive"
);
});

View File

@@ -33,7 +33,6 @@ const pageClientSrc = readSrc(`${quotaShareDir}/QuotaSharePageClient.tsx`);
const poolCardSrc = readSrc(`${quotaShareDir}/components/PoolCard.tsx`);
const accountQuotaRowSrc = readSrc(`${quotaShareDir}/components/AccountQuotaRow.tsx`);
const poolWizardSrc = readSrc(`${quotaShareDir}/components/PoolWizard.tsx`);
const editAllocationsModalSrc = readSrc(`${quotaShareDir}/components/EditAllocationsModal.tsx`);
// ── QuotaSharePageClient ──────────────────────────────────────────────────────
@@ -177,36 +176,9 @@ test("PoolWizard connLabel masks detail with emailsVisible gate", () => {
);
});
// ── EditAllocationsModal ──────────────────────────────────────────────────────
test("EditAllocationsModal imports useEmailPrivacyStore", () => {
assert.ok(
editAllocationsModalSrc.includes('import useEmailPrivacyStore from "@/store/emailPrivacyStore"'),
"Expected useEmailPrivacyStore import in EditAllocationsModal"
);
});
test("EditAllocationsModal imports maskEmailLikeValue", () => {
assert.ok(
editAllocationsModalSrc.includes("maskEmailLikeValue"),
"Expected maskEmailLikeValue import/usage in EditAllocationsModal"
);
});
test("EditAllocationsModal consumes emailsVisible from store", () => {
assert.ok(
editAllocationsModalSrc.includes("emailsVisible"),
"Expected emailsVisible usage in EditAllocationsModal"
);
});
test("EditAllocationsModal masks pool.name display", () => {
// pool.name can contain an email (e.g. "codex / gael.martins@domain.com")
assert.ok(
editAllocationsModalSrc.includes("maskEmailLikeValue(pool.name)"),
"Expected maskEmailLikeValue(pool.name) in EditAllocationsModal"
);
});
// ── EditAllocationsModal retired (Task 6) ────────────────────────────────────
// EditAllocationsModal was retired in Task 6 — edit mode now opens the full
// PoolWizard (which already has comprehensive email-privacy coverage above).
// ── maskEmailLikeValue helper behaviour (regression) ─────────────────────────

View File

@@ -0,0 +1,163 @@
/**
* tests/unit/quota-endpoints-card.test.ts
*
* Task 7 — source-scan assertions for QuotaEndpointsCard component:
* - renders /v1/chat/completions base URL
* - references quotaModelName or builds qtSd/ ids
* - has a <select> with a previewKeyNone option
* - fetches /api/quota/keys/{id}/models when a key is selected
* - QuotaSharePageClient imports + renders <QuotaEndpointsCard
* - i18n parity for the 5 new keys
*
* Pattern mirrors tests/unit/quota-groups-ui.test.ts (source-scan, Node native runner).
*/
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 CARD_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/components/QuotaEndpointsCard.tsx"
);
const PAGE_CLIENT_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx"
);
const EN_PATH = join(ROOT, "src/i18n/messages/en.json");
const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json");
const cardSrc = readFileSync(CARD_PATH, "utf8");
const pageSrc = readFileSync(PAGE_CLIENT_PATH, "utf8");
// ── QuotaEndpointsCard: base URL ───────────────────────────────────────────────
test("QuotaEndpointsCard: renders /v1/chat/completions base URL", () => {
assert.ok(
cardSrc.includes("/v1/chat/completions"),
"QuotaEndpointsCard must render the /v1/chat/completions endpoint"
);
});
// ── QuotaEndpointsCard: builds qtSd/ model ids ────────────────────────────────
test("QuotaEndpointsCard: references quotaModelName or qtSd/ prefix", () => {
const refsModelName = cardSrc.includes("quotaModelName");
const refsQtSd = cardSrc.includes("qtSd/") || cardSrc.includes("qtSd");
assert.ok(
refsModelName || refsQtSd,
"QuotaEndpointsCard must reference quotaModelName or the qtSd/ model prefix"
);
});
// ── QuotaEndpointsCard: key selector ──────────────────────────────────────────
test("QuotaEndpointsCard: has a <select> for API key preview", () => {
assert.ok(
cardSrc.includes("<select"),
"QuotaEndpointsCard must render a <select> for API key selection"
);
});
test("QuotaEndpointsCard: has a previewKeyNone leading option", () => {
assert.ok(
cardSrc.includes('previewKeyNone') || cardSrc.includes("previewKeyNone"),
"QuotaEndpointsCard must use the t('previewKeyNone') i18n key for the empty/all option"
);
});
test("QuotaEndpointsCard: has a leading option with value=\"\" for no-key selection", () => {
// The leading option must have value="" so that on reset it reverts to all-endpoints view
assert.ok(
cardSrc.includes('value=""') || cardSrc.includes("value=''"),
"QuotaEndpointsCard must have a leading <option value=\"\"> for (all endpoints)"
);
});
// ── QuotaEndpointsCard: per-key fetch ─────────────────────────────────────────
test("QuotaEndpointsCard: fetches /api/quota/keys/ + /models for key preview", () => {
assert.ok(
cardSrc.includes("/api/quota/keys/"),
"QuotaEndpointsCard must fetch /api/quota/keys/..."
);
assert.ok(
cardSrc.includes("/models"),
"QuotaEndpointsCard must include /models in the fetch URL"
);
});
// ── QuotaSharePageClient: imports + renders QuotaEndpointsCard ─────────────────
test("QuotaSharePageClient: imports QuotaEndpointsCard", () => {
assert.ok(
pageSrc.includes("QuotaEndpointsCard"),
"QuotaSharePageClient must import QuotaEndpointsCard"
);
});
test("QuotaSharePageClient: renders <QuotaEndpointsCard", () => {
assert.ok(
pageSrc.includes("<QuotaEndpointsCard"),
"QuotaSharePageClient must render <QuotaEndpointsCard ..."
);
});
test("QuotaSharePageClient: passes groups, pools, connections, apiKeys to QuotaEndpointsCard", () => {
const cardIdx = pageSrc.indexOf("<QuotaEndpointsCard");
assert.ok(cardIdx >= 0, "<QuotaEndpointsCard must exist in the source");
// Find the closing tag (allow JSX self-close or regular close)
const closeIdx = pageSrc.indexOf("/>", cardIdx);
const block = pageSrc.slice(cardIdx, closeIdx + 2);
assert.ok(block.includes("groups="), "QuotaEndpointsCard must receive groups prop");
assert.ok(block.includes("pools="), "QuotaEndpointsCard must receive pools prop");
assert.ok(block.includes("connections="), "QuotaEndpointsCard must receive connections prop");
assert.ok(block.includes("apiKeys="), "QuotaEndpointsCard must receive apiKeys prop");
});
// ── i18n parity ───────────────────────────────────────────────────────────────
const ENDPOINTS_KEYS = [
"endpointsTitle",
"endpointsHint",
"previewForKey",
"previewKeyNone",
"endpointsBaseUrl",
] as const;
test("i18n: all 5 endpoint keys present in en.json quotaShare namespace", () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, Record<string, string>>;
for (const k of ENDPOINTS_KEYS) {
assert.equal(
typeof en["quotaShare"]?.[k],
"string",
`en.json missing quotaShare.${k}`
);
}
});
test("i18n: all 5 endpoint keys present in pt-BR.json quotaShare namespace", () => {
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<string, Record<string, string>>;
for (const k of ENDPOINTS_KEYS) {
assert.equal(
typeof pt["quotaShare"]?.[k],
"string",
`pt-BR.json missing quotaShare.${k}`
);
}
});
test("i18n: full parity — en and pt-BR both have all 5 endpoint 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 ENDPOINTS_KEYS) {
assert.ok(k in (en["quotaShare"] ?? {}), `en.json missing quotaShare.${k}`);
assert.ok(k in (pt["quotaShare"] ?? {}), `pt-BR.json missing quotaShare.${k}`);
}
});

View File

@@ -29,17 +29,11 @@ const WIZARD_PATH = join(
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx"
);
const MODAL_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx"
);
const EN_PATH = join(ROOT, "src/i18n/messages/en.json");
const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json");
const pageSrc = readFileSync(PAGE_CLIENT_PATH, "utf8");
const wizardSrc = readFileSync(WIZARD_PATH, "utf8");
const modalSrc = readFileSync(MODAL_PATH, "utf8");
// ── QuotaSharePageClient: group fetch ─────────────────────────────────────────
@@ -130,10 +124,12 @@ test("QuotaSharePageClient: filters pool list by selectedGroupId (filteredPools)
});
test("QuotaSharePageClient: renders group heading above pool grid", () => {
// The group heading shows the group name and pool count
// The group heading shows the group name and pool count.
// Task 4 replaced the single-group heading (filteredPools.length) with
// per-group headings using groupPools.length — one section per group.
assert.ok(
pageSrc.includes("filteredPools.length"),
"QuotaSharePageClient must show filteredPools count in the group heading"
pageSrc.includes("groupPools.length") || pageSrc.includes("filteredPools.length"),
"QuotaSharePageClient must show pool count in the group heading (groupPools.length or filteredPools.length)"
);
});
@@ -235,14 +231,9 @@ test("PoolWizard: pool name placeholder uses provider, not connLabel", () => {
);
});
// ── EditAllocationsModal: group allocation note ────────────────────────────────
test("EditAllocationsModal: renders groupAllocationNote i18n key", () => {
assert.ok(
modalSrc.includes("groupAllocationNote"),
"EditAllocationsModal must render t('groupAllocationNote') helper text"
);
});
// ── PoolWizard: group allocation note (EditAllocationsModal retired Task 6) ───
// EditAllocationsModal was retired in Task 6. The groupAllocationNote key is kept
// in the i18n bundle for backwards compat but the wizard covers allocation editing.
// ── i18n parity ───────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,200 @@
/**
* tests/unit/quota-key-models-route.test.ts
*
* Task 2 (quota-share-v2) — source-level assertions for:
* GET /api/quota/keys/[id]/models
*
* Uses the same source-scan technique as:
* tests/unit/quota-groups-route.test.ts
* tests/unit/quota-pool-log-route.test.ts
*
* This allows the test to run with the Node.js native test runner without a
* Next.js / DOM setup, while providing strong structural coverage of:
* - Auth guard pattern (requireManagementAuth, 401 gate)
* - Error sanitization (buildErrorBody, no raw err.stack / err.message leaks)
* - Quota model resolution imports (resolveQuotaKeyScope, filterModelsToQuotaPools)
* - Response shape ({ models })
* - 404 on missing key (buildErrorBody(404, ...))
* - force-dynamic export
* - await params (Next 16 async params pattern)
* - DB imports sourced from @/lib/localDb
*
* The behavioral path (allowedQuotas → scope → filterModelsToQuotaPools → models)
* is covered by:
* - tests/unit/quota-catalog-filter.test.ts — filterModelsToQuotaPools unit tests
* - tests/unit/quota-key-resolve.test.ts — resolveQuotaKeyScope unit tests
* - tests/unit/quota-group-scope.test.ts — group-scoped pool resolution
* Re-testing the same logic inline here would duplicate DB-setup boilerplate
* without additional signal. Source-scan matches the project's established
* convention for route test files (quota-groups-route.test.ts, etc.).
*/
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/keys/[id]/models/route.ts");
const src = readFileSync(ROUTE_PATH, "utf8");
// ── Auth guard ────────────────────────────────────────────────────────────────
test("quota/keys/[id]/models: imports requireManagementAuth", () => {
assert.ok(
src.includes("requireManagementAuth"),
"route must import and call requireManagementAuth",
);
});
test("quota/keys/[id]/models: GET calls requireManagementAuth before data access", () => {
const getIdx = src.indexOf("export async function GET");
assert.ok(getIdx >= 0, "GET handler must exist");
const getBody = src.slice(getIdx);
const authIdx = getBody.indexOf("requireManagementAuth(request)");
const dataIdx = getBody.indexOf("getApiKeyById(");
assert.ok(authIdx >= 0, "requireManagementAuth call must be present in GET handler");
assert.ok(dataIdx >= 0, "getApiKeyById call must be present in GET handler");
assert.ok(authIdx < dataIdx, "auth check must come before getApiKeyById call");
});
test("quota/keys/[id]/models: GET returns early when authError is truthy", () => {
const getIdx = src.indexOf("export async function GET");
const getBody = src.slice(getIdx);
assert.ok(
getBody.includes("if (authError) return authError"),
"GET must return authError immediately — 401 without auth",
);
});
// ── Error sanitization ────────────────────────────────────────────────────────
test("quota/keys/[id]/models: imports buildErrorBody from @omniroute/open-sse/utils/error", () => {
assert.ok(
src.includes("buildErrorBody"),
"route must use buildErrorBody — Hard Rule #12",
);
assert.ok(
src.includes("@omniroute/open-sse/utils/error"),
"route must import buildErrorBody from @omniroute/open-sse/utils/error",
);
});
test("quota/keys/[id]/models: does NOT leak raw err.stack in response", () => {
assert.ok(!src.includes("err.stack"), "route must not leak err.stack in response body");
});
test("quota/keys/[id]/models: does NOT leak raw err.message outside buildErrorBody", () => {
// The only use of err.message must be inside the buildErrorBody(...) call, never
// passed raw to NextResponse.json() or similar.
const rawMessageLeak = /NextResponse\.json\s*\(\s*err\.message/.test(src);
assert.ok(!rawMessageLeak, "route must not pass raw err.message to NextResponse.json");
});
// ── Quota resolution imports ──────────────────────────────────────────────────
test("quota/keys/[id]/models: imports resolveQuotaKeyScope from @/lib/quota/quotaKey", () => {
assert.ok(
src.includes("resolveQuotaKeyScope"),
"route must call resolveQuotaKeyScope to compute pool scope",
);
assert.ok(
src.includes("@/lib/quota/quotaKey"),
"route must import resolveQuotaKeyScope from @/lib/quota/quotaKey",
);
});
test("quota/keys/[id]/models: imports filterModelsToQuotaPools from @/lib/quota/quotaCombos", () => {
assert.ok(
src.includes("filterModelsToQuotaPools"),
"route must call filterModelsToQuotaPools to filter combo candidates",
);
assert.ok(
src.includes("@/lib/quota/quotaCombos"),
"route must import filterModelsToQuotaPools from @/lib/quota/quotaCombos",
);
});
test("quota/keys/[id]/models: passes scope.poolSlugs to filterModelsToQuotaPools", () => {
assert.ok(
src.includes("scope.poolSlugs"),
"route must pass scope.poolSlugs to filterModelsToQuotaPools (same as catalog.ts approach)",
);
});
// ── DB imports from localDb ───────────────────────────────────────────────────
test("quota/keys/[id]/models: imports getApiKeyById from @/lib/localDb", () => {
assert.ok(src.includes("getApiKeyById"), "route must call getApiKeyById");
assert.ok(
src.includes("@/lib/localDb"),
"route must import from @/lib/localDb (not from @/lib/db/apiKeys directly)",
);
});
test("quota/keys/[id]/models: imports getCombos from @/lib/localDb", () => {
assert.ok(src.includes("getCombos"), "route must call getCombos to build combo candidates");
});
// ── Response shape ────────────────────────────────────────────────────────────
test("quota/keys/[id]/models: GET returns { models } shape", () => {
assert.ok(
src.includes("{ models }") || src.includes("{models}") || src.includes("models:"),
"GET must return { models } in the response body",
);
});
test("quota/keys/[id]/models: maps filtered combos to model id strings (.map(m => m.id))", () => {
assert.ok(
src.includes(".map(") && src.includes("m.id"),
"route must map filtered combo entries to their id strings",
);
});
// ── 404 on missing key ────────────────────────────────────────────────────────
test("quota/keys/[id]/models: returns buildErrorBody(404, ...) when key not found", () => {
assert.ok(
src.includes("buildErrorBody(404"),
"route must use buildErrorBody(404, ...) when the API key is not found",
);
assert.ok(src.includes("404"), "route must return HTTP 404 on missing key");
});
// ── force-dynamic ─────────────────────────────────────────────────────────────
test("quota/keys/[id]/models: has dynamic = 'force-dynamic' export", () => {
assert.ok(
src.includes('dynamic = "force-dynamic"') || src.includes("dynamic = 'force-dynamic'"),
"route must export dynamic = 'force-dynamic'",
);
});
// ── Next 16 async params ──────────────────────────────────────────────────────
test("quota/keys/[id]/models: reads id via await params (Next 16 pattern)", () => {
assert.ok(
src.includes("await params"),
"route must use await params — Next 16 async params pattern",
);
});
test("quota/keys/[id]/models: params typed as Promise<{ id: string }>", () => {
assert.ok(
src.includes("Promise<") && src.includes("id: string"),
"params type must be Promise<{ id: string }> — matching groups/[id] pattern",
);
});
// ── exports GET ───────────────────────────────────────────────────────────────
test("quota/keys/[id]/models: exports GET handler", () => {
assert.ok(
src.includes("export async function GET"),
"route must export the GET handler",
);
});

View File

@@ -0,0 +1,298 @@
/**
* tests/unit/quota-pool-update-full.test.ts
*
* Task 1 TDD — pool PATCH accepts groupId + connectionIds + re-syncs combos.
*
* Coverage:
* - PoolUpdateSchema now accepts groupId and connectionIds.
* - updatePool + syncQuotaCombos: switching a pool from openrouter to baidu
* produces baidu qtSd/ combos and prunes the openrouter combos.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── DB harness (mirror quota-pool-connections.test.ts) ──────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-update-full-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const { createGroup } = await import("../../src/lib/db/quotaGroups.ts");
const { syncQuotaCombos, removeQuotaCombosForPool } = await import(
"../../src/lib/quota/quotaCombos.ts"
);
const { PoolUpdateSchema } = await import("../../src/shared/schemas/quota.ts");
const { isQuotaModelName, parseQuotaModelName, quotaGroupSlug } = await import(
"../../src/lib/quota/quotaModelNaming.ts"
);
async function resetStorage() {
core.resetDbInstance();
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Helper: list only quota-named combos ───────────────────────────────────
async function listQuotaCombos(): Promise<Array<{ name: string }>> {
const all = await combosDb.getCombos();
return all
.filter((c) => typeof c.name === "string" && isQuotaModelName(c.name as string))
.map((c) => ({ name: c.name as string }));
}
// ── Schema tests ──────────────────────────────────────────────────────────
test("PoolUpdateSchema accepts groupId and connectionIds together with name", () => {
const result = PoolUpdateSchema.safeParse({ name: "P", groupId: "g1", connectionIds: ["c1"] });
assert.ok(result.success, `Expected success, got: ${JSON.stringify(result.error)}`);
assert.equal(result.data?.groupId, "g1");
assert.deepEqual(result.data?.connectionIds, ["c1"]);
});
test("PoolUpdateSchema.data.groupId equals the parsed value", () => {
const result = PoolUpdateSchema.safeParse({ groupId: "my-group" });
assert.ok(result.success);
assert.equal(result.data?.groupId, "my-group");
});
test("PoolUpdateSchema.data.connectionIds deep-equals the input array", () => {
const result = PoolUpdateSchema.safeParse({ connectionIds: ["conn-x", "conn-y"] });
assert.ok(result.success);
assert.deepEqual(result.data?.connectionIds, ["conn-x", "conn-y"]);
});
test("PoolUpdateSchema rejects connectionIds with empty string element", () => {
const result = PoolUpdateSchema.safeParse({ connectionIds: [""] });
assert.equal(result.success, false, "Empty string element should be rejected");
});
test("PoolUpdateSchema rejects connectionIds as empty array", () => {
const result = PoolUpdateSchema.safeParse({ connectionIds: [] });
assert.equal(result.success, false, "Empty connectionIds array should be rejected");
});
test("PoolUpdateSchema accepts empty object (no-op still works)", () => {
assert.ok(PoolUpdateSchema.safeParse({}).success);
});
test("PoolUpdateSchema accepts only groupId (partial update)", () => {
const result = PoolUpdateSchema.safeParse({ groupId: "grp-abc" });
assert.ok(result.success);
assert.equal(result.data?.groupId, "grp-abc");
assert.equal(result.data?.connectionIds, undefined);
});
// ── DB + combo integration tests ──────────────────────────────────────────
test("updatePool with new connectionIds triggers combo re-sync (openrouter → baidu)", async () => {
// Create a named group
const group = createGroup("PoolUpdateGroup");
const groupSlug = quotaGroupSlug(group.name);
// Provider connection 1: openrouter
const connA = await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "upd-or-conn",
apiKey: "sk-upd-or",
});
const idA = (connA as Record<string, unknown>).id as string;
// Provider connection 2: baidu
const connB = await providersDb.createProviderConnection({
provider: "baidu",
authType: "apikey",
name: "upd-baidu-conn",
apiKey: "sk-upd-baidu",
});
const idB = (connB as Record<string, unknown>).id as string;
// Create pool initially pointing to openrouter
const pool = poolsDb.createPool({
connectionId: idA,
name: "Pool Switch Test",
groupId: group.id,
});
// Sync: openrouter combos should now exist
await syncQuotaCombos(pool.id);
const beforeSwitch = await listQuotaCombos();
const orBefore = beforeSwitch.filter((c) => {
const p = parseQuotaModelName(c.name);
return p?.groupSlug === groupSlug && p?.provider === "openrouter";
});
assert.ok(orBefore.length > 0, `Expected openrouter combos before switch, got 0`);
// Now update the pool to point to baidu
const updated = poolsDb.updatePool(pool.id, { connectionIds: [idB] });
assert.ok(updated, "updatePool should return the updated pool");
assert.equal(updated!.connectionId, idB, "primary connectionId should now be baidu conn");
// Explicitly sync (mirrors what the PATCH route does)
await syncQuotaCombos(pool.id);
const afterSwitch = await listQuotaCombos();
// baidu combos must now exist for this group (the new provider's combos are minted)
const baiduAfter = afterSwitch.filter((c) => {
const p = parseQuotaModelName(c.name);
return p?.groupSlug === groupSlug && p?.provider === "baidu";
});
assert.ok(
baiduAfter.length > 0,
`Expected baidu combos under qtSd/${groupSlug}/baidu/... after switch, found none`
);
// The pool's primary connection should now reflect baidu
const reread = poolsDb.getPool(pool.id)!;
assert.equal(reread.connectionId, idB, "pool.connectionId must be baidu conn after update");
assert.deepEqual(reread.connectionIds, [idB], "pool.connectionIds must contain only baidu conn");
});
test("PATCH route sequence (remove→update→sync) prunes OLD-provider combos on switch", async () => {
const group = createGroup("RouteSwitchGroup");
const groupSlug = quotaGroupSlug(group.name);
const connA = await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "rt-or-conn",
apiKey: "sk-rt-or",
});
const idA = (connA as Record<string, unknown>).id as string;
const connB = await providersDb.createProviderConnection({
provider: "baidu",
authType: "apikey",
name: "rt-baidu-conn",
apiKey: "sk-rt-baidu",
});
const idB = (connB as Record<string, unknown>).id as string;
const pool = poolsDb.createPool({ connectionId: idA, name: "Route Switch", groupId: group.id });
await syncQuotaCombos(pool.id);
assert.ok(
(await listQuotaCombos()).some((c) => parseQuotaModelName(c.name)?.provider === "openrouter"),
"precondition: openrouter combos exist"
);
// Mirror the PATCH route's connection/group-change path exactly:
await removeQuotaCombosForPool(pool.id); // pool still has OLD (openrouter) provider here
poolsDb.updatePool(pool.id, { connectionIds: [idB] });
await syncQuotaCombos(pool.id); // pool now has NEW (baidu) provider
const after = await listQuotaCombos();
const orphans = after.filter((c) => {
const p = parseQuotaModelName(c.name);
return p?.groupSlug === groupSlug && p?.provider === "openrouter";
});
assert.equal(orphans.length, 0, `OLD openrouter combos must be pruned; found ${orphans.length}`);
assert.ok(
after.some((c) => {
const p = parseQuotaModelName(c.name);
return p?.groupSlug === groupSlug && p?.provider === "baidu";
}),
"NEW baidu combos must exist after the switch"
);
});
test("updatePool with groupId persists the new group assignment", () => {
const groupA = createGroup("GroupAlpha");
const groupB = createGroup("GroupBeta");
const pool = poolsDb.createPool({
connectionId: "gc-conn-1",
name: "Group Reassign Pool",
groupId: groupA.id,
});
assert.equal(pool.groupId, groupA.id, "pool should start in groupA");
const updated = poolsDb.updatePool(pool.id, { groupId: groupB.id });
assert.ok(updated, "updatePool should return updated pool");
assert.equal(updated!.groupId, groupB.id, "pool should now be in groupB");
// Re-read from DB to confirm persistence
const reread = poolsDb.getPool(pool.id)!;
assert.equal(reread.groupId, groupB.id, "persisted groupId should be groupB");
});
test("updatePool without connectionIds leaves connection membership untouched", () => {
const pool = poolsDb.createPool({
connectionId: "stable-conn",
name: "Stable Conn Pool",
connectionIds: ["stable-conn", "stable-conn-2"],
});
poolsDb.updatePool(pool.id, { name: "Renamed" });
const reread = poolsDb.getPool(pool.id)!;
assert.equal(reread.name, "Renamed");
assert.equal(reread.connectionIds.length, 2, "connectionIds should be unchanged");
assert.ok(reread.connectionIds.includes("stable-conn"));
assert.ok(reread.connectionIds.includes("stable-conn-2"));
});
test("PoolUpdateSchema path: groupId flows through to updatePool (schema→db round-trip)", () => {
const groupX = createGroup("XGroup");
const groupY = createGroup("YGroup");
const pool = poolsDb.createPool({
connectionId: "grp-rt-conn",
name: "Round Trip Pool",
groupId: groupX.id,
});
// Simulate what the PATCH route does: parse → updatePool
const parsed = PoolUpdateSchema.safeParse({ groupId: groupY.id });
assert.ok(parsed.success);
const updated = poolsDb.updatePool(pool.id, parsed.data!);
assert.ok(updated);
assert.equal(updated!.groupId, groupY.id);
});
test("PoolUpdateSchema path: connectionIds flows through to updatePool (schema→db round-trip)", () => {
const pool = poolsDb.createPool({
connectionId: "rt-conn-old",
name: "ConnIds Round Trip Pool",
});
const parsed = PoolUpdateSchema.safeParse({ connectionIds: ["rt-conn-new"] });
assert.ok(parsed.success);
const updated = poolsDb.updatePool(pool.id, parsed.data!);
assert.ok(updated);
assert.equal(updated!.connectionId, "rt-conn-new");
assert.deepEqual(updated!.connectionIds, ["rt-conn-new"]);
});

View File

@@ -0,0 +1,171 @@
/**
* Structure tests for PoolWizard editPool mode (Task 5 — Phase C1).
*
* Source-scan style, mirroring quota-pool-wizard.test.ts.
* No JSdom needed — pure text analysis of the source file.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const WIZARD_PATH = path.join(
ROOT,
"src",
"app",
"(dashboard)",
"dashboard",
"costs",
"quota-share",
"components",
"PoolWizard.tsx"
);
const EN_JSON_PATH = path.join(ROOT, "src", "i18n", "messages", "en.json");
const PT_BR_JSON_PATH = path.join(ROOT, "src", "i18n", "messages", "pt-BR.json");
const wizardSrc = fs.readFileSync(WIZARD_PATH, "utf-8");
const enJson = JSON.parse(fs.readFileSync(EN_JSON_PATH, "utf-8")) as Record<string, unknown>;
const ptBrJson = JSON.parse(fs.readFileSync(PT_BR_JSON_PATH, "utf-8")) as Record<string, unknown>;
// ── PoolWizardProps: editPool field ───────────────────────────────────────────
test("PoolWizard.tsx: declares editPool in PoolWizardProps", () => {
assert.ok(
wizardSrc.includes("editPool?"),
"Expected optional editPool field in PoolWizardProps"
);
});
test("PoolWizard.tsx: imports QuotaPool type", () => {
assert.ok(
wizardSrc.includes("QuotaPool"),
"Expected QuotaPool type to be referenced in PoolWizard"
);
});
// ── Submit handler branching ──────────────────────────────────────────────────
test("PoolWizard.tsx: submit handler contains a PATCH to /api/quota/pools/ (edit branch)", () => {
assert.ok(
wizardSrc.includes("PATCH") && wizardSrc.includes("/api/quota/pools/"),
"Expected PATCH call to /api/quota/pools/ in PoolWizard"
);
});
test("PoolWizard.tsx: submit handler still contains a POST to /api/quota/pools (create branch)", () => {
assert.ok(
wizardSrc.includes('method: "POST"') && wizardSrc.includes("/api/quota/pools"),
"Expected POST call to /api/quota/pools in PoolWizard (create branch must remain)"
);
});
test("PoolWizard.tsx: submit handler branches on editPool", () => {
assert.ok(
wizardSrc.includes("editPool"),
"Expected editPool to appear in PoolWizard source (branching in submit)"
);
// The branching condition inside handleFinish
assert.ok(
wizardSrc.includes("if (editPool)"),
"Expected if (editPool) branch in handleFinish"
);
});
// ── Pre-fill references ───────────────────────────────────────────────────────
test("PoolWizard.tsx: pre-fills pool name from editPool.name", () => {
assert.ok(
wizardSrc.includes("editPool.name"),
"Expected editPool.name used for pre-filling pool name"
);
});
test("PoolWizard.tsx: pre-fills allocations from editPool.allocations", () => {
assert.ok(
wizardSrc.includes("editPool.allocations"),
"Expected editPool.allocations used for pre-filling allocations"
);
});
test("PoolWizard.tsx: pre-fills connectionIds using editPool.connectionIds and editPool.connectionId", () => {
assert.ok(
wizardSrc.includes("editPool.connectionIds"),
"Expected editPool.connectionIds referenced in pre-fill logic"
);
assert.ok(
wizardSrc.includes("editPool.connectionId"),
"Expected editPool.connectionId referenced as fallback in pre-fill logic"
);
});
test("PoolWizard.tsx: pre-fills groupId from editPool.groupId", () => {
assert.ok(
wizardSrc.includes("editPool.groupId"),
"Expected editPool.groupId used for pre-filling group selector"
);
});
// ── i18n key usage ────────────────────────────────────────────────────────────
test("PoolWizard.tsx: uses t(\"saveChanges\") for the submit button in edit mode", () => {
assert.ok(
wizardSrc.includes('t("saveChanges")'),
"Expected t(\"saveChanges\") used in submit button (edit mode)"
);
});
test("PoolWizard.tsx: uses t(\"editPoolTitle\") for the modal title in edit mode", () => {
assert.ok(
wizardSrc.includes('t("editPoolTitle")'),
"Expected t(\"editPoolTitle\") used in modal title (edit mode)"
);
});
// ── i18n parity: en.json ─────────────────────────────────────────────────────
test("en.json quotaShare namespace: contains editPoolTitle key", () => {
const quotaShare = enJson["quotaShare"] as Record<string, unknown> | undefined;
assert.ok(quotaShare, "Expected quotaShare namespace in en.json");
assert.ok(
"editPoolTitle" in quotaShare,
"Expected editPoolTitle key in en.json quotaShare namespace"
);
assert.equal(typeof quotaShare["editPoolTitle"], "string", "editPoolTitle must be a string");
});
test("en.json quotaShare namespace: contains saveChanges key", () => {
const quotaShare = enJson["quotaShare"] as Record<string, unknown> | undefined;
assert.ok(quotaShare, "Expected quotaShare namespace in en.json");
assert.ok(
"saveChanges" in quotaShare,
"Expected saveChanges key in en.json quotaShare namespace"
);
assert.equal(typeof quotaShare["saveChanges"], "string", "saveChanges must be a string");
});
// ── i18n parity: pt-BR.json ──────────────────────────────────────────────────
test("pt-BR.json quotaShare namespace: contains editPoolTitle key", () => {
const quotaShare = ptBrJson["quotaShare"] as Record<string, unknown> | undefined;
assert.ok(quotaShare, "Expected quotaShare namespace in pt-BR.json");
assert.ok(
"editPoolTitle" in quotaShare,
"Expected editPoolTitle key in pt-BR.json quotaShare namespace"
);
assert.equal(typeof quotaShare["editPoolTitle"], "string", "editPoolTitle must be a string");
});
test("pt-BR.json quotaShare namespace: contains saveChanges key", () => {
const quotaShare = ptBrJson["quotaShare"] as Record<string, unknown> | undefined;
assert.ok(quotaShare, "Expected quotaShare namespace in pt-BR.json");
assert.ok(
"saveChanges" in quotaShare,
"Expected saveChanges key in pt-BR.json quotaShare namespace"
);
assert.equal(typeof quotaShare["saveChanges"], "string", "saveChanges must be a string");
});

View File

@@ -193,9 +193,15 @@ test("QuotaSharePageClient.tsx: passes open/onClose/onSaved/connections/apiKeys/
);
});
test("QuotaSharePageClient.tsx: EditAllocationsModal is still present for editing existing pools", () => {
test("QuotaSharePageClient.tsx: EditAllocationsModal is NOT present (retired Task 6 — edit uses PoolWizard)", () => {
// Task 6 replaced EditAllocationsModal with a second PoolWizard instance for editing.
assert.ok(
pageClientSrc.includes("EditAllocationsModal"),
"Expected EditAllocationsModal to remain for editing existing pools"
!pageClientSrc.includes("EditAllocationsModal"),
"EditAllocationsModal must not appear in QuotaSharePageClient — edit mode uses PoolWizard"
);
// The editing pool must be wired to a PoolWizard via editPool prop
assert.ok(
pageClientSrc.includes("editPool={editing"),
"Expected editPool={editing...} passed to PoolWizard for edit mode"
);
});

View File

@@ -0,0 +1,152 @@
/**
* tests/unit/quota-share-layout-v2.test.ts
*
* Task 4 — source-level assertions for the "all groups" default,
* stacked group sections, and 3-column card grid in QuotaSharePageClient.
*
* Pattern mirrors tests/unit/quota-groups-ui.test.ts (source-scan).
* Node.js native test runner — no DOM setup required.
*/
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 PAGE_CLIENT_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx"
);
const EN_PATH = join(ROOT, "src/i18n/messages/en.json");
const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json");
const pageSrc = readFileSync(PAGE_CLIENT_PATH, "utf8");
// ── 1. Default state is "all" ────────────────────────────────────────────────
test('QuotaSharePageClient: selectedGroupId defaults to "all"', () => {
assert.ok(
pageSrc.includes('useState<string>("all")'),
'QuotaSharePageClient must default selectedGroupId to "all" via useState<string>("all")'
);
});
// ── 2. "All groups" option in <select> ──────────────────────────────────────
test('QuotaSharePageClient: <select> has an option with value="all" using t("allGroups")', () => {
assert.ok(
pageSrc.includes('value="all"'),
'QuotaSharePageClient <select> must include an <option value="all">'
);
assert.ok(
pageSrc.includes('t("allGroups")') || pageSrc.includes("t('allGroups')"),
'The "all" option must use the t("allGroups") i18n key'
);
});
// ── 3. Multi-group render (groupsToRender iteration) ─────────────────────────
test("QuotaSharePageClient: renders groups by iterating groupsToRender (all-groups mode)", () => {
// Must derive groupsToRender (or equivalent) and map over it
assert.ok(
pageSrc.includes("groupsToRender"),
"QuotaSharePageClient must define groupsToRender to iterate over groups"
);
// The render must map over groupsToRender to produce per-group sections
assert.ok(
pageSrc.includes("groupsToRender.map") || pageSrc.includes("groupsToRender.filter") || pageSrc.includes(".map((g)") || pageSrc.includes(".map((g, "),
"QuotaSharePageClient must map over groupsToRender to render one section per group"
);
});
test("QuotaSharePageClient: computes groupsToRender from selectedGroupId === \"all\" check", () => {
// The all-groups path: selectedGroupId === "all" ? groups : groups.filter(...)
assert.ok(
pageSrc.includes('selectedGroupId === "all"'),
'QuotaSharePageClient must branch on selectedGroupId === "all" when computing groupsToRender'
);
});
// ── 4. 3-column grid ──────────────────────────────────────────────────────────
test("QuotaSharePageClient: card grid uses xl:grid-cols-3", () => {
assert.ok(
pageSrc.includes("xl:grid-cols-3"),
"QuotaSharePageClient card grid className must include xl:grid-cols-3"
);
});
test("QuotaSharePageClient: card grid uses md:grid-cols-2", () => {
assert.ok(
pageSrc.includes("md:grid-cols-2"),
"QuotaSharePageClient card grid className must include md:grid-cols-2 (intermediate breakpoint)"
);
});
// ── 5. Rename button hidden/disabled when "all" is selected ──────────────────
test('QuotaSharePageClient: rename button is hidden/disabled when selectedGroupId === "all"', () => {
// When selectedGroupId === "all", the rename button should be hidden or disabled.
// The old guard was `selectedGroupId !== "group-demo"`.
// New guard must exclude "all" (either explicit !== "all" or a more general check).
assert.ok(
pageSrc.includes('selectedGroupId !== "all"') ||
// guard via a truthy group lookup (groups.find(g.id === selectedGroupId) is falsy for "all")
pageSrc.includes("groups.find((g) => g.id === selectedGroupId)") ||
pageSrc.includes("groups.find(g => g.id === selectedGroupId)"),
'Rename button must be hidden when selectedGroupId === "all" (guard via !== "all" or group lookup)'
);
});
// ── 6. i18n parity: allGroups key ────────────────────────────────────────────
test('i18n en.json: quotaShare.allGroups exists and equals "All groups"', () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<
string,
Record<string, string>
>;
assert.equal(
typeof en["quotaShare"]?.["allGroups"],
"string",
"en.json must have quotaShare.allGroups"
);
assert.equal(
en["quotaShare"]["allGroups"],
"All groups",
'en.json quotaShare.allGroups must equal "All groups"'
);
});
test('i18n pt-BR.json: quotaShare.allGroups exists and equals "Todos os grupos"', () => {
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<
string,
Record<string, string>
>;
assert.equal(
typeof pt["quotaShare"]?.["allGroups"],
"string",
"pt-BR.json must have quotaShare.allGroups"
);
assert.equal(
pt["quotaShare"]["allGroups"],
"Todos os grupos",
'pt-BR.json quotaShare.allGroups must equal "Todos os grupos"'
);
});
test("i18n parity: allGroups present in both en and pt-BR", () => {
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>
>;
assert.ok("allGroups" in (en["quotaShare"] ?? {}), "en.json missing quotaShare.allGroups");
assert.ok("allGroups" in (pt["quotaShare"] ?? {}), "pt-BR.json missing quotaShare.allGroups");
});

View File

@@ -0,0 +1,77 @@
/**
* tests/unit/sidebar-quota-share-placement.test.ts
*
* Task 3 — source-scan assertions for Quota Share nav item placement.
*
* Asserts that `costs-quota-share` sits immediately after `quota` (Provider Quota)
* in the SAME array, and is no longer in the costs section array.
*
* Pattern mirrors: tests/unit/quota-groups-ui.test.ts (readFileSync style)
*
* Node.js native test runner — no DOM setup required.
*/
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 SIDEBAR_PATH = join(ROOT, "src/shared/constants/sidebarVisibility.ts");
const src = readFileSync(SIDEBAR_PATH, "utf8");
// ── Placement order: costs-quota-share must come AFTER quota ─────────────────
test("sidebar: costs-quota-share appears after quota in source", () => {
const idxQuota = src.indexOf('id: "quota"');
const idxQuotaShare = src.indexOf('id: "costs-quota-share"');
assert.ok(idxQuota >= 0, 'Could not find id: "quota" in sidebarVisibility.ts');
assert.ok(idxQuotaShare >= 0, 'Could not find id: "costs-quota-share" in sidebarVisibility.ts');
assert.ok(
idxQuotaShare > idxQuota,
`costs-quota-share (idx ${idxQuotaShare}) must appear AFTER quota (idx ${idxQuota})`
);
});
// ── Same array: no array-close ]; between quota and costs-quota-share ─────────
test("sidebar: no array close ]; between quota and costs-quota-share (same array)", () => {
const idxQuota = src.indexOf('id: "quota"');
const idxQuotaShare = src.indexOf('id: "costs-quota-share"');
assert.ok(idxQuota >= 0, 'Could not find id: "quota"');
assert.ok(idxQuotaShare >= 0, 'Could not find id: "costs-quota-share"');
const between = src.slice(idxQuota, idxQuotaShare);
assert.ok(
!between.includes("];"),
`There must be NO array-close ]; between quota and costs-quota-share. Found one, meaning they are in different arrays.\nSlice between them:\n${between}`
);
});
// ── Costs section no longer contains costs-quota-share ───────────────────────
test("sidebar: costs section does not have costs-quota-share right after costs-budget", () => {
// costs-budget and costs-quota-share should NOT be close neighbours (within 200 chars)
const idxBudget = src.indexOf('id: "costs-budget"');
const idxQuotaShare = src.indexOf('id: "costs-quota-share"');
assert.ok(idxBudget >= 0, 'Could not find id: "costs-budget"');
assert.ok(idxQuotaShare >= 0, 'Could not find id: "costs-quota-share"');
const gap = Math.abs(idxQuotaShare - idxBudget);
assert.ok(
gap > 200,
`costs-quota-share must NOT be immediately after costs-budget in the costs section (gap: ${gap} chars, expected > 200)`
);
});
// ── Exactly one occurrence of costs-quota-share ───────────────────────────────
test("sidebar: exactly one occurrence of costs-quota-share", () => {
const occurrences = src.split('id: "costs-quota-share"').length - 1;
assert.equal(
occurrences,
1,
`Expected exactly 1 occurrence of id: "costs-quota-share", found ${occurrences}`
);
});