From 1addbdedc152626fd14024b89c13885444be082b Mon Sep 17 00:00:00 2001 From: Ding Date: Sat, 14 Mar 2026 17:43:33 +0000 Subject: [PATCH 1/3] fix: isolate provider endpoint pools and sticky sessions --- messages/en/errors.json | 1 + messages/ja/errors.json | 1 + messages/ru/errors.json | 1 + messages/zh-CN/errors.json | 1 + messages/zh-TW/errors.json | 1 + src/actions/provider-endpoints.ts | 76 ++++++++- src/actions/providers.ts | 43 +++++ .../provider-chain-popover.test.tsx | 6 +- .../_components/forms/provider-form/index.tsx | 71 +------- .../_components/provider-endpoints-table.tsx | 22 ++- src/lib/provider-endpoint-error-codes.ts | 2 + src/lib/session-manager.ts | 58 +++++++ src/repository/provider-endpoints.ts | 77 ++++++++- tests/unit/actions/provider-endpoints.test.ts | 97 +++++++++++ tests/unit/actions/providers.test.ts | 32 ++++ ...anager-terminate-provider-sessions.test.ts | 55 ++++++ .../provider-endpoints-vendor-key.test.ts | 28 ++- .../statistics-quota-costs-all-time.test.ts | 38 ++++- .../provider-endpoints-table.test.tsx | 53 ++++++ .../provider-form-endpoint-pool.test.tsx | 161 +++++++++++++++++- 20 files changed, 729 insertions(+), 95 deletions(-) create mode 100644 tests/unit/lib/session-manager-terminate-provider-sessions.test.ts diff --git a/messages/en/errors.json b/messages/en/errors.json index cf7fcfc16..5b2aaf726 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -49,6 +49,7 @@ "RESOURCE_BUSY": "Resource is currently in use", "INVALID_STATE": "Operation not allowed in current state", "CONFLICT": "Operation conflict", + "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "This endpoint is still referenced by {count} enabled providers: {providers}", "RATE_LIMIT_RPM_EXCEEDED": "Rate limit exceeded: {current} requests per minute (limit: {limit}). Resets at {resetTime}", "RATE_LIMIT_5H_EXCEEDED": "5-hour cost limit exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}", diff --git a/messages/ja/errors.json b/messages/ja/errors.json index 1bd1d63a6..390561331 100644 --- a/messages/ja/errors.json +++ b/messages/ja/errors.json @@ -58,6 +58,7 @@ "RESOURCE_BUSY": "リソースは現在使用中です", "INVALID_STATE": "現在の状態では操作が許可されていません", "CONFLICT": "操作の競合", + "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "このエンドポイントは {count} 件の有効なプロバイダーから参照されています: {providers}", "USER_NOT_FOUND": "ユーザーが見つかりません", "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "一般ユーザーはクォータ制限とプロバイダーグループを変更できません", diff --git a/messages/ru/errors.json b/messages/ru/errors.json index e2fec48cf..dcb4508ef 100644 --- a/messages/ru/errors.json +++ b/messages/ru/errors.json @@ -58,6 +58,7 @@ "RESOURCE_BUSY": "Ресурс в настоящее время используется", "INVALID_STATE": "Операция не разрешена в текущем состоянии", "CONFLICT": "Конфликт операции", + "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "Этот endpoint все еще используется {count} активными провайдерами: {providers}", "USER_NOT_FOUND": "Пользователь не найден", "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "Обычные пользователи не могут изменять лимиты квоты и группы провайдеров", diff --git a/messages/zh-CN/errors.json b/messages/zh-CN/errors.json index de1573a05..c22965b7d 100644 --- a/messages/zh-CN/errors.json +++ b/messages/zh-CN/errors.json @@ -58,6 +58,7 @@ "RESOURCE_BUSY": "资源正在使用中", "INVALID_STATE": "当前状态不允许此操作", "CONFLICT": "操作冲突", + "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "该端点仍被 {count} 个启用中的供应商引用:{providers}", "USER_NOT_FOUND": "用户不存在", "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "普通用户不能修改账户限额和供应商分组", diff --git a/messages/zh-TW/errors.json b/messages/zh-TW/errors.json index 845119ceb..f476ab603 100644 --- a/messages/zh-TW/errors.json +++ b/messages/zh-TW/errors.json @@ -58,6 +58,7 @@ "RESOURCE_BUSY": "資源正在使用中", "INVALID_STATE": "當前狀態不允許此操作", "CONFLICT": "操作衝突", + "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "此端點仍被 {count} 個啟用中的供應商引用:{providers}", "USER_NOT_FOUND": "使用者不存在", "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "普通使用者不能修改帳戶額度和供應商分組", diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index 6639ba76e..d44ec4c9e 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -9,8 +9,12 @@ import { resetEndpointCircuit as resetEndpointCircuitState, } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; -import { PROVIDER_ENDPOINT_CONFLICT_CODE } from "@/lib/provider-endpoint-error-codes"; +import { + ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE, + PROVIDER_ENDPOINT_CONFLICT_CODE, +} from "@/lib/provider-endpoint-error-codes"; import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe"; +import { SessionManager } from "@/lib/session-manager"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n"; import { @@ -35,8 +39,9 @@ import { } from "@/repository"; import { findDashboardProviderEndpointsByVendorAndType, + findEnabledProviderIdsByVendorAndType, + findEnabledProviderReferencesForVendorTypeUrl, findEnabledProviderVendorTypePairs, - hasEnabledProviderReferenceForVendorTypeUrl, } from "@/repository/provider-endpoints"; import { findProviderEndpointProbeLogsBatch, @@ -219,6 +224,43 @@ function isForeignKeyViolationError(error: unknown): boolean { ); } +function formatProviderReferenceSummary( + references: Array<{ id: number; name: string }>, + maxDisplayCount: number = 3 +): string { + const uniqueNames = Array.from( + new Set(references.map((reference) => reference.name.trim()).filter(Boolean)) + ); + if (uniqueNames.length === 0) { + return ""; + } + + if (uniqueNames.length <= maxDisplayCount) { + return uniqueNames.join(", "); + } + + const displayed = uniqueNames.slice(0, maxDisplayCount).join(", "); + return `${displayed} +${uniqueNames.length - maxDisplayCount}`; +} + +async function terminateStickySessionsForProviders(providerIds: number[], context: string) { + const uniqueProviderIds = Array.from( + new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0)) + ); + if (uniqueProviderIds.length === 0) { + return; + } + + try { + await SessionManager.terminateProviderSessionsBatch(uniqueProviderIds); + } catch (error) { + logger.warn(`${context}:terminate_provider_sessions_failed`, { + providerIds: uniqueProviderIds, + error: error instanceof Error ? error.message : String(error), + }); + } +} + export async function getProviderVendors(): Promise { try { const session = await getAdminSession(); @@ -496,6 +538,18 @@ export async function editProviderEndpoint( } } + const shouldTerminateStickySessions = + parsed.data.url !== undefined || + parsed.data.sortOrder !== undefined || + parsed.data.isEnabled !== undefined; + if (shouldTerminateStickySessions) { + const affectedProviderIds = await findEnabledProviderIdsByVendorAndType( + endpoint.vendorId, + endpoint.providerType + ); + await terminateStickySessionsForProviders(affectedProviderIds, "editProviderEndpoint"); + } + try { await publishProviderCacheInvalidation(); } catch (error) { @@ -551,18 +605,20 @@ export async function removeProviderEndpoint(input: unknown): Promise 0) { return { ok: false, error: "该端点仍被启用的供应商引用,请先修改或禁用相关供应商的 URL 后再删除", - errorCode: ERROR_CODES.CONFLICT, + errorCode: ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE, + errorParams: { + count: references.length, + providers: formatProviderReferenceSummary(references), + }, }; } @@ -585,6 +641,12 @@ export async function removeProviderEndpoint(input: unknown): Promise): boolean { + return Object.keys(data).some((key) => STICKY_SESSION_INVALIDATING_PROVIDER_FIELDS.has(key)); +} + +async function terminateStickySessionsForProviders(providerIds: number[], context: string) { + const uniqueProviderIds = Array.from( + new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0)) + ); + if (uniqueProviderIds.length === 0) { + return; + } + + try { + await SessionManager.terminateProviderSessionsBatch(uniqueProviderIds); + } catch (error) { + logger.warn(`${context}:terminate_provider_sessions_failed`, { + providerIds: uniqueProviderIds, + error: error instanceof Error ? error.message : String(error), + }); + } +} + // 获取服务商数据 export async function getProviders(): Promise { try { @@ -776,6 +813,10 @@ export async function editProvider( return { ok: false, error: "供应商不存在" }; } + if (shouldInvalidateStickySessionsOnProviderEdit(payload)) { + await terminateStickySessionsForProviders([providerId], "editProvider"); + } + // 同步熔断器配置到 Redis(如果配置有变化) const hasCircuitConfigChange = validated.circuit_breaker_failure_threshold !== undefined || @@ -848,6 +889,8 @@ export async function removeProvider( const provider = await findProviderById(providerId); await deleteProvider(providerId); + await terminateStickySessionsForProviders([providerId], "removeProvider"); + const undoToken = createProviderPatchUndoToken(); const operationId = createProviderPatchOperationId(); diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx index 0902941c3..6d046bcb2 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx @@ -427,10 +427,8 @@ describe("provider-chain-popover hedge/abort reason handling", () => { // hedge_triggered is informational, not an actual request // so the request count should be 2 (winner + loser), not 3 const document = parseHtml(html); - const countBadge = Array.from(document.querySelectorAll('[data-slot="badge"]')).find((node) => - (node.textContent ?? "").includes("times") - ); - expect(countBadge?.textContent).toContain("2"); + const requestRows = document.querySelectorAll("#root .relative.flex.gap-2"); + expect(requestRows).toHaveLength(2); }); test("hedge_winner is treated as successful provider", () => { diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index b4dccb588..cead44f21 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; -import { getProviderEndpoints, getProviderVendors } from "@/actions/provider-endpoints"; +import { getProviderEndpoints } from "@/actions/provider-endpoints"; import { addProvider, editProvider, @@ -27,12 +27,7 @@ import { import { Button } from "@/components/ui/button"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; import { isValidUrl } from "@/lib/utils/validation"; -import type { - ProviderDisplay, - ProviderEndpoint, - ProviderType, - ProviderVendor, -} from "@/types/provider"; +import type { ProviderDisplay, ProviderEndpoint, ProviderType } from "@/types/provider"; import { FormTabNav, NAV_ORDER, PARENT_MAP, TAB_ORDER } from "./components/form-tab-nav"; import { ProviderFormProvider, useProviderForm } from "./provider-form-context"; import type { NavTargetId, SubTabId, TabId } from "./provider-form-types"; @@ -43,29 +38,6 @@ import { OptionsSection } from "./sections/options-section"; import { RoutingSection } from "./sections/routing-section"; import { TestingSection } from "./sections/testing-section"; -function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null { - const trimmed = rawUrl.trim(); - if (!trimmed) return null; - - const candidates = [trimmed]; - if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) { - candidates.push(`https://${trimmed}`); - } - - for (const candidate of candidates) { - try { - const parsed = new URL(candidate); - const hostname = parsed.hostname?.toLowerCase(); - if (!hostname) continue; - return hostname.startsWith("www.") ? hostname.slice(4) : hostname; - } catch { - // ignore - } - } - - return null; -} - export interface ProviderFormProps { mode: "create" | "edit"; onSuccess?: () => void; @@ -101,29 +73,10 @@ function ProviderFormContent({ const isEdit = mode === "edit"; const queryClient = useQueryClient(); - const { data: vendors = [] } = useQuery({ - queryKey: ["provider-vendors"], - queryFn: getProviderVendors, - staleTime: 60_000, - refetchOnWindowFocus: false, - }); - - const websiteDomain = useMemo( - () => normalizeWebsiteDomainFromUrl(state.basic.websiteUrl), - [state.basic.websiteUrl] - ); const resolvedEndpointPoolVendorId = useMemo(() => { - // Edit mode: vendor id already attached to provider record - if (isEdit) { - return provider?.providerVendorId ?? null; - } - - // Create/clone: resolve vendor from websiteUrl hostname - if (!websiteDomain) return null; - const vendor = vendors.find((v) => v.websiteDomain === websiteDomain); - return vendor?.id ?? null; - }, [isEdit, provider?.providerVendorId, vendors, websiteDomain]); + return isEdit ? (provider?.providerVendorId ?? null) : null; + }, [isEdit, provider?.providerVendorId]); const endpointPoolQueryKey = useMemo(() => { if (resolvedEndpointPoolVendorId == null) return null; @@ -162,22 +115,6 @@ function ProviderFormContent({ !hideUrl && resolvedEndpointPoolVendorId != null && endpointPoolHasEnabledEndpoints; // Keep state.basic.url usable across other sections when legacy URL input is hidden. - useEffect(() => { - if (isEdit) return; - if (hideUrl) return; - if (!endpointPoolHideLegacyUrlInput) return; - if (!endpointPoolPreferredUrl) return; - if (state.basic.url.trim()) return; - dispatch({ type: "SET_URL", payload: endpointPoolPreferredUrl }); - }, [ - isEdit, - hideUrl, - endpointPoolHideLegacyUrlInput, - endpointPoolPreferredUrl, - state.basic.url, - dispatch, - ]); - // Update URL when resolved URL changes useEffect(() => { if (resolvedUrl && !state.basic.url && !isEdit) { diff --git a/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx b/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx index 071affb62..220ef98da 100644 --- a/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx @@ -234,6 +234,7 @@ function EndpointRow({ circuitState: EndpointCircuitState | null; }) { const t = useTranslations("settings.providers"); + const tErrors = useTranslations("errors"); const tStatus = useTranslations("settings.providers.endpointStatus"); const tCommon = useTranslations("settings.common"); const queryClient = useQueryClient(); @@ -274,7 +275,9 @@ function EndpointRow({ const deleteMutation = useMutation({ mutationFn: async () => { const res = await removeProviderEndpoint({ endpointId: endpoint.id }); - if (!res.ok) throw new Error(res.error); + if (!res.ok) { + throw Object.assign(new Error(res.error), { actionResult: res }); + } return res.data; }, onSuccess: () => { @@ -282,8 +285,21 @@ function EndpointRow({ queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); toast.success(t("endpointDeleteSuccess")); }, - onError: () => { - toast.error(t("endpointDeleteFailed")); + onError: ( + error: Error & { + actionResult?: { + error?: string; + errorCode?: string; + errorParams?: Record; + }; + } + ) => { + const actionResult = error.actionResult; + toast.error( + actionResult?.errorCode + ? getErrorMessage(tErrors, actionResult.errorCode, actionResult.errorParams) + : (actionResult?.error ?? t("endpointDeleteFailed")) + ); }, }); diff --git a/src/lib/provider-endpoint-error-codes.ts b/src/lib/provider-endpoint-error-codes.ts index 45439d696..a3f35bbeb 100644 --- a/src/lib/provider-endpoint-error-codes.ts +++ b/src/lib/provider-endpoint-error-codes.ts @@ -1,3 +1,5 @@ export const PROVIDER_ENDPOINT_CONFLICT_CODE = "PROVIDER_ENDPOINT_CONFLICT"; export const PROVIDER_ENDPOINT_WRITE_READ_INCONSISTENCY_CODE = "PROVIDER_ENDPOINT_WRITE_READ_INCONSISTENCY"; +export const ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE = + "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS"; diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index e29256f6c..aa2276e3d 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -2033,6 +2033,64 @@ export class SessionManager { } } + static async terminateProviderSessionsBatch(providerIds: number[]): Promise { + const uniqueProviderIds = Array.from( + new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0)) + ); + if (uniqueProviderIds.length === 0) { + return 0; + } + + const redis = getRedisClient(); + if (!redis || redis.status !== "ready") { + logger.warn("SessionManager: Redis not ready, cannot terminate provider sessions"); + return 0; + } + + try { + const pipeline = redis.pipeline(); + for (const providerId of uniqueProviderIds) { + pipeline.zrange(`provider:${providerId}:active_sessions`, 0, -1); + } + + const results = await pipeline.exec(); + if (!results) { + return 0; + } + + const sessionIds = new Set(); + for (const [, result] of results) { + if (!Array.isArray(result)) { + continue; + } + + for (const sessionId of result) { + if (typeof sessionId === "string" && sessionId.trim()) { + sessionIds.add(sessionId); + } + } + } + + if (sessionIds.size === 0) { + return 0; + } + + const terminatedCount = await SessionManager.terminateSessionsBatch([...sessionIds]); + logger.info("SessionManager: Terminated provider sessions batch", { + providerIds: uniqueProviderIds, + sessionCount: sessionIds.size, + terminatedCount, + }); + return terminatedCount; + } catch (error) { + logger.error("SessionManager: Failed to terminate provider sessions batch", { + error, + providerIds: uniqueProviderIds, + }); + return 0; + } + } + /** * 批量终止 Session * diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index d100eedfd..25edacfde 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -75,7 +75,7 @@ function toNullableDate(value: unknown): Date | null { return toDate(value); } -function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null { +function normalizeWebsiteDomainKeyFromUrl(rawUrl: string): string | null { const trimmed = rawUrl.trim(); if (!trimmed) return null; @@ -89,9 +89,16 @@ function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null { const parsed = new URL(candidate); const hostname = parsed.hostname?.toLowerCase(); if (!hostname) continue; - return hostname.startsWith("www.") ? hostname.slice(4) : hostname; + + const normalizedHostname = hostname.startsWith("www.") ? hostname.slice(4) : hostname; + + if (!parsed.port) { + return normalizedHostname; + } + + return `${normalizedHostname}:${parsed.port}`; } catch (error) { - logger.debug("[ProviderVendor] Failed to parse URL", { + logger.debug("[ProviderVendor] Failed to parse website URL for vendor key", { candidateLength: candidate.length, error: error instanceof Error ? error.message : String(error), }); @@ -150,7 +157,9 @@ function normalizeHostWithPort(rawUrl: string): string | null { * Compute vendor clustering key based on URLs. * * Rules: - * - If websiteUrl is non-empty: key = normalized hostname (strip www, lowercase), ignore port + * - If websiteUrl is non-empty: + * - hostname only when no explicit port + * - host:port when an explicit port is present * - If websiteUrl is empty: key = host:port * - IPv6 format: [ipv6]:port * - Missing port: use protocol default (http=80, https=443) @@ -164,7 +173,7 @@ export async function computeVendorKey(input: { // Case 1: websiteUrl is non-empty - use hostname only (existing behavior) if (websiteUrl?.trim()) { - return normalizeWebsiteDomainFromUrl(websiteUrl); + return normalizeWebsiteDomainKeyFromUrl(websiteUrl); } // Case 2: websiteUrl is empty - use host:port as key @@ -739,6 +748,64 @@ export async function hasEnabledProviderReferenceForVendorTypeUrl(input: { return Boolean(row); } +export async function findEnabledProviderReferencesForVendorTypeUrl(input: { + vendorId: number; + providerType: ProviderType; + url: string; + excludeProviderId?: number; +}): Promise> { + const trimmedUrl = input.url.trim(); + if (!trimmedUrl) { + return []; + } + + const whereClauses = [ + eq(providers.providerVendorId, input.vendorId), + eq(providers.providerType, input.providerType), + eq(providers.url, trimmedUrl), + eq(providers.isEnabled, true), + isNull(providers.deletedAt), + ]; + + if (input.excludeProviderId != null) { + whereClauses.push(ne(providers.id, input.excludeProviderId)); + } + + const rows = await db + .select({ + id: providers.id, + name: providers.name, + }) + .from(providers) + .where(and(...whereClauses)) + .orderBy(asc(providers.id)); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + })); +} + +export async function findEnabledProviderIdsByVendorAndType( + vendorId: number, + providerType: ProviderType +): Promise { + const rows = await db + .select({ id: providers.id }) + .from(providers) + .where( + and( + eq(providers.providerVendorId, vendorId), + eq(providers.providerType, providerType), + eq(providers.isEnabled, true), + isNull(providers.deletedAt) + ) + ) + .orderBy(asc(providers.id)); + + return rows.map((row) => row.id); +} + /** * Dashboard/Endpoint Health 用:仅在存在启用 provider 的前提下返回该 vendor/type 的端点池。 * diff --git a/tests/unit/actions/provider-endpoints.test.ts b/tests/unit/actions/provider-endpoints.test.ts index 3f1a2fe3d..fd4b85b0b 100644 --- a/tests/unit/actions/provider-endpoints.test.ts +++ b/tests/unit/actions/provider-endpoints.test.ts @@ -13,7 +13,10 @@ const updateProviderEndpointMock = vi.fn(); const findProviderEndpointProbeLogsBatchMock = vi.fn(); const findVendorTypeEndpointStatsBatchMock = vi.fn(); const hasEnabledProviderReferenceForVendorTypeUrlMock = vi.fn(); +const findEnabledProviderReferencesForVendorTypeUrlMock = vi.fn(); +const findEnabledProviderIdsByVendorAndTypeMock = vi.fn(); const findDashboardProviderEndpointsByVendorAndTypeMock = vi.fn(); +const terminateProviderSessionsBatchMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -56,6 +59,12 @@ vi.mock("@/lib/provider-endpoints/probe", () => ({ probeProviderEndpointAndRecordByEndpoint: vi.fn(async () => null), })); +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + terminateProviderSessionsBatch: terminateProviderSessionsBatchMock, + }, +})); + vi.mock("@/repository/provider-endpoints-batch", () => ({ findProviderEndpointProbeLogsBatch: findProviderEndpointProbeLogsBatchMock, findVendorTypeEndpointStatsBatch: findVendorTypeEndpointStatsBatchMock, @@ -65,6 +74,8 @@ vi.mock("@/repository/provider-endpoints", () => ({ findDashboardProviderEndpointsByVendorAndType: findDashboardProviderEndpointsByVendorAndTypeMock, findEnabledProviderVendorTypePairs: vi.fn(async () => []), hasEnabledProviderReferenceForVendorTypeUrl: hasEnabledProviderReferenceForVendorTypeUrlMock, + findEnabledProviderReferencesForVendorTypeUrl: findEnabledProviderReferencesForVendorTypeUrlMock, + findEnabledProviderIdsByVendorAndType: findEnabledProviderIdsByVendorAndTypeMock, })); vi.mock("@/repository", () => ({ @@ -85,7 +96,10 @@ describe("provider-endpoints actions", () => { beforeEach(() => { vi.clearAllMocks(); hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false); + findEnabledProviderReferencesForVendorTypeUrlMock.mockResolvedValue([]); + findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([]); findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue([]); + terminateProviderSessionsBatchMock.mockResolvedValue(0); }); it("editProviderVendor: requires admin", async () => { @@ -345,6 +359,7 @@ describe("provider-endpoints actions", () => { softDeleteProviderEndpointMock.mockResolvedValue(true); tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true); hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false); + findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([11, 12]); const { removeProviderEndpoint } = await import("@/actions/provider-endpoints"); const res = await removeProviderEndpoint({ endpointId: 99 }); @@ -354,6 +369,88 @@ describe("provider-endpoints actions", () => { const { resetEndpointCircuit } = await import("@/lib/endpoint-circuit-breaker"); expect(resetEndpointCircuit).toHaveBeenCalledWith(99); expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([11, 12]); + }); + + it("removeProviderEndpoint: returns detailed conflict when endpoint is still referenced", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + findProviderEndpointByIdMock.mockResolvedValue({ + id: 99, + vendorId: 123, + providerType: "claude", + url: "https://api.example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }); + hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(true); + findEnabledProviderReferencesForVendorTypeUrlMock.mockResolvedValue([ + { id: 1, name: "CPA Primary" }, + { id: 2, name: "CPA Backup" }, + { id: 3, name: "CPA Canary" }, + { id: 4, name: "CPA Extra" }, + ]); + + const { removeProviderEndpoint } = await import("@/actions/provider-endpoints"); + const res = await removeProviderEndpoint({ endpointId: 99 }); + + expect(res.ok).toBe(false); + expect(res.errorCode).toBe("ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS"); + expect(res.errorParams).toEqual({ + count: 4, + providers: "CPA Primary, CPA Backup, CPA Canary +1", + }); + expect(softDeleteProviderEndpointMock).not.toHaveBeenCalled(); + expect(terminateProviderSessionsBatchMock).not.toHaveBeenCalled(); + }); + + it("editProviderEndpoint: route-affecting changes terminate affected provider sessions", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + const endpoint = { + id: 42, + vendorId: 123, + providerType: "claude" as const, + url: "https://next.example.com/v1/messages", + label: "primary", + sortOrder: 7, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + deletedAt: null, + }; + + findProviderEndpointByIdMock.mockResolvedValue(endpoint); + updateProviderEndpointMock.mockResolvedValue({ + ...endpoint, + isEnabled: false, + }); + findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([7, 8]); + + const { editProviderEndpoint } = await import("@/actions/provider-endpoints"); + const res = await editProviderEndpoint({ + endpointId: 42, + isEnabled: false, + }); + + expect(res.ok).toBe(true); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([7, 8]); }); it("probeProviderEndpoint: calls probeProviderEndpointAndRecordByEndpoint and returns result", async () => { diff --git a/tests/unit/actions/providers.test.ts b/tests/unit/actions/providers.test.ts index b219ff4c0..3545f0b7e 100644 --- a/tests/unit/actions/providers.test.ts +++ b/tests/unit/actions/providers.test.ts @@ -15,6 +15,7 @@ const saveProviderCircuitConfigMock = vi.fn(); const deleteProviderCircuitConfigMock = vi.fn(); const clearConfigCacheMock = vi.fn(); const clearProviderStateMock = vi.fn(); +const terminateProviderSessionsBatchMock = vi.fn(); const revalidatePathMock = vi.fn(); @@ -50,6 +51,12 @@ vi.mock("@/lib/circuit-breaker", () => ({ resetCircuit: vi.fn(), })); +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + terminateProviderSessionsBatch: terminateProviderSessionsBatchMock, + }, +})); + vi.mock("@/lib/logger", () => ({ logger: { trace: vi.fn(), @@ -167,6 +174,7 @@ describe("Provider Actions - Async Optimization", () => { saveProviderCircuitConfigMock.mockResolvedValue(undefined); deleteProviderCircuitConfigMock.mockResolvedValue(undefined); clearProviderStateMock.mockResolvedValue(undefined); + terminateProviderSessionsBatchMock.mockResolvedValue(0); updateProviderPrioritiesBatchMock.mockResolvedValue(0); }); @@ -502,6 +510,7 @@ describe("Provider Actions - Async Optimization", () => { expect(result.ok).toBe(true); expect(revalidatePathMock).not.toHaveBeenCalled(); + expect(terminateProviderSessionsBatchMock).not.toHaveBeenCalled(); }); it("editProvider endpoint sync: should forward url/provider_type edits to repository", async () => { @@ -522,6 +531,7 @@ describe("Provider Actions - Async Optimization", () => { }) ); expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); }); it("editProvider endpoint sync: should generate favicon_url when website_url is updated", async () => { @@ -543,6 +553,7 @@ describe("Provider Actions - Async Optimization", () => { favicon_url: "https://www.google.com/s2/favicons?domain=vendor.example.com&sz=32", }) ); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); }); it("editProvider endpoint sync: should clear favicon_url when website_url is cleared", async () => { @@ -561,6 +572,26 @@ describe("Provider Actions - Async Optimization", () => { favicon_url: null, }) ); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); + }); + + it("editProvider: group or allowlist changes should also terminate sticky sessions", async () => { + const { editProvider } = await import("@/actions/providers"); + + const result = await editProvider(1, { + group_tag: "gpt-load", + allowed_models: ["gpt-4.1"], + }); + + expect(result.ok).toBe(true); + expect(updateProviderMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + group_tag: "gpt-load", + allowed_models: ["gpt-4.1"], + }) + ); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); }); }); @@ -571,6 +602,7 @@ describe("Provider Actions - Async Optimization", () => { expect(result.ok).toBe(true); expect(revalidatePathMock).not.toHaveBeenCalled(); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); }); }); }); diff --git a/tests/unit/lib/session-manager-terminate-provider-sessions.test.ts b/tests/unit/lib/session-manager-terminate-provider-sessions.test.ts new file mode 100644 index 000000000..665a979c0 --- /dev/null +++ b/tests/unit/lib/session-manager-terminate-provider-sessions.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let redisClientRef: any; +let pipelineRef: any; + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/logger", () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => redisClientRef, +})); + +describe("SessionManager.terminateProviderSessionsBatch", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.resetModules(); + + pipelineRef = { + zrange: vi.fn(() => pipelineRef), + exec: vi.fn(async () => [ + [null, ["sess-a", "sess-b"]], + [null, ["sess-b", "sess-c"]], + ]), + }; + + redisClientRef = { + status: "ready", + pipeline: vi.fn(() => pipelineRef), + }; + }); + + it("should collect unique session ids from provider active-session zsets and terminate them in batch", async () => { + const { SessionManager } = await import("@/lib/session-manager"); + const terminateSessionsBatchSpy = vi + .spyOn(SessionManager, "terminateSessionsBatch") + .mockResolvedValue(3); + + const result = await SessionManager.terminateProviderSessionsBatch([42, 43, 42, 0]); + + expect(result).toBe(3); + expect(pipelineRef.zrange).toHaveBeenCalledTimes(2); + expect(pipelineRef.zrange).toHaveBeenCalledWith("provider:42:active_sessions", 0, -1); + expect(pipelineRef.zrange).toHaveBeenCalledWith("provider:43:active_sessions", 0, -1); + expect(terminateSessionsBatchSpy).toHaveBeenCalledWith(["sess-a", "sess-b", "sess-c"]); + }); +}); diff --git a/tests/unit/repository/provider-endpoints-vendor-key.test.ts b/tests/unit/repository/provider-endpoints-vendor-key.test.ts index dc5deef3c..4d0ca8860 100644 --- a/tests/unit/repository/provider-endpoints-vendor-key.test.ts +++ b/tests/unit/repository/provider-endpoints-vendor-key.test.ts @@ -3,15 +3,24 @@ import { computeVendorKey } from "@/repository/provider-endpoints"; describe("computeVendorKey", () => { describe("with websiteUrl (priority over providerUrl)", () => { - test("returns hostname only, ignoring port", async () => { + test("returns hostname only when websiteUrl has no explicit port", async () => { expect( await computeVendorKey({ providerUrl: "https://api.example.com:8080/v1/messages", - websiteUrl: "https://example.com:3000", + websiteUrl: "https://example.com", }) ).toBe("example.com"); }); + test("preserves explicit websiteUrl port for local/self-hosted vendors", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://api.example.com:8080/v1/messages", + websiteUrl: "https://example.com:3000", + }) + ).toBe("example.com:3000"); + }); + test("strips www prefix", async () => { expect( await computeVendorKey({ @@ -38,6 +47,21 @@ describe("computeVendorKey", () => { }) ).toBe("example.com"); }); + + test("keeps distinct ports as distinct vendor keys when websiteUrl contains explicit ports", async () => { + const key1 = await computeVendorKey({ + providerUrl: "http://192.168.1.1:8080/v1/messages", + websiteUrl: "http://192.168.1.1:111", + }); + const key2 = await computeVendorKey({ + providerUrl: "http://192.168.1.1:9090/v1/messages", + websiteUrl: "http://192.168.1.1:222", + }); + + expect(key1).toBe("192.168.1.1:111"); + expect(key2).toBe("192.168.1.1:222"); + expect(key1).not.toBe(key2); + }); }); describe("without websiteUrl (fallback to providerUrl with host:port)", () => { diff --git a/tests/unit/repository/statistics-quota-costs-all-time.test.ts b/tests/unit/repository/statistics-quota-costs-all-time.test.ts index 3ca53ffb2..629e2a906 100644 --- a/tests/unit/repository/statistics-quota-costs-all-time.test.ts +++ b/tests/unit/repository/statistics-quota-costs-all-time.test.ts @@ -41,11 +41,28 @@ describe("sumUserQuotaCosts & sumKeyQuotaCostsById - all-time query support", () vi.doMock("drizzle-orm", async () => { const actual = await vi.importActual("drizzle-orm"); + const sqlMock = Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]) => ({ + queryChunks: [...strings, ...values], + }), + { + raw: vi.fn(), + join: vi.fn(), + identifier: vi.fn(), + } + ); return { ...actual, + relations: vi.fn(() => ({})), + eq: vi.fn((...args: unknown[]) => ({ kind: "eq", args })), + gte: vi.fn((...args: unknown[]) => ({ kind: "gte", args })), + inArray: vi.fn((...args: unknown[]) => ({ kind: "inArray", args })), + isNull: vi.fn((...args: unknown[]) => ({ kind: "isNull", args })), + lt: vi.fn((...args: unknown[]) => ({ kind: "lt", args })), + sql: sqlMock, and: (...args: unknown[]) => { capturedAndArgs = args; - return (actual as any).and(...args); + return { kind: "and", args }; }, }; }); @@ -88,11 +105,28 @@ describe("sumUserQuotaCosts & sumKeyQuotaCostsById - all-time query support", () vi.doMock("drizzle-orm", async () => { const actual = await vi.importActual("drizzle-orm"); + const sqlMock = Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]) => ({ + queryChunks: [...strings, ...values], + }), + { + raw: vi.fn(), + join: vi.fn(), + identifier: vi.fn(), + } + ); return { ...actual, + relations: vi.fn(() => ({})), + eq: vi.fn((...args: unknown[]) => ({ kind: "eq", args })), + gte: vi.fn((...args: unknown[]) => ({ kind: "gte", args })), + inArray: vi.fn((...args: unknown[]) => ({ kind: "inArray", args })), + isNull: vi.fn((...args: unknown[]) => ({ kind: "isNull", args })), + lt: vi.fn((...args: unknown[]) => ({ kind: "lt", args })), + sql: sqlMock, and: (...args: unknown[]) => { capturedAndArgs = args; - return (actual as any).and(...args); + return { kind: "and", args }; }, }; }); diff --git a/tests/unit/settings/providers/provider-endpoints-table.test.tsx b/tests/unit/settings/providers/provider-endpoints-table.test.tsx index 11482e9c0..5342f853b 100644 --- a/tests/unit/settings/providers/provider-endpoints-table.test.tsx +++ b/tests/unit/settings/providers/provider-endpoints-table.test.tsx @@ -102,6 +102,7 @@ function loadMessages() { } let queryClient: QueryClient; +const confirmMock = vi.fn(() => true); function renderWithProviders(node: ReactNode) { const container = document.createElement("div"); @@ -143,6 +144,8 @@ describe("ProviderEndpointsTable", () => { }, }); vi.clearAllMocks(); + confirmMock.mockReturnValue(true); + vi.stubGlobal("confirm", confirmMock); while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } @@ -270,6 +273,56 @@ describe("ProviderEndpointsTable", () => { unmount(); }); + test("delete failure should show translated reference detail instead of generic error", async () => { + providerEndpointsActionMocks.removeProviderEndpoint.mockResolvedValueOnce({ + ok: false, + errorCode: "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS", + errorParams: { + count: 2, + providers: "CPA Primary, CPA Backup", + }, + } as any); + + const { unmount } = renderWithProviders(); + + await flushTicks(6); + + const endpointRow = Array.from(document.querySelectorAll("tr")).find((row) => + row.textContent?.includes("https://api.claude.example.com/v1") + ); + expect(endpointRow).toBeDefined(); + + const buttons = endpointRow?.querySelectorAll("button"); + const moreButton = buttons?.[buttons.length - 1] as HTMLButtonElement | undefined; + expect(moreButton).toBeDefined(); + + act(() => { + moreButton?.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + moreButton?.click(); + }); + + await flushTicks(2); + + const deleteItem = Array.from( + document.querySelectorAll("[data-slot='dropdown-menu-item']") + ).find((item) => item.textContent?.includes("Delete")) as HTMLElement | undefined; + + expect(deleteItem).toBeDefined(); + + act(() => { + deleteItem?.click(); + }); + + await flushTicks(4); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(sonnerMocks.toast.error).toHaveBeenCalledWith( + "This endpoint is still referenced by 2 enabled providers: CPA Primary, CPA Backup" + ); + + unmount(); + }); + test("shows empty state when no endpoints", async () => { providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([]); diff --git a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx index edf4f19c4..98aa2757c 100644 --- a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx +++ b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx @@ -11,7 +11,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { ProviderForm } from "../../../../src/app/[locale]/settings/providers/_components/forms/provider-form"; import { Dialog } from "../../../../src/components/ui/dialog"; import enMessages from "../../../../messages/en"; -import type { ProviderEndpoint, ProviderVendor } from "../../../../src/types/provider"; +import type { + ProviderDisplay, + ProviderEndpoint, + ProviderVendor, +} from "../../../../src/types/provider"; const sonnerMocks = vi.hoisted(() => ({ toast: { @@ -111,6 +115,68 @@ function setNativeValue(element: HTMLInputElement, value: string) { element.value = value; } +function makeCloneProvider(overrides: Partial = {}): ProviderDisplay { + return { + id: 88, + name: "CPA Provider", + url: "https://old.example.com/v1/messages", + maskedKey: "sk-****1234", + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + providerVendorId: 1, + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + allowedClients: [], + blockedClients: [], + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 60000, + requestTimeoutNonStreamingMs: 120000, + websiteUrl: "https://example.com", + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + codexServiceTierPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + ...overrides, + }; +} + describe("ProviderForm: endpoint pool integration", () => { beforeEach(() => { queryClient = new QueryClient({ @@ -144,7 +210,7 @@ describe("ProviderForm: endpoint pool integration", () => { unmount(); }); - test("When vendor resolves and endpoints exist, should show endpoint pool and hide URL input", async () => { + test("Create mode should keep manual URL input even when websiteUrl matches an existing vendor", async () => { providerEndpointsActionMocks.getProviderVendors.mockResolvedValueOnce([ { id: 1, @@ -195,9 +261,94 @@ describe("ProviderForm: endpoint pool integration", () => { await flushTicks(6); - expect(document.getElementById("url")).toBeNull(); - expect(document.body.textContent || "").toContain("Endpoints"); - expect(document.body.textContent || "").toContain("Add Endpoint"); + expect(providerEndpointsActionMocks.getProviderEndpoints).not.toHaveBeenCalled(); + expect(document.getElementById("url")).toBeTruthy(); + expect(document.body.textContent || "").not.toContain("Add Endpoint"); + + unmount(); + }); + + test("Clone mode should submit the explicit URL instead of inheriting an existing vendor endpoint pool", async () => { + providerEndpointsActionMocks.getProviderVendors.mockResolvedValueOnce([ + { + id: 1, + websiteDomain: "example.com", + displayName: "Example", + websiteUrl: "https://example.com", + faviconUrl: null, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + }, + ]); + providerEndpointsActionMocks.getProviderEndpoints.mockResolvedValueOnce([ + { + id: 10, + vendorId: 1, + providerType: "claude", + url: "https://api.example.com/v1", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + deletedAt: null, + }, + ]); + + const { unmount } = renderWithProviders( + + ); + + await flushTicks(2); + + const nameInput = document.getElementById("name") as HTMLInputElement | null; + const urlInput = document.getElementById("url") as HTMLInputElement | null; + const keyInput = document.getElementById("key") as HTMLInputElement | null; + + expect(nameInput).toBeTruthy(); + expect(urlInput).toBeTruthy(); + expect(keyInput).toBeTruthy(); + + await act(async () => { + if (!nameInput || !urlInput || !keyInput) return; + setNativeValue(nameInput, "CPA Provider_Copy"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + nameInput.dispatchEvent(new Event("change", { bubbles: true })); + + setNativeValue(urlInput, "https://manual.example.com/v1/messages"); + urlInput.dispatchEvent(new Event("input", { bubbles: true })); + urlInput.dispatchEvent(new Event("change", { bubbles: true })); + + setNativeValue(keyInput, "new-key"); + keyInput.dispatchEvent(new Event("input", { bubbles: true })); + keyInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const form = document.body.querySelector("form") as HTMLFormElement | null; + expect(form).toBeTruthy(); + + await act(async () => { + form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + + for (let i = 0; i < 8; i++) { + if (providersActionMocks.addProvider.mock.calls.length > 0) break; + await flushTicks(1); + } + + expect(providerEndpointsActionMocks.getProviderEndpoints).not.toHaveBeenCalled(); + expect(providersActionMocks.addProvider).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://manual.example.com/v1/messages", + website_url: "https://example.com", + }) + ); unmount(); }); From 672ed8c02fa48222d64a32c256d7a0e453e3973a Mon Sep 17 00:00:00 2001 From: Ding Date: Sun, 15 Mar 2026 04:31:39 +0000 Subject: [PATCH 2/3] fix: address PR #919 review comments - Extract duplicate terminateStickySessionsForProviders into SessionManager static method (DRY) - Fix sticky session invalidation to check actual value changes via preimageFields instead of raw payload keys (logic bug) - Add missing allowed_clients/blocked_clients to preimage mapping - Handle Redis pipeline errors in terminateProviderSessionsBatch instead of silently dropping them - Remove stale hasEnabledProviderReferenceForVendorTypeUrlMock from tests --- src/actions/provider-endpoints.ts | 28 +++------- src/actions/providers.ts | 55 ++++++++----------- src/lib/session-manager.ts | 29 +++++++++- tests/unit/actions/provider-endpoints.test.ts | 16 +++--- tests/unit/actions/providers.test.ts | 11 ++-- 5 files changed, 73 insertions(+), 66 deletions(-) diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index d44ec4c9e..6e3d2e889 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -243,24 +243,6 @@ function formatProviderReferenceSummary( return `${displayed} +${uniqueNames.length - maxDisplayCount}`; } -async function terminateStickySessionsForProviders(providerIds: number[], context: string) { - const uniqueProviderIds = Array.from( - new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0)) - ); - if (uniqueProviderIds.length === 0) { - return; - } - - try { - await SessionManager.terminateProviderSessionsBatch(uniqueProviderIds); - } catch (error) { - logger.warn(`${context}:terminate_provider_sessions_failed`, { - providerIds: uniqueProviderIds, - error: error instanceof Error ? error.message : String(error), - }); - } -} - export async function getProviderVendors(): Promise { try { const session = await getAdminSession(); @@ -547,7 +529,10 @@ export async function editProviderEndpoint( endpoint.vendorId, endpoint.providerType ); - await terminateStickySessionsForProviders(affectedProviderIds, "editProviderEndpoint"); + await SessionManager.terminateStickySessionsForProviders( + affectedProviderIds, + "editProviderEndpoint" + ); } try { @@ -645,7 +630,10 @@ export async function removeProviderEndpoint(input: unknown): Promise([ "url", - "website_url", - "provider_type", - "group_tag", - "is_enabled", - "allowed_models", - "allowed_clients", - "blocked_clients", - "model_redirects", - "active_time_start", - "active_time_end", + "websiteUrl", + "providerType", + "groupTag", + "isEnabled", + "allowedModels", + "allowedClients", + "blockedClients", + "modelRedirects", + "activeTimeStart", + "activeTimeEnd", ]); -function shouldInvalidateStickySessionsOnProviderEdit(data: Record): boolean { - return Object.keys(data).some((key) => STICKY_SESSION_INVALIDATING_PROVIDER_FIELDS.has(key)); -} - -async function terminateStickySessionsForProviders(providerIds: number[], context: string) { - const uniqueProviderIds = Array.from( - new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0)) +function shouldInvalidateStickySessionsOnProviderEdit( + changedProviderFields: Record +): boolean { + return Object.keys(changedProviderFields).some((key) => + STICKY_SESSION_INVALIDATING_PROVIDER_KEYS.has(key) ); - if (uniqueProviderIds.length === 0) { - return; - } - - try { - await SessionManager.terminateProviderSessionsBatch(uniqueProviderIds); - } catch (error) { - logger.warn(`${context}:terminate_provider_sessions_failed`, { - providerIds: uniqueProviderIds, - error: error instanceof Error ? error.message : String(error), - }); - } } + // 获取服务商数据 export async function getProviders(): Promise { try { @@ -813,8 +800,8 @@ export async function editProvider( return { ok: false, error: "供应商不存在" }; } - if (shouldInvalidateStickySessionsOnProviderEdit(payload)) { - await terminateStickySessionsForProviders([providerId], "editProvider"); + if (shouldInvalidateStickySessionsOnProviderEdit(preimageFields)) { + await SessionManager.terminateStickySessionsForProviders([providerId], "editProvider"); } // 同步熔断器配置到 Redis(如果配置有变化) @@ -889,7 +876,7 @@ export async function removeProvider( const provider = await findProviderById(providerId); await deleteProvider(providerId); - await terminateStickySessionsForProviders([providerId], "removeProvider"); + await SessionManager.terminateStickySessionsForProviders([providerId], "removeProvider"); const undoToken = createProviderPatchUndoToken(); const operationId = createProviderPatchOperationId(); @@ -1323,6 +1310,8 @@ const SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY: Record active_time_end: "activeTimeEnd", model_redirects: "modelRedirects", allowed_models: "allowedModels", + allowed_clients: "allowedClients", + blocked_clients: "blockedClients", limit_5h_usd: "limit5hUsd", limit_daily_usd: "limitDailyUsd", daily_reset_mode: "dailyResetMode", diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index aa2276e3d..8df336b1e 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -2059,7 +2059,13 @@ export class SessionManager { } const sessionIds = new Set(); - for (const [, result] of results) { + for (const [err, result] of results) { + if (err) { + logger.warn("SessionManager: Pipeline command error in terminateProviderSessionsBatch", { + error: err, + }); + continue; + } if (!Array.isArray(result)) { continue; } @@ -2091,6 +2097,27 @@ export class SessionManager { } } + static async terminateStickySessionsForProviders( + providerIds: number[], + context: string + ): Promise { + const uniqueProviderIds = Array.from( + new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0)) + ); + if (uniqueProviderIds.length === 0) { + return; + } + + try { + await SessionManager.terminateProviderSessionsBatch(uniqueProviderIds); + } catch (error) { + logger.warn(`${context}:terminate_provider_sessions_failed`, { + providerIds: uniqueProviderIds, + error: error instanceof Error ? error.message : String(error), + }); + } + } + /** * 批量终止 Session * diff --git a/tests/unit/actions/provider-endpoints.test.ts b/tests/unit/actions/provider-endpoints.test.ts index fd4b85b0b..94ff45564 100644 --- a/tests/unit/actions/provider-endpoints.test.ts +++ b/tests/unit/actions/provider-endpoints.test.ts @@ -12,7 +12,6 @@ const tryDeleteProviderVendorIfEmptyMock = vi.fn(); const updateProviderEndpointMock = vi.fn(); const findProviderEndpointProbeLogsBatchMock = vi.fn(); const findVendorTypeEndpointStatsBatchMock = vi.fn(); -const hasEnabledProviderReferenceForVendorTypeUrlMock = vi.fn(); const findEnabledProviderReferencesForVendorTypeUrlMock = vi.fn(); const findEnabledProviderIdsByVendorAndTypeMock = vi.fn(); const findDashboardProviderEndpointsByVendorAndTypeMock = vi.fn(); @@ -62,6 +61,7 @@ vi.mock("@/lib/provider-endpoints/probe", () => ({ vi.mock("@/lib/session-manager", () => ({ SessionManager: { terminateProviderSessionsBatch: terminateProviderSessionsBatchMock, + terminateStickySessionsForProviders: terminateProviderSessionsBatchMock, }, })); @@ -73,7 +73,6 @@ vi.mock("@/repository/provider-endpoints-batch", () => ({ vi.mock("@/repository/provider-endpoints", () => ({ findDashboardProviderEndpointsByVendorAndType: findDashboardProviderEndpointsByVendorAndTypeMock, findEnabledProviderVendorTypePairs: vi.fn(async () => []), - hasEnabledProviderReferenceForVendorTypeUrl: hasEnabledProviderReferenceForVendorTypeUrlMock, findEnabledProviderReferencesForVendorTypeUrl: findEnabledProviderReferencesForVendorTypeUrlMock, findEnabledProviderIdsByVendorAndType: findEnabledProviderIdsByVendorAndTypeMock, })); @@ -95,7 +94,6 @@ vi.mock("@/repository", () => ({ describe("provider-endpoints actions", () => { beforeEach(() => { vi.clearAllMocks(); - hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false); findEnabledProviderReferencesForVendorTypeUrlMock.mockResolvedValue([]); findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([]); findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue([]); @@ -358,7 +356,6 @@ describe("provider-endpoints actions", () => { }); softDeleteProviderEndpointMock.mockResolvedValue(true); tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true); - hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false); findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([11, 12]); const { removeProviderEndpoint } = await import("@/actions/provider-endpoints"); @@ -369,7 +366,10 @@ describe("provider-endpoints actions", () => { const { resetEndpointCircuit } = await import("@/lib/endpoint-circuit-breaker"); expect(resetEndpointCircuit).toHaveBeenCalledWith(99); expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([11, 12]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith( + [11, 12], + "removeProviderEndpoint" + ); }); it("removeProviderEndpoint: returns detailed conflict when endpoint is still referenced", async () => { @@ -393,7 +393,6 @@ describe("provider-endpoints actions", () => { updatedAt: new Date(), deletedAt: null, }); - hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(true); findEnabledProviderReferencesForVendorTypeUrlMock.mockResolvedValue([ { id: 1, name: "CPA Primary" }, { id: 2, name: "CPA Backup" }, @@ -450,7 +449,10 @@ describe("provider-endpoints actions", () => { }); expect(res.ok).toBe(true); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([7, 8]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith( + [7, 8], + "editProviderEndpoint" + ); }); it("probeProviderEndpoint: calls probeProviderEndpointAndRecordByEndpoint and returns result", async () => { diff --git a/tests/unit/actions/providers.test.ts b/tests/unit/actions/providers.test.ts index 3545f0b7e..beeea7911 100644 --- a/tests/unit/actions/providers.test.ts +++ b/tests/unit/actions/providers.test.ts @@ -54,6 +54,7 @@ vi.mock("@/lib/circuit-breaker", () => ({ vi.mock("@/lib/session-manager", () => ({ SessionManager: { terminateProviderSessionsBatch: terminateProviderSessionsBatchMock, + terminateStickySessionsForProviders: terminateProviderSessionsBatchMock, }, })); @@ -531,7 +532,7 @@ describe("Provider Actions - Async Optimization", () => { }) ); expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider"); }); it("editProvider endpoint sync: should generate favicon_url when website_url is updated", async () => { @@ -553,7 +554,7 @@ describe("Provider Actions - Async Optimization", () => { favicon_url: "https://www.google.com/s2/favicons?domain=vendor.example.com&sz=32", }) ); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider"); }); it("editProvider endpoint sync: should clear favicon_url when website_url is cleared", async () => { @@ -572,7 +573,7 @@ describe("Provider Actions - Async Optimization", () => { favicon_url: null, }) ); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider"); }); it("editProvider: group or allowlist changes should also terminate sticky sessions", async () => { @@ -591,7 +592,7 @@ describe("Provider Actions - Async Optimization", () => { allowed_models: ["gpt-4.1"], }) ); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider"); }); }); @@ -602,7 +603,7 @@ describe("Provider Actions - Async Optimization", () => { expect(result.ok).toBe(true); expect(revalidatePathMock).not.toHaveBeenCalled(); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1]); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "removeProvider"); }); }); }); From 22c68ac12a6342adad47e489c8157965411c5a20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 04:32:10 +0000 Subject: [PATCH 3/3] chore: format code (fix-provider-endpoint-isolation-and-sticky-session-672ed8c) --- src/actions/providers.ts | 1 - tests/unit/actions/provider-endpoints.test.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index fb0f5f2fc..ea3a43af9 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -206,7 +206,6 @@ function shouldInvalidateStickySessionsOnProviderEdit( ); } - // 获取服务商数据 export async function getProviders(): Promise { try { diff --git a/tests/unit/actions/provider-endpoints.test.ts b/tests/unit/actions/provider-endpoints.test.ts index 94ff45564..ac287215d 100644 --- a/tests/unit/actions/provider-endpoints.test.ts +++ b/tests/unit/actions/provider-endpoints.test.ts @@ -449,10 +449,7 @@ describe("provider-endpoints actions", () => { }); expect(res.ok).toBe(true); - expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith( - [7, 8], - "editProviderEndpoint" - ); + expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([7, 8], "editProviderEndpoint"); }); it("probeProviderEndpoint: calls probeProviderEndpointAndRecordByEndpoint and returns result", async () => {