mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
feat(quota): multi-connection pool wizard — select N providers, all models available (Phase D3)
- PoolCreateSchema: add optional connectionIds[] + .refine() that enforces primary membership - PoolWizard: replace single-select dropdown with checkbox multi-select; first checked = primary (badge); step-2 adds helper note for additional connections; step-3 preview grouped by provider with +N more; POST body sends both connectionId and connectionIds - PoolCard: optional providers[] prop renders a row of ProviderIcon (up to 3 + badge) instead of a single icon when pool has multiple connections - i18n: 4 new keys added to both en.json and pt-BR.json (wizardConnectionsLabel, wizardPrimaryBadge, wizardAdditionalConnectionsNote, wizardPreviewMoreModels) — parity maintained (23 wizard keys each) - Tests: quota-pool-wizard-multi.test.ts (21 tests) covering schema accept/reject, structural wizard assertions, and i18n parity
This commit is contained in:
@@ -17,8 +17,10 @@ export interface PoolCardProps {
|
||||
keyLabels: Record<string, string>;
|
||||
/** Connection display label */
|
||||
connectionLabel: string;
|
||||
/** Provider identifier */
|
||||
/** Primary provider identifier */
|
||||
provider: string;
|
||||
/** Optional list of all provider identifiers when pool has multiple connections */
|
||||
providers?: string[];
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
@@ -47,6 +49,7 @@ export default function PoolCard({
|
||||
keyLabels,
|
||||
connectionLabel,
|
||||
provider,
|
||||
providers,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: PoolCardProps) {
|
||||
@@ -62,9 +65,28 @@ export default function PoolCard({
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center overflow-hidden shrink-0 bg-bg-subtle">
|
||||
<ProviderIcon providerId={provider} size={28} type="color" />
|
||||
</div>
|
||||
{/* Provider icon(s): show one per provider when multi-connection, else single icon */}
|
||||
{providers && providers.length > 1 ? (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{providers.slice(0, 3).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="w-5 h-5 rounded flex items-center justify-center overflow-hidden bg-bg-subtle"
|
||||
>
|
||||
<ProviderIcon providerId={p} size={20} type="color" />
|
||||
</div>
|
||||
))}
|
||||
{providers.length > 3 && (
|
||||
<span className="text-[10px] text-text-muted font-semibold ml-0.5">
|
||||
+{providers.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center overflow-hidden shrink-0 bg-bg-subtle">
|
||||
<ProviderIcon providerId={provider} size={28} type="color" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`material-symbols-outlined text-[16px] shrink-0 ${statusCls}`}>
|
||||
|
||||
@@ -165,7 +165,8 @@ export default function PoolWizard({
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
|
||||
// ── Step 1 state ──────────────────────────────────────────────────────────
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
// Multi-select: ordered array of selected connection IDs. First element is primary.
|
||||
const [connectionIds, setConnectionIds] = useState<string[]>([]);
|
||||
const [poolName, setPoolName] = useState("");
|
||||
const [defaultPolicy, setDefaultPolicy] = useState<Policy>("hard");
|
||||
|
||||
@@ -181,14 +182,18 @@ export default function PoolWizard({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Derived state ─────────────────────────────────────────────────────────
|
||||
// The first selected connection is the "primary" — used for plan fetch/PUT.
|
||||
const primaryConnectionId = connectionIds[0] ?? "";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const connLabel = (c: Connection) =>
|
||||
`${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`;
|
||||
|
||||
const selectedConn = useMemo(
|
||||
() => connections.find((c) => c.id === connectionId),
|
||||
[connections, connectionId]
|
||||
() => connections.find((c) => c.id === primaryConnectionId),
|
||||
[connections, primaryConnectionId]
|
||||
);
|
||||
|
||||
const availableConnections = useMemo(
|
||||
@@ -196,15 +201,15 @@ export default function PoolWizard({
|
||||
[connections, existingPoolConnectionIds]
|
||||
);
|
||||
|
||||
// ── Load dimensions when connection changes ───────────────────────────────
|
||||
// ── Load dimensions when primary connection changes ───────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionId) {
|
||||
if (!primaryConnectionId) {
|
||||
setEditDimensions([]);
|
||||
setDimensionsEdited(false);
|
||||
return;
|
||||
}
|
||||
const existingPlan = plans[connectionId];
|
||||
const existingPlan = plans[primaryConnectionId];
|
||||
if (existingPlan && existingPlan.dimensions.length > 0) {
|
||||
setEditDimensions([...existingPlan.dimensions]);
|
||||
} else {
|
||||
@@ -213,14 +218,14 @@ export default function PoolWizard({
|
||||
}
|
||||
setDimensionsEdited(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectionId]);
|
||||
}, [primaryConnectionId]);
|
||||
|
||||
// ── Reset wizard on open/close ────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setStep(1);
|
||||
setConnectionId("");
|
||||
setConnectionIds([]);
|
||||
setPoolName("");
|
||||
setDefaultPolicy("hard");
|
||||
setEditDimensions([]);
|
||||
@@ -296,21 +301,35 @@ export default function PoolWizard({
|
||||
|
||||
// ── Preview model names ───────────────────────────────────────────────────
|
||||
|
||||
const previewNames = useMemo(() => {
|
||||
if (!selectedConn || !poolName.trim()) return [];
|
||||
const models = getPreviewModels(selectedConn.provider);
|
||||
const MAX = 6;
|
||||
return models.slice(0, MAX).map((m) =>
|
||||
quotaModelName(poolName.trim(), selectedConn.provider, m)
|
||||
);
|
||||
}, [selectedConn, poolName]);
|
||||
// Per-provider preview: { provider, names[], totalModels }
|
||||
const previewByProvider = useMemo(() => {
|
||||
const name = poolName.trim();
|
||||
if (connectionIds.length === 0 || !name) return [];
|
||||
|
||||
const MAX_PER_PROVIDER = 3;
|
||||
return connectionIds.map((cid) => {
|
||||
const conn = connections.find((c) => c.id === cid);
|
||||
if (!conn) return null;
|
||||
const allModels = getPreviewModels(conn.provider);
|
||||
const names = allModels.slice(0, MAX_PER_PROVIDER).map((m) =>
|
||||
quotaModelName(name, conn.provider, m)
|
||||
);
|
||||
return { provider: conn.provider, names, totalModels: allModels.length };
|
||||
}).filter(Boolean) as Array<{ provider: string; names: string[]; totalModels: number }>;
|
||||
}, [connectionIds, connections, poolName]);
|
||||
|
||||
// Flat list (for legacy single-provider path, kept for step-3 rendering simplicity)
|
||||
const previewNames = useMemo(
|
||||
() => previewByProvider.flatMap((p) => p.names),
|
||||
[previewByProvider]
|
||||
);
|
||||
|
||||
const effectivePoolName = poolName.trim() || (selectedConn ? connLabel(selectedConn) : "");
|
||||
|
||||
// ── Save sequence ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!selectedConn) return;
|
||||
if (!selectedConn || connectionIds.length === 0) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
@@ -320,7 +339,8 @@ export default function PoolWizard({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
connectionId,
|
||||
connectionId: primaryConnectionId,
|
||||
connectionIds,
|
||||
name: effectivePoolName,
|
||||
allocations: [],
|
||||
}),
|
||||
@@ -334,9 +354,9 @@ export default function PoolWizard({
|
||||
const createData = (await createRes.json()) as { pool: { id: string } };
|
||||
const newPoolId = createData.pool.id;
|
||||
|
||||
// 2. PUT /api/quota/plans/[connectionId] — only when user edited dimensions
|
||||
// 2. PUT /api/quota/plans/[primaryConnectionId] — only when user edited dimensions
|
||||
if (dimensionsEdited && editDimensions.length > 0) {
|
||||
const planRes = await fetch(`/api/quota/plans/${connectionId}`, {
|
||||
const planRes = await fetch(`/api/quota/plans/${primaryConnectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dimensions: editDimensions }),
|
||||
@@ -390,40 +410,72 @@ export default function PoolWizard({
|
||||
<p className="text-[11px] text-text-muted">{t("wizardStep1Subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Connection selector */}
|
||||
{/* Connection multi-select (checkboxes) */}
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("providerConnection")}
|
||||
{t("wizardConnectionsLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={connectionId}
|
||||
onChange={(e) => {
|
||||
setConnectionId(e.target.value);
|
||||
setPoolName("");
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
>
|
||||
<option value="">{t("selectConnection")}</option>
|
||||
{availableConnections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{connLabel(c)}
|
||||
</option>
|
||||
))}
|
||||
<div className="space-y-1.5 rounded border border-border bg-bg-base px-3 py-2 max-h-48 overflow-y-auto">
|
||||
{availableConnections.map((c) => {
|
||||
const checked = connectionIds.includes(c.id);
|
||||
const isPrimary = connectionIds[0] === c.id;
|
||||
return (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 cursor-pointer select-none py-0.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setConnectionIds((prev) => {
|
||||
if (prev.includes(c.id)) {
|
||||
const next = prev.filter((id) => id !== c.id);
|
||||
if (next.length === 0) setPoolName("");
|
||||
return next;
|
||||
} else {
|
||||
return [...prev, c.id];
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="accent-primary w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<span className="text-sm truncate">{connLabel(c)}</span>
|
||||
{isPrimary && (
|
||||
<span className="text-[10px] font-semibold text-primary bg-primary/10 px-1.5 py-0.5 rounded shrink-0">
|
||||
{t("wizardPrimaryBadge")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{connections
|
||||
.filter((c) => existingPoolConnectionIds.has(c.id))
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id} disabled>
|
||||
{connLabel(c)} {t("alreadyUsedSuffix")}
|
||||
</option>
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 cursor-not-allowed select-none py-0.5 opacity-40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
checked={false}
|
||||
readOnly
|
||||
className="w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{connLabel(c)} {t("alreadyUsedSuffix")}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{connections.length === 0 && (
|
||||
<p className="text-[10px] text-amber-400 mt-1">{t("noEligibleConnections")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pool name */}
|
||||
{connectionId && (
|
||||
{connectionIds.length > 0 && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("wizardPoolNameLabel")}
|
||||
@@ -439,7 +491,7 @@ export default function PoolWizard({
|
||||
)}
|
||||
|
||||
{/* Default policy */}
|
||||
{connectionId && (
|
||||
{connectionIds.length > 0 && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("policyLabel")}
|
||||
@@ -466,7 +518,7 @@ export default function PoolWizard({
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!connectionId}
|
||||
disabled={connectionIds.length === 0}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("wizardNext")}
|
||||
@@ -563,6 +615,13 @@ export default function PoolWizard({
|
||||
<p className="text-[10px] text-amber-400">{t("wizardDimensionsEditedNotice")}</p>
|
||||
)}
|
||||
|
||||
{/* Helper note when pool has multiple connections */}
|
||||
{connectionIds.length > 1 && (
|
||||
<p className="text-[10px] text-text-muted bg-bg-subtle/40 px-3 py-2 rounded border border-border/40">
|
||||
{t("wizardAdditionalConnectionsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
@@ -715,18 +774,37 @@ export default function PoolWizard({
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* quotaModelName preview */}
|
||||
{previewNames.length > 0 && (
|
||||
{/* quotaModelName preview — grouped by provider */}
|
||||
{previewByProvider.length > 0 && (
|
||||
<div className="rounded-md border border-border/40 bg-bg-subtle/30 p-3 text-[11px]">
|
||||
<div className="font-semibold text-text-muted uppercase tracking-wide mb-1.5 text-[10px]">
|
||||
{t("wizardPreviewLabel")}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{previewNames.map((name) => (
|
||||
<div key={name} className="font-mono text-text-main truncate">
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
{previewByProvider.map(({ provider, names, totalModels }) => {
|
||||
const extra = totalModels - names.length;
|
||||
return (
|
||||
<div key={provider}>
|
||||
{previewByProvider.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold mb-0.5">
|
||||
{provider}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{names.map((name) => (
|
||||
<div key={name} className="font-mono text-text-main truncate">
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<div className="text-text-muted italic">
|
||||
{t("wizardPreviewMoreModels", { count: extra })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7885,7 +7885,11 @@
|
||||
"wizardDimensionsEditedNotice": "Dimensions edited — will be saved as a manual override when the pool is created.",
|
||||
"wizardExclusiveLabel": "Exclusive quota",
|
||||
"wizardExclusiveHint": "When enabled, these API keys will only be allowed to use this pool's virtual models (allowedQuotas reconciliation is applied on save).",
|
||||
"wizardPreviewLabel": "Virtual model names preview"
|
||||
"wizardPreviewLabel": "Virtual model names preview",
|
||||
"wizardConnectionsLabel": "Provider connections",
|
||||
"wizardPrimaryBadge": "primary",
|
||||
"wizardAdditionalConnectionsNote": "Additional connections use their catalog default limits (editable later).",
|
||||
"wizardPreviewMoreModels": "+{count} more"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
|
||||
@@ -5395,7 +5395,11 @@
|
||||
"wizardDimensionsEditedNotice": "Dimensões editadas — serão salvas como override manual quando o pool for criado.",
|
||||
"wizardExclusiveLabel": "Cota exclusiva",
|
||||
"wizardExclusiveHint": "Quando ativado, estas API keys só poderão usar os modelos virtuais deste pool (reconciliação de allowedQuotas aplicada ao salvar).",
|
||||
"wizardPreviewLabel": "Prévia dos nomes de modelos virtuais"
|
||||
"wizardPreviewLabel": "Prévia dos nomes de modelos virtuais",
|
||||
"wizardConnectionsLabel": "Conexões de provider",
|
||||
"wizardPrimaryBadge": "principal",
|
||||
"wizardAdditionalConnectionsNote": "Conexões adicionais usam os limites padrão do catálogo (editáveis posteriormente).",
|
||||
"wizardPreviewMoreModels": "+{count} mais"
|
||||
},
|
||||
"requestLogger": {
|
||||
"recording": "Recording",
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { PoolAllocationSchema, QuotaDimensionSchema } from "@/lib/quota/dimensions";
|
||||
|
||||
export const PoolCreateSchema = z.object({
|
||||
connectionId: z.string().min(1),
|
||||
name: z.string().min(1).max(120),
|
||||
allocations: z.array(PoolAllocationSchema).default([]),
|
||||
});
|
||||
export const PoolCreateSchema = z
|
||||
.object({
|
||||
connectionId: z.string().min(1),
|
||||
connectionIds: z.array(z.string().min(1)).min(1).optional(),
|
||||
name: z.string().min(1).max(120),
|
||||
allocations: z.array(PoolAllocationSchema).default([]),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.connectionIds === undefined) return true;
|
||||
return data.connectionIds.includes(data.connectionId);
|
||||
},
|
||||
{ message: "primary connectionId must be one of connectionIds" }
|
||||
);
|
||||
export type PoolCreate = z.infer<typeof PoolCreateSchema>;
|
||||
|
||||
export const PoolUpdateSchema = z.object({
|
||||
|
||||
209
tests/unit/quota-pool-wizard-multi.test.ts
Normal file
209
tests/unit/quota-pool-wizard-multi.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Phase D3 — Multi-connection pool wizard tests.
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. PoolCreateSchema accepts/rejects connectionIds combinations
|
||||
* 2. Structural assertions on PoolWizard.tsx (multi-select, POST body, step-3 preview)
|
||||
* 3. i18n parity: new wizard keys exist in both locales
|
||||
*
|
||||
* Node native test runner — no JSdom needed (pure schema + source analysis).
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
import { PoolCreateSchema } from "../../src/shared/schemas/quota.js";
|
||||
|
||||
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_PATH = path.join(ROOT, "src", "i18n", "messages", "en.json");
|
||||
const PT_PATH = path.join(ROOT, "src", "i18n", "messages", "pt-BR.json");
|
||||
|
||||
const wizardSrc = fs.readFileSync(WIZARD_PATH, "utf-8");
|
||||
const en = JSON.parse(fs.readFileSync(EN_PATH, "utf-8")) as { quotaShare: Record<string, string> };
|
||||
const pt = JSON.parse(fs.readFileSync(PT_PATH, "utf-8")) as { quotaShare: Record<string, string> };
|
||||
|
||||
// ── PoolCreateSchema: connectionIds field ─────────────────────────────────────
|
||||
|
||||
test("PoolCreateSchema accepts multi-connection input when primary is a member", () => {
|
||||
const result = PoolCreateSchema.safeParse({
|
||||
connectionId: "a",
|
||||
connectionIds: ["a", "b"],
|
||||
name: "x",
|
||||
allocations: [],
|
||||
});
|
||||
assert.ok(result.success, `Expected success, got: ${result.error?.message}`);
|
||||
if (result.success) {
|
||||
assert.deepEqual(result.data.connectionIds, ["a", "b"]);
|
||||
assert.equal(result.data.connectionId, "a");
|
||||
}
|
||||
});
|
||||
|
||||
test("PoolCreateSchema rejects when primary connectionId is NOT in connectionIds", () => {
|
||||
const result = PoolCreateSchema.safeParse({
|
||||
connectionId: "z",
|
||||
connectionIds: ["a", "b"],
|
||||
name: "x",
|
||||
allocations: [],
|
||||
});
|
||||
assert.equal(result.success, false, "Expected refine to reject when primary not in connectionIds");
|
||||
const msg = result.error?.issues[0]?.message ?? "";
|
||||
assert.ok(
|
||||
msg.includes("primary connectionId must be one of connectionIds"),
|
||||
`Expected refine message, got: "${msg}"`
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolCreateSchema accepts single-connection input without connectionIds (back-compat)", () => {
|
||||
const result = PoolCreateSchema.safeParse({ connectionId: "c", name: "Pool" });
|
||||
assert.ok(result.success, `Expected success, got: ${result.error?.message}`);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.connectionIds, undefined);
|
||||
assert.equal(result.data.connectionId, "c");
|
||||
}
|
||||
});
|
||||
|
||||
test("PoolCreateSchema rejects empty connectionIds array", () => {
|
||||
const result = PoolCreateSchema.safeParse({
|
||||
connectionId: "a",
|
||||
connectionIds: [],
|
||||
name: "x",
|
||||
allocations: [],
|
||||
});
|
||||
assert.equal(result.success, false, "Expected failure for empty connectionIds");
|
||||
});
|
||||
|
||||
test("PoolCreateSchema accepts connectionIds with single element matching connectionId", () => {
|
||||
const result = PoolCreateSchema.safeParse({
|
||||
connectionId: "solo",
|
||||
connectionIds: ["solo"],
|
||||
name: "solo pool",
|
||||
allocations: [],
|
||||
});
|
||||
assert.ok(result.success, `Expected success, got: ${result.error?.message}`);
|
||||
});
|
||||
|
||||
// ── PoolWizard.tsx structural assertions ──────────────────────────────────────
|
||||
|
||||
test("PoolWizard.tsx: connectionIds state is defined (multi-select)", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("connectionIds"),
|
||||
"Expected connectionIds state in PoolWizard"
|
||||
);
|
||||
assert.ok(
|
||||
wizardSrc.includes("useState<string[]>([])"),
|
||||
"Expected connectionIds initialized as string[] state"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: renders checkboxes for connection selection in step 1", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes('type="checkbox"'),
|
||||
"Expected checkbox inputs in step 1 for multi-connection selection"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: primaryConnectionId is derived from connectionIds[0]", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("primaryConnectionId = connectionIds[0]"),
|
||||
"Expected primaryConnectionId derived from connectionIds[0]"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: POST body sends both connectionId and connectionIds", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("connectionId: primaryConnectionId"),
|
||||
"Expected connectionId: primaryConnectionId in POST body"
|
||||
);
|
||||
assert.ok(
|
||||
wizardSrc.includes("connectionIds,"),
|
||||
"Expected connectionIds spread in POST body"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: step-3 preview maps over connectionIds (previewByProvider)", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("previewByProvider"),
|
||||
"Expected previewByProvider useMemo in PoolWizard"
|
||||
);
|
||||
assert.ok(
|
||||
wizardSrc.includes("connectionIds.map((cid)"),
|
||||
"Expected connectionIds.map to build per-provider preview"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: step-2 shows additional connections note when multiple selected", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("wizardAdditionalConnectionsNote"),
|
||||
"Expected wizardAdditionalConnectionsNote i18n key in step 2"
|
||||
);
|
||||
assert.ok(
|
||||
wizardSrc.includes("connectionIds.length > 1"),
|
||||
"Expected guard connectionIds.length > 1 for the note"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: primary badge rendered for first selected connection", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("wizardPrimaryBadge"),
|
||||
"Expected wizardPrimaryBadge i18n key in step 1 checkbox list"
|
||||
);
|
||||
});
|
||||
|
||||
// ── i18n parity: new wizard keys ─────────────────────────────────────────────
|
||||
|
||||
const NEW_KEYS = [
|
||||
"wizardConnectionsLabel",
|
||||
"wizardPrimaryBadge",
|
||||
"wizardAdditionalConnectionsNote",
|
||||
"wizardPreviewMoreModels",
|
||||
];
|
||||
|
||||
for (const key of NEW_KEYS) {
|
||||
test(`i18n en.json has key quotaShare.${key}`, () => {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(en.quotaShare, key),
|
||||
`en.json missing quotaShare.${key}`
|
||||
);
|
||||
assert.equal(typeof en.quotaShare[key], "string", `quotaShare.${key} must be a string in en.json`);
|
||||
assert.ok(en.quotaShare[key].length > 0, `quotaShare.${key} must not be empty in en.json`);
|
||||
});
|
||||
|
||||
test(`i18n pt-BR.json has key quotaShare.${key}`, () => {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(pt.quotaShare, key),
|
||||
`pt-BR.json missing quotaShare.${key}`
|
||||
);
|
||||
assert.equal(typeof pt.quotaShare[key], "string", `quotaShare.${key} must be a string in pt-BR.json`);
|
||||
assert.ok(pt.quotaShare[key].length > 0, `quotaShare.${key} must not be empty in pt-BR.json`);
|
||||
});
|
||||
}
|
||||
|
||||
test("i18n parity: all quotaShare.wizard* keys are in sync between en and pt-BR", () => {
|
||||
const enWizardKeys = Object.keys(en.quotaShare)
|
||||
.filter((k) => k.startsWith("wizard"))
|
||||
.sort();
|
||||
const ptWizardKeys = Object.keys(pt.quotaShare)
|
||||
.filter((k) => k.startsWith("wizard"))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
enWizardKeys,
|
||||
ptWizardKeys,
|
||||
`wizard* key parity mismatch.\nen: ${JSON.stringify(enWizardKeys)}\npt: ${JSON.stringify(ptWizardKeys)}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user