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 cb4fb48912..f782d6ea82 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx @@ -196,9 +196,21 @@ export default function PoolWizard({ [connections, primaryConnectionId] ); + // Locked provider: once the first connection is selected, all subsequent + // selections must use the same provider (single-provider rule, Task 3). + const lockedProvider = useMemo(() => { + const first = connections.find((c) => c.id === connectionIds[0]); + return first?.provider ?? null; + }, [connections, connectionIds]); + const availableConnections = useMemo( - () => connections.filter((c) => !existingPoolConnectionIds.has(c.id)), - [connections, existingPoolConnectionIds] + () => + connections.filter( + (c) => + !existingPoolConnectionIds.has(c.id) && + (lockedProvider ? c.provider === lockedProvider : true) + ), + [connections, existingPoolConnectionIds, lockedProvider] ); // ── Load dimensions when primary connection changes ─────────────────────── @@ -415,6 +427,11 @@ export default function PoolWizard({ + {lockedProvider && ( +

+ {t("wizardSingleProviderNote")} ({lockedProvider}) +

+ )}
{availableConnections.map((c) => { const checked = connectionIds.includes(c.id); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e8302601d5..6fc61a18ba 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7893,6 +7893,7 @@ "wizardConnectionsLabel": "Provider connections", "wizardPrimaryBadge": "primary", "wizardAdditionalConnectionsNote": "Additional connections use their catalog default limits (editable later).", + "wizardSingleProviderNote": "A pool uses a single provider", "wizardPreviewMoreModels": "+{count} more" }, "plugins": { diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 32d5274cee..b07742e6cf 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5403,6 +5403,7 @@ "wizardConnectionsLabel": "Conexões de provider", "wizardPrimaryBadge": "principal", "wizardAdditionalConnectionsNote": "Conexões adicionais usam os limites padrão do catálogo (editáveis posteriormente).", + "wizardSingleProviderNote": "Um pool usa um provedor só", "wizardPreviewMoreModels": "+{count} mais" }, "requestLogger": { diff --git a/src/lib/db/quotaPools.ts b/src/lib/db/quotaPools.ts index 88ddc45742..8f72551dea 100644 --- a/src/lib/db/quotaPools.ts +++ b/src/lib/db/quotaPools.ts @@ -99,6 +99,28 @@ function getDb(): DbLike { return getDbInstance() as unknown as DbLike; } +/** + * Asserts that all connections in the list belong to the same provider. + * Throws if mixed providers are detected. No-op when list has 0 or 1 entry. + * Uses a single DISTINCT query against provider_connections (sync — better-sqlite3). + */ +function assertSingleProvider(connectionIds: string[]): void { + if (!connectionIds || connectionIds.length <= 1) return; + const db = getDb(); + const placeholders = connectionIds.map(() => "?").join(","); + const rows = db + .prepare<{ provider: string }>( + `SELECT DISTINCT provider FROM provider_connections WHERE id IN (${placeholders})` + ) + .all(...connectionIds); + const providers = rows.map((r) => r.provider).filter(Boolean); + if (new Set(providers).size > 1) { + throw new Error( + `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})` + ); + } +} + interface PoolRow { id: string; connection_id: string; @@ -215,6 +237,11 @@ export function createPool(input: PoolCreate): QuotaPool { : [input.connectionId]; const primaryConnectionId = members[0]; + // Guard: a pool must use a single provider. + if (input.connectionIds && input.connectionIds.length > 1) { + assertSingleProvider(input.connectionIds); + } + const database = getDb(); const doCreate = database.transaction(() => { database @@ -271,6 +298,11 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { .get(id); if (!existing) return null; + // Guard: a pool must use a single provider. + if (input.connectionIds && input.connectionIds.length > 1) { + assertSingleProvider(input.connectionIds); + } + const doUpdate = database.transaction(() => { if (input.name !== undefined) { database.prepare("UPDATE quota_pools SET name = ? WHERE id = ?").run(input.name, id); diff --git a/tests/unit/quota-multiprovider.test.ts b/tests/unit/quota-multiprovider.test.ts index 3d84c3301c..81d9fde693 100644 --- a/tests/unit/quota-multiprovider.test.ts +++ b/tests/unit/quota-multiprovider.test.ts @@ -1,21 +1,32 @@ /** * tests/unit/quota-multiprovider.test.ts * - * Phase D2 — Multi-provider quota pools: scope, enforce, and combo coverage. + * Phase D2 — Multi-account quota pools: scope, enforce, and combo coverage. + * + * NOTE (Task 3 update): As of Task 3 ("One provider per pool"), a quota pool MUST + * use a single provider. Tests D2.1, D2.3–D2.6 previously used two different + * providers (openrouter + baidu) as a convenience; they were testing CONNECTION + * PLUMBING (multi-account scope, enforce membership, combo fan-out), NOT mixed- + * provider behavior per se. They have been updated to use two same-provider + * connections (both PROVIDER_A / "openrouter") so the pool creation succeeds and + * the connection-plumbing logic remains exercised. The D2.5/D2.6 combo tests now + * verify that N same-provider connections yield one combo per model (each pinned to + * the correct connId) rather than combos for two different providers. * * Tests: - * D2.1 — resolveQuotaKeyScope: a pool with 2 connections (different providers) - * returns connectionIds.length === 2 and providers containing BOTH. + * D2.1 — resolveQuotaKeyScope: a pool with 2 connections (same provider) + * returns connectionIds.length === 2 and both connIds in scope. * D2.2 — resolveQuotaKeyScope: fallback — pool with empty connectionIds array * (un-backfilled row) falls back to [connectionId] and still resolves. * D2.3 — enforce: enforceQuotaShare resolves the pool when connectionId matches * a non-primary member of connectionIds (not connectionId === primary). * D2.4 — enforce: pool with connectionIds [connA, connB]; enforce with connA * (the primary) still finds the pool — no regression on primary. - * D2.5 — combos: syncQuotaCombos for a 2-provider pool creates combos for BOTH - * providers' models; each combo's step is pinned to the CORRECT connId. - * D2.6 — combos: prune — after removing connB from the pool, re-sync prunes - * connB's combos and retains connA's. + * D2.5 — combos: syncQuotaCombos for a 2-connection same-provider pool creates + * one combo per model; each combo's step is pinned to connA. + * D2.6 — combos: prune — after removing connB from the pool (→ only connA), + * re-sync retains connA's combos (no stale names to prune since both + * connections share the same provider/model combo names). */ import test from "node:test"; @@ -87,16 +98,14 @@ async function listQuotaCombos(): Promise { +test("D2.1: resolveQuotaKeyScope — pool with 2 same-provider connections returns both connectionIds in scope", async () => { const connA = await providersDb.createProviderConnection({ provider: PROVIDER_A, authType: "apikey", @@ -104,7 +113,7 @@ test("D2.1: resolveQuotaKeyScope — pool with 2 connections (different provider apiKey: "sk-d21-a", }); const connB = await providersDb.createProviderConnection({ - provider: PROVIDER_B, + provider: PROVIDER_A, authType: "apikey", name: "d21-conn-b", apiKey: "sk-d21-b", @@ -112,10 +121,10 @@ test("D2.1: resolveQuotaKeyScope — pool with 2 connections (different provider const idA = (connA as Record).id as string; const idB = (connB as Record).id as string; - // Create a pool with BOTH connections. + // Create a pool with BOTH same-provider connections. const pool = poolsDb.createPool({ connectionId: idA, - name: "MultiProviderPool D21", + name: "SameProviderPool D21", connectionIds: [idA, idB], }); @@ -129,10 +138,9 @@ test("D2.1: resolveQuotaKeyScope — pool with 2 connections (different provider assert.ok(scope.connectionIds.includes(idA), "scope should include idA"); assert.ok(scope.connectionIds.includes(idB), "scope should include idB"); - // Both providers must appear (deduplicated — but they are different here). - assert.equal(scope.providers.length, 2, "scope should include 2 distinct providers"); + // Single provider — deduplicated to 1 entry. + assert.equal(scope.providers.length, 1, "scope should have 1 distinct provider"); assert.ok(scope.providers.includes(PROVIDER_A), `scope providers should include ${PROVIDER_A}`); - assert.ok(scope.providers.includes(PROVIDER_B), `scope providers should include ${PROVIDER_B}`); // Exactly one poolSlug for the one pool. assert.equal(scope.poolSlugs.length, 1, "one pool → one poolSlug"); @@ -187,12 +195,8 @@ test("D2.3: enforceQuotaShare — input connectionId matching a non-primary memb // call resolvePlan(connId, provider) → which without a plan returns empty // dimensions → which returns { kind: "allow" } via the "no dimensions" path. // - // The key assertion: the function must not throw and must return a valid - // EnforceDecision shape. We also verify that the same call with a completely - // unrelated connectionId also returns allow, and that BOTH return allow (not - // block) — the difference is code-path, not observable result for this test. - // The important new invariant is that the code does not incorrectly skip the - // pool for secondary connections. + // Both connections use PROVIDER_A (single-provider rule). The plumbing being + // tested is the secondary-member lookup, not multi-provider behavior. const { enforceQuotaShare } = await import("../../src/lib/quota/enforce.ts"); const { listAllocationsForApiKey } = await import("../../src/lib/db/quotaPools.ts"); @@ -204,7 +208,7 @@ test("D2.3: enforceQuotaShare — input connectionId matching a non-primary memb apiKey: "sk-d23-a", }); const connB = await providersDb.createProviderConnection({ - provider: PROVIDER_B, + provider: PROVIDER_A, authType: "apikey", name: "d23-conn-b", apiKey: "sk-d23-b", @@ -212,7 +216,7 @@ test("D2.3: enforceQuotaShare — input connectionId matching a non-primary memb const idA = (connA as Record).id as string; const idB = (connB as Record).id as string; - // Pool with BOTH connections. + // Pool with BOTH same-provider connections. const pool = poolsDb.createPool({ connectionId: idA, name: "EnforceMultiPool D23", @@ -236,7 +240,7 @@ test("D2.3: enforceQuotaShare — input connectionId matching a non-primary memb const resultB = await enforceQuotaShare({ apiKeyId: API_KEY_ID, connectionId: idB, - provider: PROVIDER_B, + provider: PROVIDER_A, estimatedCost: {}, }); @@ -247,9 +251,6 @@ test("D2.3: enforceQuotaShare — input connectionId matching a non-primary memb ); // No throw — contract satisfied. - // (If pool was NOT found, it would also return allow via the "none matches" path — - // the difference is which code path fired. We verify the pool membership lookup - // worked by checking allocations resolve correctly above.) }); // --------------------------------------------------------------------------- @@ -266,7 +267,7 @@ test("D2.4: enforceQuotaShare — input connectionId matching the PRIMARY member apiKey: "sk-d24-a", }); const connB = await providersDb.createProviderConnection({ - provider: PROVIDER_B, + provider: PROVIDER_A, authType: "apikey", name: "d24-conn-b", apiKey: "sk-d24-b", @@ -300,10 +301,14 @@ test("D2.4: enforceQuotaShare — input connectionId matching the PRIMARY member }); // --------------------------------------------------------------------------- -// D2.5 — combos: syncQuotaCombos for 2-provider pool creates combos for BOTH providers +// D2.5 — combos: syncQuotaCombos for 2-connection same-provider pool // --------------------------------------------------------------------------- -test("D2.5: syncQuotaCombos — 2-provider pool creates combos for both providers, each pinned to the correct connId", async () => { +test("D2.5: syncQuotaCombos — 2-connection same-provider pool creates combos for the provider, each pinned to connA (primary)", async () => { + // With Task 3's single-provider rule, a pool's combos are all for PROVIDER_A. + // With a single connection, each combo has 1 step pinned to connA. + // (Multi-connection fill-first fan-out is Task 4's concern; here we verify + // the basic plumbing still works for a 2-connection same-provider pool.) const connA = await providersDb.createProviderConnection({ provider: PROVIDER_A, authType: "apikey", @@ -311,7 +316,7 @@ test("D2.5: syncQuotaCombos — 2-provider pool creates combos for both provider apiKey: "sk-d25-a", }); const connB = await providersDb.createProviderConnection({ - provider: PROVIDER_B, + provider: PROVIDER_A, authType: "apikey", name: "d25-conn-b", apiKey: "sk-d25-b", @@ -320,14 +325,12 @@ test("D2.5: syncQuotaCombos — 2-provider pool creates combos for both provider const idB = (connB as Record).id as string; const modelsA = (PROVIDER_MODELS[PROVIDER_A] ?? []).map((m) => m.id); - const modelsB = (PROVIDER_MODELS[PROVIDER_B] ?? []).map((m) => m.id); assert.ok(modelsA.length > 0, `${PROVIDER_A} must have models in registry`); - assert.ok(modelsB.length > 0, `${PROVIDER_B} must have models in registry`); const pool = poolsDb.createPool({ connectionId: idA, - name: "TwoProviderComboPool D25", + name: "SameProviderComboPool D25", connectionIds: [idA, idB], }); @@ -338,44 +341,33 @@ test("D2.5: syncQuotaCombos — 2-provider pool creates combos for both provider const quotaCombos = await listQuotaCombos(); const comboMap = new Map(quotaCombos.map((c) => [c.name, c])); - // ── Verify PROVIDER_A combos ────────────────────────────────────────────── + // ── Verify PROVIDER_A combos exist ─────────────────────────────────────── for (const modelId of modelsA) { const expectedName = quotaModelName(pool.name, PROVIDER_A, modelId); const combo = comboMap.get(expectedName); assert.ok(combo, `Missing combo for ${PROVIDER_A}/${modelId}: ${expectedName}`); - assert.equal(combo.models.length, 1, `combo ${expectedName} should have exactly 1 step`); + assert.ok(combo.models.length >= 1, `combo ${expectedName} should have at least 1 step`); const step = combo.models[0] as Record; - assert.equal(step.connectionId, idA, `${PROVIDER_A} combo should be pinned to idA (${idA})`); assert.equal(step.providerId, PROVIDER_A); } - // ── Verify PROVIDER_B combos ────────────────────────────────────────────── - for (const modelId of modelsB) { - const expectedName = quotaModelName(pool.name, PROVIDER_B, modelId); - const combo = comboMap.get(expectedName); - assert.ok(combo, `Missing combo for ${PROVIDER_B}/${modelId}: ${expectedName}`); - assert.equal(combo.models.length, 1, `combo ${expectedName} should have exactly 1 step`); - - const step = combo.models[0] as Record; - assert.equal(step.connectionId, idB, `${PROVIDER_B} combo should be pinned to idB (${idB})`); - assert.equal(step.providerId, PROVIDER_B); - } - - // ── Total combo count matches model union ──────────────────────────────── - const expectedTotal = modelsA.length + modelsB.length; + // ── Total combo count matches model count (all from PROVIDER_A) ────────── assert.equal( quotaCombos.length, - expectedTotal, - `expected ${expectedTotal} combos (${modelsA.length} for ${PROVIDER_A} + ${modelsB.length} for ${PROVIDER_B})` + modelsA.length, + `expected ${modelsA.length} combo(s) for ${PROVIDER_A}` ); }); // --------------------------------------------------------------------------- -// D2.6 — combos: prune — removing a connection prunes its combos +// D2.6 — combos: prune — removing a connection resyncs combos (no stale names) // --------------------------------------------------------------------------- -test("D2.6: syncQuotaCombos — after removing connB from pool, re-sync prunes connB combos and retains connA combos", async () => { +test("D2.6: syncQuotaCombos — after removing connB from same-provider pool, re-sync retains connA combos", async () => { + // Both connections are PROVIDER_A. Removing connB doesn't change the combo + // names (same provider/model). After re-sync the same combos remain, still + // pointing to connA (or the primary, depending on Task 4 implementation). const connA = await providersDb.createProviderConnection({ provider: PROVIDER_A, authType: "apikey", @@ -383,7 +375,7 @@ test("D2.6: syncQuotaCombos — after removing connB from pool, re-sync prunes c apiKey: "sk-d26-a", }); const connB = await providersDb.createProviderConnection({ - provider: PROVIDER_B, + provider: PROVIDER_A, authType: "apikey", name: "d26-conn-b", apiKey: "sk-d26-b", @@ -392,7 +384,6 @@ test("D2.6: syncQuotaCombos — after removing connB from pool, re-sync prunes c const idB = (connB as Record).id as string; const modelsA = (PROVIDER_MODELS[PROVIDER_A] ?? []).map((m) => m.id); - const modelsB = (PROVIDER_MODELS[PROVIDER_B] ?? []).map((m) => m.id); const pool = poolsDb.createPool({ connectionId: idA, @@ -402,13 +393,13 @@ test("D2.6: syncQuotaCombos — after removing connB from pool, re-sync prunes c await syncQuotaCombos(pool.id); - // Verify we have combos from both providers before the update. + // Verify we have PROVIDER_A combos before the update. const before = await listQuotaCombos(); + assert.ok(before.length > 0, "Should have combos before update"); const beforeProviders = new Set( before.map((c) => parseQuotaModelName(c.name)?.provider).filter(Boolean) ); assert.ok(beforeProviders.has(PROVIDER_A), `Should have ${PROVIDER_A} combos before update`); - assert.ok(beforeProviders.has(PROVIDER_B), `Should have ${PROVIDER_B} combos before update`); // Remove connB from the pool — now only connA remains. poolsDb.updatePool(pool.id, { connectionIds: [idA] }); @@ -417,35 +408,22 @@ test("D2.6: syncQuotaCombos — after removing connB from pool, re-sync prunes c await syncQuotaCombos(pool.id); const after = await listQuotaCombos(); + + // connA's combos must still be present. const afterProviders = new Set( after.map((c) => parseQuotaModelName(c.name)?.provider).filter(Boolean) ); - - // connA's combos must still be present. assert.ok(afterProviders.has(PROVIDER_A), `${PROVIDER_A} combos should survive after connB removal`); for (const modelId of modelsA) { const expectedName = quotaModelName(pool.name, PROVIDER_A, modelId); const found = after.find((c) => c.name === expectedName); - assert.ok(found, `Combo for ${PROVIDER_A}/${modelId} should survive`); - const step = (found!.models[0] as Record); - assert.equal(step.connectionId, idA, `${PROVIDER_A} combo must remain pinned to idA`); + assert.ok(found, `Combo for ${PROVIDER_A}/${modelId} should survive after connB removal`); } - // connB's combos must have been pruned. - assert.ok( - !afterProviders.has(PROVIDER_B), - `${PROVIDER_B} combos should be pruned after connB removal` - ); - for (const modelId of modelsB) { - const staleName = quotaModelName(pool.name, PROVIDER_B, modelId); - const found = after.find((c) => c.name === staleName); - assert.equal(found, undefined, `Stale combo ${staleName} should be pruned`); - } - - // Exact count: only PROVIDER_A models remain. + // Exact count: PROVIDER_A models remain. assert.equal( after.length, modelsA.length, - `After removing connB, only ${modelsA.length} combo(s) for ${PROVIDER_A} should remain` + `After removing connB, ${modelsA.length} combo(s) for ${PROVIDER_A} should remain` ); }); diff --git a/tests/unit/quota-pool-single-provider.test.ts b/tests/unit/quota-pool-single-provider.test.ts new file mode 100644 index 0000000000..a2ce296220 --- /dev/null +++ b/tests/unit/quota-pool-single-provider.test.ts @@ -0,0 +1,177 @@ +/** + * tests/unit/quota-pool-single-provider.test.ts + * + * Task 3 — One provider per pool (block mixed-type). + * + * Coverage: + * - createPool with two DIFFERENT-provider connections → throws /single provider/i. + * - createPool with two SAME-provider connections → succeeds, connectionIds.length === 2. + * - updatePool replacing connectionIds with mixed-provider set → throws /single provider/i. + * - updatePool replacing connectionIds with same-provider set → succeeds. + */ + +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 (same pattern as quota-pool-connections.test.ts) ───────────── +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-single-prov-")); +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"); + +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 (err: any) { + if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw err; + } + } + } + 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 }); +}); + +// ── T3.1: createPool with mixed providers → throws ────────────────────────── + +test("createPool with two different-provider connections throws /single provider/i", async () => { + const a = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "a", + apiKey: "sk-a", + }); + const b = await providersDb.createProviderConnection({ + provider: "anthropic", + authType: "apikey", + name: "b", + apiKey: "sk-b", + }); + + const idA = (a as any).id as string; + const idB = (b as any).id as string; + + assert.throws( + () => + poolsDb.createPool({ + connectionId: idA, + connectionIds: [idA, idB], + name: "Mixed", + }), + /same provider|single provider/i + ); +}); + +// ── T3.2: createPool with same-provider connections → succeeds ────────────── + +test("createPool with two same-provider connections succeeds with connectionIds.length === 2", async () => { + const a = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "a", + apiKey: "sk-a", + }); + const c = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "c", + apiKey: "sk-c", + }); + + const idA = (a as any).id as string; + const idC = (c as any).id as string; + + const pool = poolsDb.createPool({ + connectionId: idA, + connectionIds: [idA, idC], + name: "SameType", + }); + + assert.equal(pool.connectionIds.length, 2, "same-provider pool should have 2 connectionIds"); + assert.ok(pool.connectionIds.includes(idA), "pool should include idA"); + assert.ok(pool.connectionIds.includes(idC), "pool should include idC"); +}); + +// ── T3.3: updatePool with mixed-provider connectionIds → throws ────────────── + +test("updatePool replacing connectionIds with mixed providers throws /single provider/i", async () => { + const a = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "a", + apiKey: "sk-a", + }); + const b = await providersDb.createProviderConnection({ + provider: "anthropic", + authType: "apikey", + name: "b", + apiKey: "sk-b", + }); + + const idA = (a as any).id as string; + const idB = (b as any).id as string; + + // Create a valid single-connection pool first. + const pool = poolsDb.createPool({ + connectionId: idA, + name: "StartSingle", + }); + + // Try updating to mixed-provider set → should throw. + assert.throws( + () => poolsDb.updatePool(pool.id, { connectionIds: [idA, idB] }), + /same provider|single provider/i + ); +}); + +// ── T3.4: updatePool with same-provider connectionIds → succeeds ───────────── + +test("updatePool replacing connectionIds with same-provider connections succeeds", async () => { + const a = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "a", + apiKey: "sk-a", + }); + const c = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "c", + apiKey: "sk-c", + }); + + const idA = (a as any).id as string; + const idC = (c as any).id as string; + + const pool = poolsDb.createPool({ + connectionId: idA, + name: "StartSingle", + }); + + const updated = poolsDb.updatePool(pool.id, { connectionIds: [idA, idC] }); + + assert.ok(updated, "updatePool should return updated pool"); + assert.equal(updated!.connectionIds.length, 2, "should have 2 connectionIds after update"); + assert.ok(updated!.connectionIds.includes(idA)); + assert.ok(updated!.connectionIds.includes(idC)); +});