mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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`
|
||||
);
|
||||
});
|
||||
|
||||
177
tests/unit/quota-pool-single-provider.test.ts
Normal file
177
tests/unit/quota-pool-single-provider.test.ts
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user