From e6946748511c5fbab1b27825d14516a842660450 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 1 Jun 2026 02:28:21 -0300 Subject: [PATCH 01/10] feat(quota): pool PATCH accepts groupId + connectionIds, re-syncs combos on connection change --- src/app/api/quota/pools/[id]/route.ts | 13 ++ src/shared/schemas/quota.ts | 2 + tests/unit/quota-pool-update-full.test.ts | 250 ++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 tests/unit/quota-pool-update-full.test.ts diff --git a/src/app/api/quota/pools/[id]/route.ts b/src/app/api/quota/pools/[id]/route.ts index 2cdaa69983..d5bf6167b7 100644 --- a/src/app/api/quota/pools/[id]/route.ts +++ b/src/app/api/quota/pools/[id]/route.ts @@ -70,6 +70,19 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise< return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 }); } + // Re-sync quota combos when connectionIds was explicitly changed. + // updatePool already fires a guarded sync internally, but we add an explicit + // awaited sync here so the caller's response reflects the updated combo state. + // Mirrors the dynamic-import + non-fatal pattern from groups/[id]/route.ts PATCH. + if (body !== null && typeof body === "object" && "connectionIds" in body) { + try { + const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos"); + await syncQuotaCombos(id); + } 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); diff --git a/src/shared/schemas/quota.ts b/src/shared/schemas/quota.ts index 518d80f883..99bc49beb9 100644 --- a/src/shared/schemas/quota.ts +++ b/src/shared/schemas/quota.ts @@ -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; diff --git a/tests/unit/quota-pool-update-full.test.ts b/tests/unit/quota-pool-update-full.test.ts new file mode 100644 index 0000000000..2d8aeabf77 --- /dev/null +++ b/tests/unit/quota-pool-update-full.test.ts @@ -0,0 +1,250 @@ +/** + * 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 } = 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> { + 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).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).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("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"]); +}); From e5392a7eaaa9990aeb628b6b3cb08031440b4ef0 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 1 Jun 2026 02:35:26 -0300 Subject: [PATCH 02/10] fix(quota): pool PATCH prunes OLD group/provider combos before re-sync (no orphan qtSd/ on switch) --- src/app/api/quota/pools/[id]/route.ts | 28 ++++++++++--- tests/unit/quota-pool-update-full.test.ts | 50 ++++++++++++++++++++++- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/app/api/quota/pools/[id]/route.ts b/src/app/api/quota/pools/[id]/route.ts index d5bf6167b7..cac6216e47 100644 --- a/src/app/api/quota/pools/[id]/route.ts +++ b/src/app/api/quota/pools/[id]/route.ts @@ -65,19 +65,35 @@ 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 /. 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 }); } - // Re-sync quota combos when connectionIds was explicitly changed. - // updatePool already fires a guarded sync internally, but we add an explicit - // awaited sync here so the caller's response reflects the updated combo state. - // Mirrors the dynamic-import + non-fatal pattern from groups/[id]/route.ts PATCH. - if (body !== null && typeof body === "object" && "connectionIds" in body) { + if (combosNeedResync) { try { const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos"); - await syncQuotaCombos(id); + await syncQuotaCombos(id); // pool now has its NEW group/provider } catch { // Guard: combo-sync failure must never break pool update callers. } diff --git a/tests/unit/quota-pool-update-full.test.ts b/tests/unit/quota-pool-update-full.test.ts index 2d8aeabf77..1497b144b6 100644 --- a/tests/unit/quota-pool-update-full.test.ts +++ b/tests/unit/quota-pool-update-full.test.ts @@ -24,7 +24,9 @@ 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 } = await import("../../src/lib/quota/quotaCombos.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" @@ -178,6 +180,52 @@ test("updatePool with new connectionIds triggers combo re-sync (openrouter → b 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).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).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"); From 342f12b77ab5ef479a0b54fa340dd0c3829e9563 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 1 Jun 2026 02:39:37 -0300 Subject: [PATCH 03/10] =?UTF-8?q?feat(quota):=20GET=20/api/quota/keys/[id]?= =?UTF-8?q?/models=20=E2=80=94=20preview=20the=20qtSd/=20models=20a=20key?= =?UTF-8?q?=20sees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/quota/keys/[id]/models/route.ts | 70 +++++++ tests/unit/quota-key-models-route.test.ts | 200 ++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/app/api/quota/keys/[id]/models/route.ts create mode 100644 tests/unit/quota-key-models-route.test.ts diff --git a/src/app/api/quota/keys/[id]/models/route.ts b/src/app/api/quota/keys/[id]/models/route.ts new file mode 100644 index 0000000000..d4297cfa51 --- /dev/null +++ b/src/app/api/quota/keys/[id]/models/route.ts @@ -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 { + 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).allowedQuotas, + ) + ? ((key as Record).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> = []; + 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 }); + } +} diff --git a/tests/unit/quota-key-models-route.test.ts b/tests/unit/quota-key-models-route.test.ts new file mode 100644 index 0000000000..bb71a649ec --- /dev/null +++ b/tests/unit/quota-key-models-route.test.ts @@ -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", + ); +}); From 881a8e9b56ca8bf49aed0a21e7db5f5e0f083c6f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 1 Jun 2026 02:43:59 -0300 Subject: [PATCH 04/10] feat(quota): move Quota Share nav item under Provider Quota --- src/shared/constants/sidebarVisibility.ts | 14 ++-- .../sidebar-quota-share-placement.test.ts | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 tests/unit/sidebar-quota-share-placement.test.ts diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 613d3805a8..42aed0cfdc 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -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 = { diff --git a/tests/unit/sidebar-quota-share-placement.test.ts b/tests/unit/sidebar-quota-share-placement.test.ts new file mode 100644 index 0000000000..8118e5ade6 --- /dev/null +++ b/tests/unit/sidebar-quota-share-placement.test.ts @@ -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}` + ); +}); From b64489b3addcf466d50c5f65f7f000ec88b8420b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 1 Jun 2026 02:52:51 -0300 Subject: [PATCH 05/10] feat(quota): PoolWizard editPool mode (full pool edit via PATCH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `editPool?: QuotaPool` to PoolWizardProps; import QuotaPool type - Open effect pre-fills state (connectionIds, name, groupId, allocations, plan dims) from editPool; create-reset unchanged when editPool absent - handleFinish branches on editPool: create path keeps POST→PUT→PATCH unchanged; edit path sends a single PATCH (name+groupId+connectionIds+ allocations+exclusive) then optional PUT when dimensionsEdited - Title uses t("editPoolTitle"), submit button uses t("saveChanges") in edit mode - Add editPoolTitle/saveChanges to en.json and pt-BR.json (quotaShare namespace) - New source-scan test: quota-pool-wizard-edit.test.ts (15 assertions, all pass) --- .../quota-share/components/PoolWizard.tsx | 161 ++++++++++++----- src/i18n/messages/en.json | 2 + src/i18n/messages/pt-BR.json | 2 + tests/unit/quota-pool-wizard-edit.test.ts | 171 ++++++++++++++++++ 4 files changed, 293 insertions(+), 43 deletions(-) create mode 100644 tests/unit/quota-pool-wizard-edit.test.ts diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx index 4c1b450ee3..8f6c7b07e6 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx @@ -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,8 @@ export interface PoolWizardProps { existingPoolConnectionIds: Set; groups?: QuotaGroup[]; selectedGroupId?: string; + /** When provided, the wizard enters edit mode — pre-fills from the pool and PATCHes instead of POSTing. */ + editPool?: QuotaPool; } // ──────────────────────────────────────────────────────────────────────────── @@ -169,6 +172,7 @@ export default function PoolWizard({ existingPoolConnectionIds, groups = [], selectedGroupId: initialGroupId = "group-demo", + editPool, }: PoolWizardProps) { const t = useTranslations("quotaShare"); const tPlans = useTranslations("quotaPlans"); @@ -255,6 +259,7 @@ export default function PoolWizard({ useEffect(() => { if (!open) { + // Closing: always reset to defaults. setStep(1); setConnectionIds([]); setPoolName(""); @@ -266,8 +271,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 ?? []); + // `exclusive` is not stored on the QuotaPool type; default to false in edit mode. + // The server-side reconcilePoolExclusivity will apply the chosen value on PATCH. + setExclusive(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 +407,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 +512,7 @@ export default function PoolWizard({ if (!open) return null; return ( - +
@@ -894,7 +969,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")}
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 63f61a4a71..6a181a7142 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 77580dc264..6ebd0f7e91 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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", diff --git a/tests/unit/quota-pool-wizard-edit.test.ts b/tests/unit/quota-pool-wizard-edit.test.ts new file mode 100644 index 0000000000..1bed430aa6 --- /dev/null +++ b/tests/unit/quota-pool-wizard-edit.test.ts @@ -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; +const ptBrJson = JSON.parse(fs.readFileSync(PT_BR_JSON_PATH, "utf-8")) as Record; + +// ── 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 | 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 | 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 | 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 | 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"); +}); From 3c8e84d70222a5a29e73c2ca48af64c2c2f8a328 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 1 Jun 2026 03:03:03 -0300 Subject: [PATCH 06/10] feat(quota): all-groups default + stacked group sections + 3-col cards - selectedGroupId defaults to "all" instead of "group-demo" - ────────────────────────────────────── + +test('QuotaSharePageClient: must include an