mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "qtSd/<group>/<provider>/<model>"
|
||||
</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/<groupSlug>/<provider>/<model>
|
||||
</code>
|
||||
{" — "}
|
||||
{t("emptyDescription")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
70
src/app/api/quota/keys/[id]/models/route.ts
Normal file
70
src/app/api/quota/keys/[id]/models/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
132
tests/unit/quota-edit-opens-wizard.test.ts
Normal file
132
tests/unit/quota-edit-opens-wizard.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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) ─────────────────────────
|
||||
|
||||
|
||||
163
tests/unit/quota-endpoints-card.test.ts
Normal file
163
tests/unit/quota-endpoints-card.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
200
tests/unit/quota-key-models-route.test.ts
Normal file
200
tests/unit/quota-key-models-route.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
298
tests/unit/quota-pool-update-full.test.ts
Normal file
298
tests/unit/quota-pool-update-full.test.ts
Normal 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"]);
|
||||
});
|
||||
171
tests/unit/quota-pool-wizard-edit.test.ts
Normal file
171
tests/unit/quota-pool-wizard-edit.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
152
tests/unit/quota-share-layout-v2.test.ts
Normal file
152
tests/unit/quota-share-layout-v2.test.ts
Normal 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");
|
||||
});
|
||||
77
tests/unit/sidebar-quota-share-placement.test.ts
Normal file
77
tests/unit/sidebar-quota-share-placement.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user