mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(quota-share): hidden pools, delete-group UI, endpoints card (Anthropic + collapse)
Bugs found while testing the Quota Share engine on the local VPS: - B1 hidden/stuck pools: pools created while the page group filter was "all" were persisted with group_id="all", matched no real group, and rendered nowhere — so they could not be seen, edited or deleted. PoolWizard now resolves the group id away from the "all" sentinel before POST/PATCH (falls back to the first real group / seed group-demo), and QuotaSharePageClient renders an "Ungrouped" recovery bucket so already-orphaned pools stay editable/deletable. - B3 one-connection-per-pool made explicit: existingPoolConnectionIds now spans every member connection (not just the primary), and the wizard shows which pool an already-used connection belongs to instead of silently disabling it. - B4 delete group: wired the missing UI control + handler (handleDeleteGroup, 409-aware) — the backend DELETE handler + deleteGroup already existed. Hidden for "all" and the protected seed group-demo. - B5a endpoints card now surfaces the native Anthropic POST /v1/messages line when a claude*/anthropic provider is in scope (previously only /v1/chat/completions). - B5b endpoints card gained a collapse/minimize toggle (the card was too tall). Source-scan tests + en/pt-BR i18n parity in quota-share-bugfixes-v388.test.ts. The larger quota-key redesign (key type bound to a group, default-restricted with opt-in normal-model access, recoverable keys, api-keys page layout) is planned separately in _tasks/features-v3.8.8/quota-share-key-redesign.plan.md.
This commit is contained in:
@@ -251,6 +251,25 @@ export default function QuotaSharePageClient() {
|
||||
setRenaming(false);
|
||||
}, [selectedGroupId, groups, fetchGroups, t]);
|
||||
|
||||
// Delete the selected group. The API blocks deletion while the group still has
|
||||
// pools (HTTP 409) and protects the seed "group-demo"; surface both to the user.
|
||||
const handleDeleteGroup = useCallback(async () => {
|
||||
if (selectedGroupId === "all" || selectedGroupId === "group-demo") return;
|
||||
if (!confirm(t("deleteGroupConfirm"))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/quota/groups/${selectedGroupId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setSelectedGroupId("all");
|
||||
await fetchGroups();
|
||||
await mutate();
|
||||
} else if (res.status === 409) {
|
||||
alert(t("deleteGroupHasPools"));
|
||||
}
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
}, [selectedGroupId, fetchGroups, mutate, t]);
|
||||
|
||||
// ── Derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
const keyLabels = useMemo(() => {
|
||||
@@ -274,6 +293,29 @@ export default function QuotaSharePageClient() {
|
||||
[connections]
|
||||
);
|
||||
|
||||
// connectionId → name of the pool it already belongs to (all members, not just
|
||||
// primary). Feeds the wizard's "already used" hint so the one-connection-per-pool
|
||||
// rule is explicit instead of silently disabling a checkbox.
|
||||
const connectionPoolName = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of pools) {
|
||||
const name = (p as unknown as { name?: string }).name ?? p.id.slice(0, 8);
|
||||
for (const cid of p.connectionIds ?? [p.connectionId]) {
|
||||
if (!(cid in map)) map[cid] = name;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [pools]);
|
||||
|
||||
// Pools whose groupId matches no loaded group (e.g. legacy pools saved with the
|
||||
// "all" sentinel). Surfaced in an "Ungrouped" bucket so they stay editable/deletable.
|
||||
const orphanPools = useMemo(() => {
|
||||
const known = new Set(groups.map((g) => g.id));
|
||||
return pools.filter(
|
||||
(p) => !known.has((p as unknown as { groupId?: string }).groupId ?? "group-demo")
|
||||
);
|
||||
}, [pools, groups]);
|
||||
|
||||
const aggregate = usePoolsUsageAggregate(pools);
|
||||
|
||||
const stats = useMemo(
|
||||
@@ -423,6 +465,16 @@ export default function QuotaSharePageClient() {
|
||||
{t("renameGroup")}
|
||||
</button>
|
||||
)}
|
||||
{selectedGroupId !== "all" && selectedGroupId !== "group-demo" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteGroup()}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-red-400 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">delete</span>
|
||||
{t("deleteGroup")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Concept card */}
|
||||
@@ -531,6 +583,38 @@ export default function QuotaSharePageClient() {
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Ungrouped bucket — pools whose group no longer matches (e.g. legacy
|
||||
"all" sentinel). Keeps them visible + editable + deletable. */}
|
||||
{selectedGroupId === "all" && orphanPools.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-amber-400">
|
||||
folder_off
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-main">{t("ungroupedTitle")}</span>
|
||||
<span className="text-[11px] text-text-muted">({orphanPools.length})</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-400/80">{t("ungroupedHint")}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{orphanPools.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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -542,7 +626,8 @@ export default function QuotaSharePageClient() {
|
||||
connections={connections}
|
||||
apiKeys={apiKeys}
|
||||
plans={plans}
|
||||
existingPoolConnectionIds={new Set(pools.map((p) => p.connectionId))}
|
||||
existingPoolConnectionIds={new Set(pools.flatMap((p) => p.connectionIds ?? [p.connectionId]))}
|
||||
connectionPoolName={connectionPoolName}
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
/>
|
||||
@@ -560,7 +645,14 @@ export default function QuotaSharePageClient() {
|
||||
connections={connections}
|
||||
apiKeys={apiKeys}
|
||||
plans={plans}
|
||||
existingPoolConnectionIds={new Set(pools.filter((p) => p.id !== editing?.id).map((p) => p.connectionId))}
|
||||
existingPoolConnectionIds={
|
||||
new Set(
|
||||
pools
|
||||
.filter((p) => p.id !== editing?.id)
|
||||
.flatMap((p) => p.connectionIds ?? [p.connectionId])
|
||||
)
|
||||
}
|
||||
connectionPoolName={connectionPoolName}
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface PoolWizardProps {
|
||||
editPool?: QuotaPool;
|
||||
/** Whether the pool being edited is currently exclusive. Used to pre-fill the exclusive checkbox in edit mode. */
|
||||
editPoolExclusive?: boolean;
|
||||
/** connectionId → name of the pool it already belongs to, for the "already used" hint. */
|
||||
connectionPoolName?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -176,6 +178,7 @@ export default function PoolWizard({
|
||||
selectedGroupId: initialGroupId = "group-demo",
|
||||
editPool,
|
||||
editPoolExclusive,
|
||||
connectionPoolName = {},
|
||||
}: PoolWizardProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
const tPlans = useTranslations("quotaPlans");
|
||||
@@ -305,6 +308,18 @@ export default function PoolWizard({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, editPool, initialGroupId]);
|
||||
|
||||
// Keep the group <select> on a real, selectable option: if the inherited page
|
||||
// filter was "all" (or an unknown id), snap to the first real group once groups
|
||||
// load. Prevents persisting groupId="all" (which renders under no group → B1).
|
||||
useEffect(() => {
|
||||
if (!open || editPool) return;
|
||||
if (groups.length === 0) return;
|
||||
if (groupId === "all" || !groups.some((g) => g.id === groupId)) {
|
||||
setGroupId(groups[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, editPool, groups]);
|
||||
|
||||
// ── Step 2 — dimension editors ────────────────────────────────────────────
|
||||
|
||||
const addDimension = () => {
|
||||
@@ -409,6 +424,13 @@ export default function PoolWizard({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Never persist the "all" filter sentinel (or an unknown id) as a real group.
|
||||
// Fall back to the first real group — the seed "group-demo" always exists (migration 088).
|
||||
const resolvedGroupId =
|
||||
groupId && groupId !== "all" && groups.some((g) => g.id === groupId)
|
||||
? groupId
|
||||
: (groups[0]?.id ?? "group-demo");
|
||||
|
||||
try {
|
||||
if (!editPool) {
|
||||
// ── Create mode: POST → optional PUT → PATCH ──────────────────────
|
||||
@@ -422,7 +444,7 @@ export default function PoolWizard({
|
||||
connectionIds,
|
||||
name: effectivePoolName,
|
||||
allocations: [],
|
||||
groupId,
|
||||
groupId: resolvedGroupId,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
@@ -471,7 +493,7 @@ export default function PoolWizard({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: effectivePoolName,
|
||||
groupId,
|
||||
groupId: resolvedGroupId,
|
||||
connectionIds,
|
||||
allocations,
|
||||
exclusive,
|
||||
@@ -589,6 +611,7 @@ export default function PoolWizard({
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{connLabel(c)} {t("alreadyUsedSuffix")}
|
||||
{connectionPoolName[c.id] ? ` — ${connectionPoolName[c.id]}` : ""}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -87,6 +87,23 @@ export default function QuotaEndpointsCard({
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
|
||||
const [previewModels, setPreviewModels] = useState<string[] | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Anthropic-format providers (claude*/anthropic) can be called on the native
|
||||
// Messages endpoint too, so we surface POST /v1/messages when one is in scope.
|
||||
const isAnthropicProvider = (provider: string) =>
|
||||
provider === "anthropic" || provider.startsWith("claude");
|
||||
|
||||
const hasAnthropic = useMemo(() => {
|
||||
const provs = new Set<string>();
|
||||
for (const pool of pools) {
|
||||
for (const cid of pool.connectionIds ?? [pool.connectionId]) {
|
||||
const conn = connections.find((c) => c.id === cid);
|
||||
if (conn) provs.add(conn.provider);
|
||||
}
|
||||
}
|
||||
return [...provs].some(isAnthropicProvider);
|
||||
}, [pools, connections]);
|
||||
|
||||
// ── Derive default model list from groups + pools + connections ──────────────
|
||||
// For each group, collect all pools that belong to it, then for each pool's
|
||||
@@ -183,38 +200,68 @@ export default function QuotaEndpointsCard({
|
||||
</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>
|
||||
)}
|
||||
{/* Key preview selector + collapse toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{apiKeys.length > 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>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? t("endpointsExpand") : t("endpointsCollapse")}
|
||||
aria-label={collapsed ? t("endpointsExpand") : t("endpointsCollapse")}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{collapsed ? "expand_more" : "expand_less"}
|
||||
</span>
|
||||
</button>
|
||||
</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>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Base URL line(s) */}
|
||||
<div className="mt-3 rounded-md bg-bg-subtle/50 border border-border/40 px-3 py-2 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
{hasAnthropic && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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/messages</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>
|
||||
<span className="text-[10px] text-text-muted">({t("endpointsAnthropicNote")})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model listing */}
|
||||
@@ -284,6 +331,8 @@ export default function QuotaEndpointsCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7934,7 +7934,15 @@
|
||||
"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"
|
||||
"endpointsBaseUrl": "Base URL",
|
||||
"endpointsCollapse": "Collapse",
|
||||
"endpointsExpand": "Expand",
|
||||
"endpointsAnthropicNote": "Anthropic-native",
|
||||
"deleteGroup": "Delete group",
|
||||
"deleteGroupConfirm": "Delete this group? Its pools must be reassigned or removed first.",
|
||||
"deleteGroupHasPools": "This group still has pools — reassign or delete them first.",
|
||||
"ungroupedTitle": "Ungrouped",
|
||||
"ungroupedHint": "These pools are not assigned to a known group. Edit a pool to move it into a real group."
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
|
||||
@@ -5423,7 +5423,15 @@
|
||||
"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"
|
||||
"endpointsBaseUrl": "URL base",
|
||||
"endpointsCollapse": "Recolher",
|
||||
"endpointsExpand": "Expandir",
|
||||
"endpointsAnthropicNote": "nativo Anthropic",
|
||||
"deleteGroup": "Excluir grupo",
|
||||
"deleteGroupConfirm": "Excluir este grupo? Os pools dele precisam ser reatribuídos ou removidos antes.",
|
||||
"deleteGroupHasPools": "Este grupo ainda tem pools — reatribua ou exclua-os primeiro.",
|
||||
"ungroupedTitle": "Sem grupo",
|
||||
"ungroupedHint": "Estes pools não estão atribuídos a um grupo conhecido. Edite um pool para movê-lo para um grupo real."
|
||||
},
|
||||
"requestLogger": {
|
||||
"recording": "Recording",
|
||||
|
||||
160
tests/unit/quota-share-bugfixes-v388.test.ts
Normal file
160
tests/unit/quota-share-bugfixes-v388.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* tests/unit/quota-share-bugfixes-v388.test.ts
|
||||
*
|
||||
* Source-level assertions for the v3.8.8 Quota Share bug fixes reported from
|
||||
* Local-VPS testing. Pattern mirrors quota-share-layout-v2.test.ts (source-scan
|
||||
* + i18n parity) — no DOM setup required.
|
||||
*
|
||||
* B1 pools created "in a group" persisted with the "all" sentinel → hidden,
|
||||
* uneditable, undeletable. Fix: wizard never persists "all"; page shows an
|
||||
* "Ungrouped" recovery bucket for orphan pools.
|
||||
* B3 one-connection-per-pool made explicit (member set + "already used" pool name).
|
||||
* B4 delete-group control wired in the UI (backend already existed).
|
||||
* B5a native Anthropic POST /v1/messages surfaced in the endpoints card.
|
||||
* B5b endpoints card collapse/minimize toggle.
|
||||
*/
|
||||
|
||||
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 QS = "src/app/(dashboard)/dashboard/costs/quota-share";
|
||||
|
||||
const pageSrc = readFileSync(join(ROOT, QS, "QuotaSharePageClient.tsx"), "utf8");
|
||||
const wizardSrc = readFileSync(join(ROOT, QS, "components/PoolWizard.tsx"), "utf8");
|
||||
const endpointsSrc = readFileSync(join(ROOT, QS, "components/QuotaEndpointsCard.tsx"), "utf8");
|
||||
const en = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/en.json"), "utf8")) as {
|
||||
quotaShare: Record<string, string>;
|
||||
};
|
||||
const pt = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/pt-BR.json"), "utf8")) as {
|
||||
quotaShare: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── B1 — wizard never persists the "all" sentinel as a real group ────────────
|
||||
|
||||
test("B1: PoolWizard resolves groupId away from 'all' before persisting", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("const resolvedGroupId ="),
|
||||
"wizard must compute a resolvedGroupId before saving"
|
||||
);
|
||||
assert.ok(
|
||||
/resolvedGroupId\s*=[\s\S]*?groupId\s*!==\s*"all"/.test(wizardSrc),
|
||||
"resolvedGroupId must guard against the 'all' sentinel"
|
||||
);
|
||||
// Both the create POST body and the edit PATCH body must send the resolved id,
|
||||
// never the raw groupId state.
|
||||
const groupIdSends = wizardSrc.match(/groupId:\s*resolvedGroupId/g) ?? [];
|
||||
assert.ok(
|
||||
groupIdSends.length >= 2,
|
||||
`both create + edit bodies must send groupId: resolvedGroupId (found ${groupIdSends.length})`
|
||||
);
|
||||
assert.ok(
|
||||
!/\n\s*groupId,\n/.test(wizardSrc),
|
||||
"wizard must not send the bare groupId shorthand in a request body"
|
||||
);
|
||||
});
|
||||
|
||||
// ── B1 — page surfaces orphan pools so stuck pools stay actionable ───────────
|
||||
|
||||
test("B1: QuotaSharePageClient renders an Ungrouped bucket for orphan pools", () => {
|
||||
assert.ok(pageSrc.includes("const orphanPools = useMemo"), "must compute orphanPools");
|
||||
assert.ok(
|
||||
pageSrc.includes('t("ungroupedTitle")'),
|
||||
"must render the ungrouped section heading"
|
||||
);
|
||||
// Orphan pools must reuse the same card with edit/remove wiring.
|
||||
const orphanBlock = pageSrc.slice(pageSrc.indexOf('t("ungroupedTitle")'));
|
||||
assert.ok(
|
||||
orphanBlock.includes("orphanPools.map") &&
|
||||
orphanBlock.includes("onEdit") &&
|
||||
orphanBlock.includes("onRemove"),
|
||||
"orphan pools must render PoolCard with edit + remove controls"
|
||||
);
|
||||
});
|
||||
|
||||
// ── B3 — one connection per pool, made explicit ──────────────────────────────
|
||||
|
||||
test("B3: connection→pool membership is explicit (all members, not just primary)", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("const connectionPoolName = useMemo"),
|
||||
"page must build a connectionId→pool-name map"
|
||||
);
|
||||
assert.ok(
|
||||
pageSrc.includes("connectionPoolName={connectionPoolName}"),
|
||||
"page must pass connectionPoolName into the wizard"
|
||||
);
|
||||
// existingPoolConnectionIds must include every member connection, not only the primary.
|
||||
assert.ok(
|
||||
pageSrc.includes("flatMap((p) => p.connectionIds ?? [p.connectionId])"),
|
||||
"existingPoolConnectionIds must span all member connections"
|
||||
);
|
||||
assert.ok(
|
||||
wizardSrc.includes("connectionPoolName[c.id]"),
|
||||
"wizard must show which pool an already-used connection belongs to"
|
||||
);
|
||||
});
|
||||
|
||||
// ── B4 — delete-group control wired in the UI ────────────────────────────────
|
||||
|
||||
test("B4: QuotaSharePageClient wires a delete-group control (protecting the seed)", () => {
|
||||
assert.ok(pageSrc.includes("const handleDeleteGroup = useCallback"), "must define handleDeleteGroup");
|
||||
assert.ok(
|
||||
pageSrc.includes('method: "DELETE" }') && pageSrc.includes("/api/quota/groups/"),
|
||||
"handleDeleteGroup must DELETE the group via the API"
|
||||
);
|
||||
assert.ok(
|
||||
pageSrc.includes('res.status === 409') && pageSrc.includes('t("deleteGroupHasPools")'),
|
||||
"must handle the 409 (group still has pools) response"
|
||||
);
|
||||
assert.ok(
|
||||
pageSrc.includes('selectedGroupId !== "all" && selectedGroupId !== "group-demo"'),
|
||||
"delete control must be hidden for 'all' and the protected seed group"
|
||||
);
|
||||
});
|
||||
|
||||
// ── B5a — native Anthropic endpoint ──────────────────────────────────────────
|
||||
|
||||
test("B5a: endpoints card surfaces POST /v1/messages for Anthropic providers", () => {
|
||||
assert.ok(endpointsSrc.includes("const hasAnthropic"), "must detect Anthropic providers in scope");
|
||||
assert.ok(endpointsSrc.includes("POST /v1/messages"), "must show the native Anthropic endpoint");
|
||||
assert.ok(
|
||||
/isAnthropicProvider[\s\S]*?startsWith\("claude"\)/.test(endpointsSrc),
|
||||
"Anthropic detection must cover claude* providers"
|
||||
);
|
||||
});
|
||||
|
||||
// ── B5b — collapse toggle ────────────────────────────────────────────────────
|
||||
|
||||
test("B5b: endpoints card has a collapse/expand toggle", () => {
|
||||
assert.ok(endpointsSrc.includes("const [collapsed, setCollapsed]"), "must hold a collapsed state");
|
||||
assert.ok(
|
||||
endpointsSrc.includes("{!collapsed && ("),
|
||||
"the card body must be hidden while collapsed"
|
||||
);
|
||||
assert.ok(
|
||||
endpointsSrc.includes('t("endpointsCollapse")') && endpointsSrc.includes('t("endpointsExpand")'),
|
||||
"toggle must use collapse/expand labels"
|
||||
);
|
||||
});
|
||||
|
||||
// ── i18n parity for every new key ────────────────────────────────────────────
|
||||
|
||||
test("i18n: new quotaShare keys exist in both en and pt-BR", () => {
|
||||
const keys = [
|
||||
"deleteGroup",
|
||||
"deleteGroupConfirm",
|
||||
"deleteGroupHasPools",
|
||||
"ungroupedTitle",
|
||||
"ungroupedHint",
|
||||
"endpointsCollapse",
|
||||
"endpointsExpand",
|
||||
"endpointsAnthropicNote",
|
||||
];
|
||||
for (const k of keys) {
|
||||
assert.ok(en.quotaShare[k], `en.json quotaShare.${k} must exist`);
|
||||
assert.ok(pt.quotaShare[k], `pt-BR.json quotaShare.${k} must exist`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user