feat(quota): one provider per pool (block mixed-type) — server guard + wizard filter

- Add assertSingleProvider() helper in quotaPools.ts: queries provider_connections
  with DISTINCT provider WHERE id IN (...), throws if >1 provider detected.
- Call guard early in createPool (when connectionIds.length > 1) and updatePool
  (when input.connectionIds.length > 1), before any DB writes.
- Add lockedProvider useMemo in PoolWizard.tsx: derived from first selected
  connection; availableConnections filtered to same provider once locked.
- Show wizardSingleProviderNote i18n hint in step 1 when lockedProvider is set.
- Add wizardSingleProviderNote to en.json + pt-BR.json (parity).
- New test: tests/unit/quota-pool-single-provider.test.ts (4 cases).
- Update tests/unit/quota-multiprovider.test.ts: all D2 tests now use two
  same-provider connections (both PROVIDER_A/openrouter) — the tests were
  exercising connection-plumbing (scope, enforce membership, combo fan-out),
  not mixed-provider behavior per se; updated to remain valid under Task 3 rule.
This commit is contained in:
diegosouzapw
2026-05-31 10:53:02 -03:00
parent bbc4c575e0
commit 2521c09119
6 changed files with 288 additions and 82 deletions

View File

@@ -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({
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("wizardConnectionsLabel")}
</label>
{lockedProvider && (
<p className="text-[10px] text-amber-400 mb-1.5">
{t("wizardSingleProviderNote")} ({lockedProvider})
</p>
)}
<div className="space-y-1.5 rounded border border-border bg-bg-base px-3 py-2 max-h-48 overflow-y-auto">
{availableConnections.map((c) => {
const checked = connectionIds.includes(c.id);

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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);

View File

@@ -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.3D2.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<Array<{ name: string; models: unknown[
}
// "openrouter" has exactly 1 model ("auto") in the static registry.
// "baidu" has exactly 1 model ("ernie-4.0-8k") in the static registry.
// Both are stable, deterministic, and have no overlap — ideal for 2-provider pool tests.
// Both connections use the same provider — required by Task 3 single-provider rule.
const PROVIDER_A = "openrouter";
const PROVIDER_B = "baidu";
// ---------------------------------------------------------------------------
// D2.1 — resolveQuotaKeyScope: 2-connection pool → both providers in scope
// D2.1 — resolveQuotaKeyScope: 2-connection same-provider pool → both in scope
// ---------------------------------------------------------------------------
test("D2.1: resolveQuotaKeyScope — pool with 2 connections (different providers) returns both connectionIds and both providers", async () => {
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<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).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<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<string, unknown>);
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`
);
});

View File

@@ -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));
});