Files
9router/tests/unit/chat-connection-pin.test.js
luulam 38ff11ee16 fix(merge): restore chat.js imports, trust-vision floor, gitignore entries
Audit of every branch-owned line the -X theirs merge dropped from
the 32 pre-merge commits found three more real regressions:

* src/sse/handlers/chat.js: merge kept the capsOverride feature
  (bb8d67ba) but reverted the import block, so getCustomModels and
  capabilitiesFromServiceKind were undefined. The runtime error was
  swallowed by the feature's own fail-open try/catch — custom
  models silently lost their vision override. Restored both imports.
* open-sse/providers/capabilities.js: TRUST_UPSTREAM_VISION (the
  floor that keeps vision on for unknown models on upstream-validating
  gateways like openrouter) was left as dead code by the merge —
  upstream rewrote step 4 as refine() and dropped the check.
  Re-applied it on top of the new refine() so catalog/limits
  refinement still applies.
* tests/unit/chat-connection-pin.test.js: mock auth module lacked
  isModelAllowedForKey added by f0adfb20.
* .gitignore: re-add .pi-subagents/.
2026-09-08 09:38:03 +07:00

117 lines
3.9 KiB
JavaScript

/**
* Unit tests for x-connection-id pinning in src/sse/handlers/chat.js
*
* Covers:
* - the dashboard per-key test header is forwarded to getProviderCredentials
* as preferredConnectionId (same contract as embeddings/images/video)
* - a pinned request does NOT rotate to another account on failure
* - an unpinned request keeps rotating (regression guard)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const authMocks = vi.hoisted(() => ({
getProviderCredentials: vi.fn(),
markAccountUnavailable: vi.fn(async () => ({ shouldFallback: true })),
clearAccountError: vi.fn(async () => {}),
extractApiKey: vi.fn(() => null),
isValidApiKey: vi.fn(async () => true),
isModelAllowedForKey: vi.fn(() => true),
}));
const tokenMocks = vi.hoisted(() => ({
checkAndRefreshToken: vi.fn(async (_p, creds) => creds),
updateProviderCredentials: vi.fn(async () => {}),
}));
vi.mock("@/sse/services/auth.js", () => authMocks);
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
vi.mock("@/lib/localDb", () => ({
getSettings: vi.fn(async () => ({ requireApiKey: false })),
getComboByName: vi.fn(async () => null),
getModelAliases: vi.fn(async () => ({})),
getCustomModels: vi.fn(async () => []),
getProviderNodes: vi.fn(async () => []),
getProviderConnections: vi.fn(async () => []),
updateProviderCredentials: vi.fn(async () => {}),
}));
vi.mock("@/sse/utils/logger.js", () => ({
info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), maskKey: (k) => k,
}));
// chatCore would perform the upstream call; stub success so the loop exits.
vi.mock("open-sse/handlers/chatCore.js", () => ({
handleChatCore: vi.fn(async ({ credentials }) => ({
success: true,
response: new Response(JSON.stringify({ servedBy: credentials.connectionId }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
})),
}));
vi.mock("open-sse/services/combo.js", () => ({
getComboModels: vi.fn(async () => null),
resetComboRotation: vi.fn(),
detectRequiredCapabilities: vi.fn(() => new Set()),
augmentModelsWithCapacityAdapter: vi.fn((m) => m),
withCapacityAdapterStripping: vi.fn((fn) => fn),
getActiveAdapterStrategy: vi.fn(() => "fallback"),
}));
import { handleChat } from "@/sse/handlers/chat.js";
const originalFetch = global.fetch;
const makeRequest = (body, headers = {}) =>
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
});
const account = (id) => ({
connectionId: id,
connectionName: `acc-${id}`,
accessToken: "tok",
refreshToken: "ref",
authType: "oauth",
});
beforeEach(() => {
global.fetch = vi.fn();
authMocks.getProviderCredentials.mockReset();
authMocks.getProviderCredentials.mockImplementation(async (_p, exclude) =>
exclude.size === 0 ? account("conn-A") : null,
);
authMocks.markAccountUnavailable.mockClear();
authMocks.clearAccountError.mockClear();
tokenMocks.checkAndRefreshToken.mockClear();
});
afterEach(() => {
global.fetch = originalFetch;
});
describe("chat x-connection-id pinning", () => {
it("forwards x-connection-id as preferredConnectionId", async () => {
const res = await handleChat(
makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }, { "x-connection-id": "conn-B" }),
);
expect(res.status).toBe(200);
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
"prov",
expect.anything(),
"m1",
expect.objectContaining({ preferredConnectionId: "conn-B" }),
);
});
it("passes null when no pin header is present", async () => {
await handleChat(makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }));
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
"prov",
expect.anything(),
"m1",
expect.objectContaining({ preferredConnectionId: null }),
);
});
});