mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
feat(quota): group selector + grouped pool cards + wizard group pick (Interface A)
Task B9: QuotaSharePageClient fetches /api/quota/groups, renders a group bar (select + New group + Rename group actions), and filters pool cards by the selected group. PoolWizard adds a group picker in step 1 and POSTs groupId; default pool name now uses provider slug instead of the raw connection label/email. EditAllocationsModal adds a group allocation note. i18n parity for 7 new quotaShare keys in en + pt-BR. PoolCreateSchema gains optional groupId field. Test: tests/unit/quota-groups-ui.test.ts (21 source-scan assertions, all green).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
|
||||
@@ -21,6 +21,12 @@ import EditAllocationsModal from "./components/EditAllocationsModal";
|
||||
// Local types (display layer only)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface QuotaGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
provider: string;
|
||||
@@ -134,6 +140,13 @@ export default function QuotaSharePageClient() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<QuotaPool | null>(null);
|
||||
|
||||
// ── Group state ───────────────────────────────────────────────────────────
|
||||
const [groups, setGroups] = useState<QuotaGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("group-demo");
|
||||
const [newGroupInput, setNewGroupInput] = useState("");
|
||||
const [showNewGroupInput, setShowNewGroupInput] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
// ── Fetch side data once on mount ─────────────────────────────────────────
|
||||
|
||||
useMemo(() => {
|
||||
@@ -178,6 +191,66 @@ export default function QuotaSharePageClient() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Fetch groups ──────────────────────────────────────────────────────────
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/quota/groups");
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { groups: QuotaGroup[] };
|
||||
setGroups(Array.isArray(data.groups) ? data.groups : []);
|
||||
}
|
||||
} catch {
|
||||
// fail open — groups list not critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
// ── Group actions ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleCreateGroup = useCallback(async () => {
|
||||
const name = newGroupInput.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await fetch("/api/quota/groups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { group: QuotaGroup };
|
||||
await fetchGroups();
|
||||
setSelectedGroupId(data.group.id);
|
||||
}
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
setNewGroupInput("");
|
||||
setShowNewGroupInput(false);
|
||||
}, [newGroupInput, fetchGroups]);
|
||||
|
||||
const handleRenameGroup = useCallback(async () => {
|
||||
const name = prompt(t("groupNamePrompt"), groups.find((g) => g.id === selectedGroupId)?.name ?? "");
|
||||
if (!name?.trim()) return;
|
||||
setRenaming(true);
|
||||
try {
|
||||
const res = await fetch(`/api/quota/groups/${selectedGroupId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchGroups();
|
||||
}
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
setRenaming(false);
|
||||
}, [selectedGroupId, groups, fetchGroups, t]);
|
||||
|
||||
// ── Derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
const keyLabels = useMemo(() => {
|
||||
@@ -213,6 +286,12 @@ export default function QuotaSharePageClient() {
|
||||
[pools, aggregate]
|
||||
);
|
||||
|
||||
// Pools filtered by selected group
|
||||
const filteredPools = useMemo(
|
||||
() => pools.filter((p) => (p as unknown as { groupId?: string }).groupId === selectedGroupId || (!( p as unknown as { groupId?: string }).groupId && selectedGroupId === "group-demo")),
|
||||
[pools, selectedGroupId]
|
||||
);
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveAllocations = useCallback(
|
||||
@@ -258,6 +337,76 @@ export default function QuotaSharePageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group bar */}
|
||||
<div className="flex items-center gap-2 flex-wrap rounded-lg border border-border/40 bg-bg-subtle/20 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wide text-text-muted font-semibold shrink-0">
|
||||
{t("groupLabel")}
|
||||
</span>
|
||||
<select
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
title={t("groupSelectHint")}
|
||||
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-text-main min-w-[120px]"
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showNewGroupInput ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupInput}
|
||||
onChange={(e) => setNewGroupInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreateGroup();
|
||||
if (e.key === "Escape") { setShowNewGroupInput(false); setNewGroupInput(""); }
|
||||
}}
|
||||
placeholder={t("groupNamePrompt")}
|
||||
autoFocus
|
||||
className="px-2 py-1 rounded border border-border bg-bg-base text-sm w-36"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateGroup()}
|
||||
disabled={!newGroupInput.trim()}
|
||||
className="text-xs px-2 py-1 rounded bg-primary/15 text-primary hover:bg-primary/25 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t("newGroup")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowNewGroupInput(false); setNewGroupInput(""); }}
|
||||
className="text-xs px-2 py-1 rounded border border-border text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewGroupInput(true)}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
{t("newGroup")}
|
||||
</button>
|
||||
)}
|
||||
{selectedGroupId !== "group-demo" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRenameGroup()}
|
||||
disabled={renaming}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-main transition-colors ml-1 disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">edit</span>
|
||||
{t("renameGroup")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Concept card */}
|
||||
<QuotaConceptCard />
|
||||
|
||||
@@ -293,21 +442,45 @@ export default function QuotaSharePageClient() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{pools.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>
|
||||
<>
|
||||
{/* 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 ? (
|
||||
<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)}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
|
||||
{t("newPool")}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
@@ -319,6 +492,8 @@ export default function QuotaSharePageClient() {
|
||||
apiKeys={apiKeys}
|
||||
plans={plans}
|
||||
existingPoolConnectionIds={new Set(pools.map((p) => p.connectionId))}
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
/>
|
||||
|
||||
{editing && (
|
||||
|
||||
@@ -113,6 +113,12 @@ export default function EditAllocationsModal({
|
||||
{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")}
|
||||
|
||||
@@ -48,6 +48,12 @@ interface PlanInfo {
|
||||
source: "auto" | "manual";
|
||||
}
|
||||
|
||||
interface QuotaGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PoolWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -56,6 +62,8 @@ export interface PoolWizardProps {
|
||||
apiKeys: ApiKey[];
|
||||
plans: Record<string, PlanInfo>;
|
||||
existingPoolConnectionIds: Set<string>;
|
||||
groups?: QuotaGroup[];
|
||||
selectedGroupId?: string;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -159,6 +167,8 @@ export default function PoolWizard({
|
||||
apiKeys,
|
||||
plans,
|
||||
existingPoolConnectionIds,
|
||||
groups = [],
|
||||
selectedGroupId: initialGroupId = "group-demo",
|
||||
}: PoolWizardProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
const tPlans = useTranslations("quotaPlans");
|
||||
@@ -181,6 +191,9 @@ export default function PoolWizard({
|
||||
const [allocations, setAllocations] = useState<PoolAllocation[]>([]);
|
||||
const [exclusive, setExclusive] = useState(false);
|
||||
|
||||
// ── Group selection ───────────────────────────────────────────────────────
|
||||
const [groupId, setGroupId] = useState<string>(initialGroupId);
|
||||
|
||||
// ── Saving ────────────────────────────────────────────────────────────────
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -252,8 +265,9 @@ export default function PoolWizard({
|
||||
setExclusive(false);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setGroupId(initialGroupId);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, initialGroupId]);
|
||||
|
||||
// ── Step 2 — dimension editors ────────────────────────────────────────────
|
||||
|
||||
@@ -349,7 +363,8 @@ export default function PoolWizard({
|
||||
[previewByProvider]
|
||||
);
|
||||
|
||||
const effectivePoolName = poolName.trim() || (selectedConn ? connLabel(selectedConn) : "");
|
||||
// Default pool name uses provider slug — NOT the raw connection label/email.
|
||||
const effectivePoolName = poolName.trim() || (selectedConn ? selectedConn.provider : "");
|
||||
|
||||
// ── Save sequence ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -368,6 +383,7 @@ export default function PoolWizard({
|
||||
connectionIds,
|
||||
name: effectivePoolName,
|
||||
allocations: [],
|
||||
groupId,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
@@ -514,12 +530,32 @@ export default function PoolWizard({
|
||||
type="text"
|
||||
value={poolName}
|
||||
onChange={(e) => setPoolName(e.target.value)}
|
||||
placeholder={selectedConn ? connLabel(selectedConn) : t("wizardPoolNamePlaceholder")}
|
||||
placeholder={selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group picker */}
|
||||
{connectionIds.length > 0 && groups.length > 0 && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("wizardGroupLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default policy */}
|
||||
{connectionIds.length > 0 && (
|
||||
<div>
|
||||
|
||||
@@ -7898,7 +7898,14 @@
|
||||
"accountQuotaTitle": "Account quota",
|
||||
"accountQuotaNone": "—",
|
||||
"logTitle": "Usage log",
|
||||
"logEmpty": "No usage yet"
|
||||
"logEmpty": "No usage yet",
|
||||
"groupLabel": "Group",
|
||||
"newGroup": "New group",
|
||||
"renameGroup": "Rename group",
|
||||
"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"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
|
||||
@@ -5408,7 +5408,14 @@
|
||||
"accountQuotaTitle": "Cota da conta",
|
||||
"accountQuotaNone": "—",
|
||||
"logTitle": "Log de uso",
|
||||
"logEmpty": "Sem uso ainda"
|
||||
"logEmpty": "Sem uso ainda",
|
||||
"groupLabel": "Grupo",
|
||||
"newGroup": "Novo grupo",
|
||||
"renameGroup": "Renomear grupo",
|
||||
"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"
|
||||
},
|
||||
"requestLogger": {
|
||||
"recording": "Recording",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const PoolCreateSchema = z
|
||||
connectionIds: z.array(z.string().min(1)).min(1).optional(),
|
||||
name: z.string().min(1).max(120),
|
||||
allocations: z.array(PoolAllocationSchema).default([]),
|
||||
groupId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
||||
297
tests/unit/quota-groups-ui.test.ts
Normal file
297
tests/unit/quota-groups-ui.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* tests/unit/quota-groups-ui.test.ts
|
||||
*
|
||||
* Task B9 — source-level assertions for the group selector UI,
|
||||
* group-aware PoolWizard, and EditAllocationsModal group note.
|
||||
*
|
||||
* Pattern mirrors:
|
||||
* tests/unit/quota-pool-log-route.test.ts (source-scan + i18n parity)
|
||||
* tests/unit/quota-pool-wizard.test.ts (source-scan structural)
|
||||
*
|
||||
* 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 WIZARD_PATH = join(
|
||||
ROOT,
|
||||
"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 ─────────────────────────────────────────
|
||||
|
||||
test("QuotaSharePageClient: fetches /api/quota/groups", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("/api/quota/groups"),
|
||||
"QuotaSharePageClient must fetch /api/quota/groups"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: has a fetch for GET /api/quota/groups (list)", () => {
|
||||
// The fetchGroups function does a plain GET fetch
|
||||
const fetchGroupsIdx = pageSrc.indexOf("fetchGroups");
|
||||
assert.ok(fetchGroupsIdx >= 0, "fetchGroups function must exist");
|
||||
// The function body must call fetch('/api/quota/groups')
|
||||
const afterFetch = pageSrc.slice(fetchGroupsIdx);
|
||||
assert.ok(
|
||||
afterFetch.includes('"/api/quota/groups"') || afterFetch.includes("'/api/quota/groups'"),
|
||||
"fetchGroups must call fetch with /api/quota/groups"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: has a POST to /api/quota/groups for group creation", () => {
|
||||
// handleCreateGroup does POST /api/quota/groups
|
||||
const createGroupIdx = pageSrc.indexOf("handleCreateGroup");
|
||||
assert.ok(createGroupIdx >= 0, "handleCreateGroup must be defined");
|
||||
const afterCreate = pageSrc.slice(createGroupIdx, createGroupIdx + 800);
|
||||
assert.ok(
|
||||
afterCreate.includes('method: "POST"') || afterCreate.includes("method: 'POST'"),
|
||||
"handleCreateGroup must use POST"
|
||||
);
|
||||
assert.ok(
|
||||
afterCreate.includes("/api/quota/groups"),
|
||||
"handleCreateGroup must POST to /api/quota/groups"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: has a PATCH to /api/quota/groups/[id] for rename", () => {
|
||||
// handleRenameGroup does PATCH /api/quota/groups/${selectedGroupId}
|
||||
const renameIdx = pageSrc.indexOf("handleRenameGroup");
|
||||
assert.ok(renameIdx >= 0, "handleRenameGroup must be defined");
|
||||
const afterRename = pageSrc.slice(renameIdx, renameIdx + 800);
|
||||
assert.ok(
|
||||
afterRename.includes('method: "PATCH"') || afterRename.includes("method: 'PATCH'"),
|
||||
"handleRenameGroup must use PATCH"
|
||||
);
|
||||
assert.ok(
|
||||
afterRename.includes("/api/quota/groups/"),
|
||||
"handleRenameGroup must PATCH /api/quota/groups/[id]"
|
||||
);
|
||||
});
|
||||
|
||||
// ── QuotaSharePageClient: group select in header ──────────────────────────────
|
||||
|
||||
test("QuotaSharePageClient: renders a group <select>", () => {
|
||||
// The group bar must include a <select> with value bound to selectedGroupId
|
||||
assert.ok(
|
||||
pageSrc.includes("selectedGroupId"),
|
||||
"QuotaSharePageClient must track selectedGroupId state"
|
||||
);
|
||||
assert.ok(
|
||||
pageSrc.includes("<select") && pageSrc.includes("selectedGroupId"),
|
||||
"QuotaSharePageClient must render a <select> for groups"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: group bar renders 'New group' action", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("newGroup") || pageSrc.includes("showNewGroupInput"),
|
||||
"QuotaSharePageClient must render a New group action (uses newGroup i18n key or showNewGroupInput state)"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: group bar renders 'Rename group' action", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("renameGroup"),
|
||||
"QuotaSharePageClient must render a Rename group action using renameGroup i18n key"
|
||||
);
|
||||
});
|
||||
|
||||
// ── QuotaSharePageClient: pool list filtered by group ────────────────────────
|
||||
|
||||
test("QuotaSharePageClient: filters pool list by selectedGroupId (filteredPools)", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("filteredPools"),
|
||||
"QuotaSharePageClient must derive filteredPools from the group selection"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: renders group heading above pool grid", () => {
|
||||
// The group heading shows the group name and pool count
|
||||
assert.ok(
|
||||
pageSrc.includes("filteredPools.length"),
|
||||
"QuotaSharePageClient must show filteredPools count in the group heading"
|
||||
);
|
||||
});
|
||||
|
||||
// ── QuotaSharePageClient: passes groups/selectedGroupId to PoolWizard ─────────
|
||||
|
||||
test("QuotaSharePageClient: passes groups prop to PoolWizard", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("groups={groups}"),
|
||||
"QuotaSharePageClient must pass groups={groups} to PoolWizard"
|
||||
);
|
||||
});
|
||||
|
||||
test("QuotaSharePageClient: passes selectedGroupId prop to PoolWizard", () => {
|
||||
assert.ok(
|
||||
pageSrc.includes("selectedGroupId={selectedGroupId}"),
|
||||
"QuotaSharePageClient must pass selectedGroupId={selectedGroupId} to PoolWizard"
|
||||
);
|
||||
});
|
||||
|
||||
// ── PoolWizard: groupId in POST body ──────────────────────────────────────────
|
||||
|
||||
test("PoolWizard: includes groupId in the POST /api/quota/pools body", () => {
|
||||
// The POST body JSON must include groupId
|
||||
assert.ok(
|
||||
wizardSrc.includes("groupId"),
|
||||
"PoolWizard must include groupId in the create POST body"
|
||||
);
|
||||
// Find the POST body JSON.stringify call
|
||||
const postIdx = wizardSrc.indexOf('method: "POST"');
|
||||
assert.ok(postIdx >= 0, "POST method call must exist in PoolWizard");
|
||||
const postSection = wizardSrc.slice(postIdx, postIdx + 400);
|
||||
assert.ok(
|
||||
postSection.includes("groupId"),
|
||||
"groupId must be in the POST body JSON.stringify"
|
||||
);
|
||||
});
|
||||
|
||||
// ── PoolWizard: group picker UI in step 1 ────────────────────────────────────
|
||||
|
||||
test("PoolWizard: accepts groups prop in PoolWizardProps", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("groups?:") || wizardSrc.includes("groups ?: ") || wizardSrc.includes("groups?: "),
|
||||
"PoolWizardProps must include optional groups prop"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard: renders a group picker <select> in step 1", () => {
|
||||
// The group picker is in step 1 (between step === 1 and step === 2 blocks)
|
||||
const step1Idx = wizardSrc.indexOf("step === 1");
|
||||
const step2Idx = wizardSrc.indexOf("step === 2");
|
||||
assert.ok(step1Idx >= 0, "step === 1 block must exist");
|
||||
assert.ok(step2Idx > step1Idx, "step === 2 block must come after step === 1");
|
||||
const step1Block = wizardSrc.slice(step1Idx, step2Idx);
|
||||
assert.ok(
|
||||
step1Block.includes("groupId") && step1Block.includes("<select"),
|
||||
"step 1 must include a group picker <select> with groupId binding"
|
||||
);
|
||||
});
|
||||
|
||||
// ── PoolWizard: default pool name does NOT use raw connection label/email ──────
|
||||
|
||||
test("PoolWizard: effectivePoolName uses provider slug, not connLabel", () => {
|
||||
// Before fix: effectivePoolName = poolName.trim() || connLabel(selectedConn)
|
||||
// After fix: effectivePoolName = poolName.trim() || selectedConn.provider
|
||||
// The old connLabel call in effectivePoolName must be gone
|
||||
const effectiveIdx = wizardSrc.indexOf("effectivePoolName");
|
||||
assert.ok(effectiveIdx >= 0, "effectivePoolName must be defined");
|
||||
const effectiveLine = wizardSrc.slice(effectiveIdx, effectiveIdx + 200);
|
||||
assert.ok(
|
||||
!effectiveLine.includes("connLabel(selectedConn)"),
|
||||
"effectivePoolName must NOT use connLabel(selectedConn) — that exposes email"
|
||||
);
|
||||
assert.ok(
|
||||
effectiveLine.includes("selectedConn.provider") || effectiveLine.includes(".provider"),
|
||||
"effectivePoolName must fall back to provider slug"
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard: pool name placeholder uses provider, not connLabel", () => {
|
||||
// placeholder={selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")}
|
||||
const placeholderIdx = wizardSrc.indexOf("placeholder={");
|
||||
assert.ok(placeholderIdx >= 0, "placeholder attribute must exist in pool name input");
|
||||
// Find ALL placeholder occurrences within the pool name input area (step 1)
|
||||
let idx = 0;
|
||||
let foundProviderPlaceholder = false;
|
||||
while (true) {
|
||||
const pos = wizardSrc.indexOf("placeholder={", idx);
|
||||
if (pos < 0) break;
|
||||
const chunk = wizardSrc.slice(pos, pos + 120);
|
||||
if (chunk.includes(".provider") || chunk.includes("selectedConn?.provider")) {
|
||||
foundProviderPlaceholder = true;
|
||||
break;
|
||||
}
|
||||
idx = pos + 1;
|
||||
}
|
||||
assert.ok(
|
||||
foundProviderPlaceholder,
|
||||
"Pool name input placeholder must use selectedConn.provider (not connLabel/email)"
|
||||
);
|
||||
});
|
||||
|
||||
// ── EditAllocationsModal: group allocation note ────────────────────────────────
|
||||
|
||||
test("EditAllocationsModal: renders groupAllocationNote i18n key", () => {
|
||||
assert.ok(
|
||||
modalSrc.includes("groupAllocationNote"),
|
||||
"EditAllocationsModal must render t('groupAllocationNote') helper text"
|
||||
);
|
||||
});
|
||||
|
||||
// ── i18n parity ───────────────────────────────────────────────────────────────
|
||||
|
||||
const GROUP_KEYS = [
|
||||
"groupLabel",
|
||||
"newGroup",
|
||||
"renameGroup",
|
||||
"groupSelectHint",
|
||||
"groupAllocationNote",
|
||||
"groupNamePrompt",
|
||||
"wizardGroupLabel",
|
||||
] as const;
|
||||
|
||||
test("i18n: all group 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 GROUP_KEYS) {
|
||||
assert.equal(
|
||||
typeof en["quotaShare"]?.[k],
|
||||
"string",
|
||||
`en.json missing quotaShare.${k}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("i18n: all group 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 GROUP_KEYS) {
|
||||
assert.equal(
|
||||
typeof pt["quotaShare"]?.[k],
|
||||
"string",
|
||||
`pt-BR.json missing quotaShare.${k}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("i18n: parity — en and pt-BR have exactly the same group 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 GROUP_KEYS) {
|
||||
assert.ok(k in (en["quotaShare"] ?? {}), `en.json missing quotaShare.${k}`);
|
||||
assert.ok(k in (pt["quotaShare"] ?? {}), `pt-BR.json missing quotaShare.${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("i18n: no group keys are present in en but missing from pt-BR (full parity)", () => {
|
||||
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>>;
|
||||
const enKeys = new Set(Object.keys(en["quotaShare"] ?? {}));
|
||||
const ptKeys = new Set(Object.keys(pt["quotaShare"] ?? {}));
|
||||
const missingInPt = GROUP_KEYS.filter((k) => enKeys.has(k) && !ptKeys.has(k));
|
||||
assert.deepEqual(missingInPt, [], `pt-BR.json missing quotaShare keys: ${missingInPt.join(", ")}`);
|
||||
});
|
||||
Reference in New Issue
Block a user