Add tests for centered modal adoption, search modal results, and accounts batch actions
- Implement tests for the CenteredModal component to ensure it follows the shared modal shell pattern. - Add comprehensive tests for the SearchModal results, including API interactions and UI behavior. - Create tests for accounts batch actions, verifying the functionality of refreshing balances and selecting accounts via row clicks. - Introduce tests for the accounts centered modal to confirm the use of CenteredModal for add/edit/rebind flows. - Add tests for the accounts edit panel to ensure proper rendering and functionality when opening the edit modal. - Implement tests for the accounts tokens header to verify the reuse of the accounts page header in the tokens segment. - Create tests for the system proxy settings, ensuring the correct saving of the proxy URL. - Add tests for the tokens actions layout to confirm the proper layout and styling of token row actions. - Implement tests for the tokens batch actions, including deletion of selected tokens and row selection behavior. - Introduce tests for the tokens focus navigation to ensure the correct highlighting of focused token rows.
This commit is contained in:
@@ -35,6 +35,28 @@ describe('accountHealthService', () => {
|
||||
expect(health.reason).not.toContain('访问令牌已过期');
|
||||
});
|
||||
|
||||
it('ignores stored auth failures for proxy-only accounts', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
siteStatus: 'active',
|
||||
sessionCapable: false,
|
||||
extraConfig: JSON.stringify({
|
||||
runtimeHealth: {
|
||||
state: 'unhealthy',
|
||||
reason: '访问令牌失效:无权进行此操作,access token 无效',
|
||||
source: 'auth',
|
||||
checkedAt: '2026-03-07T09:00:01.432Z',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'unknown',
|
||||
source: 'none',
|
||||
});
|
||||
expect(health.reason).not.toContain('访问令牌');
|
||||
});
|
||||
|
||||
it('returns stored runtime health from extra config when available', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
|
||||
@@ -82,11 +82,21 @@ export function extractRuntimeHealth(extraConfig?: string | null): RuntimeHealth
|
||||
return normalizeRuntimeHealthRecord(parsed.runtimeHealth);
|
||||
}
|
||||
|
||||
function isProxyOnlyAuthFailure(
|
||||
health: RuntimeHealthInfo | null,
|
||||
sessionCapable?: boolean,
|
||||
): boolean {
|
||||
if (!health || sessionCapable !== false) return false;
|
||||
if (health.state !== 'unhealthy') return false;
|
||||
return (health.source || '').toLowerCase() === 'auth';
|
||||
}
|
||||
|
||||
export function buildRuntimeHealthForAccount(input: {
|
||||
accountStatus?: string | null;
|
||||
siteStatus?: string | null;
|
||||
extraConfig?: string | null;
|
||||
sessionCapable?: boolean;
|
||||
hasDiscoveredModels?: boolean;
|
||||
}): RuntimeHealthInfo {
|
||||
const accountStatus = (input.accountStatus || 'active').toLowerCase();
|
||||
const siteStatus = (input.siteStatus || 'active').toLowerCase();
|
||||
@@ -110,7 +120,23 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
}
|
||||
|
||||
const stored = extractRuntimeHealth(input.extraConfig);
|
||||
if (stored) return stored;
|
||||
const ignoreStoredProxyOnlyAuthFailure = isProxyOnlyAuthFailure(stored, input.sessionCapable);
|
||||
if (
|
||||
stored
|
||||
&& !ignoreStoredProxyOnlyAuthFailure
|
||||
&& !(input.sessionCapable === false && input.hasDiscoveredModels && stored.state === 'unknown')
|
||||
) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (input.sessionCapable === false && input.hasDiscoveredModels) {
|
||||
return {
|
||||
state: 'healthy',
|
||||
reason: '模型探测成功',
|
||||
source: 'model-discovery',
|
||||
checkedAt: stored?.checkedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'unknown',
|
||||
|
||||
@@ -162,6 +162,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
externalCheckinUrl: null,
|
||||
platform,
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: sites.length,
|
||||
|
||||
@@ -279,6 +279,47 @@ describe('balanceService auto relogin', () => {
|
||||
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips balance refresh for api-key-only accounts without expiring them', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 10,
|
||||
username: 'wong-key',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-only-token',
|
||||
balance: 5,
|
||||
balanceUsed: 1,
|
||||
quota: 6,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'apikey',
|
||||
}),
|
||||
},
|
||||
sites: {
|
||||
id: 10,
|
||||
name: 'wong',
|
||||
url: 'https://wzw.pp.ua',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(10);
|
||||
|
||||
expect(result).toEqual({
|
||||
balance: 5,
|
||||
used: 1,
|
||||
quota: 6,
|
||||
skipped: true,
|
||||
reason: 'proxy_only',
|
||||
});
|
||||
expect(adapterMock.getBalance).not.toHaveBeenCalled();
|
||||
expect(adapterMock.login).not.toHaveBeenCalled();
|
||||
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
|
||||
expect(setAccountRuntimeHealthMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import {
|
||||
getAutoReloginConfig,
|
||||
getCredentialModeFromExtraConfig,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
resolvePlatformUserId,
|
||||
@@ -13,12 +14,18 @@ import { decryptAccountPassword } from './accountCredentialService.js';
|
||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
||||
import type { BalanceInfo } from './platforms/base.js';
|
||||
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||
import { resolveProxyUrlForSite, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function shouldAttemptAutoRelogin(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
if (isTokenExpiredError({ message })) return true;
|
||||
@@ -196,7 +203,7 @@ async function refreshSub2ApiManagedSession(params: {
|
||||
const { fetch } = await import('undici');
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
const response = await fetch(endpoint, withExplicitProxyRequestInit(params.site.proxyUrl, {
|
||||
const response = await fetch(endpoint, withSiteRecordProxyRequestInit(params.site, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
@@ -363,6 +370,16 @@ export async function refreshBalance(accountId: number) {
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return null;
|
||||
|
||||
if (isApiKeyConnection(account)) {
|
||||
return {
|
||||
balance: account.balance ?? 0,
|
||||
used: account.balanceUsed ?? 0,
|
||||
quota: account.quota ?? 0,
|
||||
skipped: true,
|
||||
reason: 'proxy_only',
|
||||
};
|
||||
}
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let activeAccessToken = account.accessToken;
|
||||
let activeExtraConfig = account.extraConfig;
|
||||
@@ -457,7 +474,7 @@ export async function refreshBalance(accountId: number) {
|
||||
accessToken: activeAccessToken,
|
||||
platform: site.platform,
|
||||
platformUserId,
|
||||
proxyUrl: site.proxyUrl,
|
||||
proxyUrl: resolveProxyUrlForSite(site),
|
||||
});
|
||||
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
||||
balanceInfo.todayIncome = fallbackIncome;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
@@ -202,18 +203,27 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
accountId: account.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs: modelLatency.get(modelName) ?? null,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
})),
|
||||
).run();
|
||||
|
||||
if (isApiKeyConnection(account)) {
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'healthy',
|
||||
reason: '模型探测成功',
|
||||
source: 'model-discovery',
|
||||
checkedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
buildNewApiCookieCandidates,
|
||||
fetchJsonWithShieldCookieRetry,
|
||||
} from './platforms/newApiShield.js';
|
||||
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||
import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||
|
||||
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
|
||||
const SELF_LOG_PAGE_SIZE = 20;
|
||||
@@ -28,6 +28,7 @@ interface ProxyUsageFallbackInput {
|
||||
url: string;
|
||||
platform: string;
|
||||
apiKey?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
proxyUrl?: string | null;
|
||||
};
|
||||
account: {
|
||||
@@ -358,7 +359,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
page_size: String(SELF_LOG_PAGE_SIZE),
|
||||
model: input.modelName,
|
||||
});
|
||||
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withExplicitProxyRequestInit(input.site.proxyUrl, {
|
||||
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -412,7 +413,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, withExplicitProxyRequestInit(input.site.proxyUrl, {
|
||||
const response = await fetch(url, withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
|
||||
@@ -22,6 +23,7 @@ describe('siteProxy', () => {
|
||||
beforeEach(async () => {
|
||||
const { invalidateSiteProxyCache } = await import('./siteProxy.js');
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.sites).run();
|
||||
invalidateSiteProxyCache();
|
||||
});
|
||||
@@ -30,36 +32,35 @@ describe('siteProxy', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('resolves longest matched site proxy url', async () => {
|
||||
await db.insert(schema.sites).values([
|
||||
{
|
||||
name: 'base-site',
|
||||
url: 'https://relay.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7891',
|
||||
},
|
||||
{
|
||||
name: 'openai-site',
|
||||
url: 'https://relay.example.com/openai',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
]).run();
|
||||
it('resolves system proxy only for sites that opt in', async () => {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
value: JSON.stringify('http://127.0.0.1:7890'),
|
||||
}).run();
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO sites (name, url, platform, use_system_proxy)
|
||||
VALUES
|
||||
('base-site', 'https://relay.example.com', 'new-api', 0),
|
||||
('openai-site', 'https://relay.example.com/openai', 'new-api', 1)
|
||||
`);
|
||||
|
||||
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
|
||||
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
|
||||
.toBe('http://127.0.0.1:7890');
|
||||
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
|
||||
.toBe('http://127.0.0.1:7891');
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('injects dispatcher when proxy exists', async () => {
|
||||
await db.insert(schema.sites).values({
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
it('injects dispatcher when a site opts into the configured system proxy', async () => {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
value: JSON.stringify('http://127.0.0.1:7890'),
|
||||
}).run();
|
||||
await db.run(sql`
|
||||
INSERT INTO sites (name, url, platform, use_system_proxy)
|
||||
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 1)
|
||||
`);
|
||||
|
||||
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
|
||||
const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { ProxyAgent } from 'undici';
|
||||
|
||||
@@ -15,7 +17,7 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||
|
||||
type SiteProxyRow = {
|
||||
siteUrl: string;
|
||||
proxyUrl: string | null;
|
||||
useSystemProxy: boolean;
|
||||
};
|
||||
|
||||
type ParsedSiteProxyInput = {
|
||||
@@ -24,12 +26,18 @@ type ParsedSiteProxyInput = {
|
||||
proxyUrl: string | null;
|
||||
};
|
||||
|
||||
type SiteProxyConfigLike = {
|
||||
useSystemProxy?: boolean | null;
|
||||
};
|
||||
|
||||
let siteProxyCache: {
|
||||
loadedAt: number;
|
||||
rows: SiteProxyRow[];
|
||||
systemProxyUrl: string | null;
|
||||
} = {
|
||||
loadedAt: 0,
|
||||
rows: [],
|
||||
systemProxyUrl: null,
|
||||
};
|
||||
|
||||
const dispatcherCache = new Map<string, Dispatcher>();
|
||||
@@ -54,23 +62,41 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
siteUrl: schema.sites.url,
|
||||
proxyUrl: schema.sites.proxyUrl,
|
||||
})
|
||||
.from(schema.sites)
|
||||
.all();
|
||||
const [rows, systemProxySetting] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
siteUrl: schema.sites.url,
|
||||
useSystemProxy: schema.sites.useSystemProxy,
|
||||
})
|
||||
.from(schema.sites)
|
||||
.all(),
|
||||
db.select({ value: schema.settings.value })
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, 'system_proxy_url'))
|
||||
.get(),
|
||||
]);
|
||||
const parsedSystemProxyUrl = normalizeSiteProxyUrl(
|
||||
typeof systemProxySetting?.value === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(systemProxySetting.value);
|
||||
} catch {
|
||||
return systemProxySetting.value;
|
||||
}
|
||||
})()
|
||||
: systemProxySetting?.value,
|
||||
);
|
||||
|
||||
siteProxyCache = {
|
||||
loadedAt: nowMs,
|
||||
rows: rows.map((row) => ({
|
||||
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
|
||||
useSystemProxy: !!row.useSystemProxy,
|
||||
})),
|
||||
systemProxyUrl: parsedSystemProxyUrl,
|
||||
};
|
||||
} catch {
|
||||
siteProxyCache = { loadedAt: nowMs, rows: [] };
|
||||
siteProxyCache = { loadedAt: nowMs, rows: [], systemProxyUrl: null };
|
||||
}
|
||||
|
||||
return siteProxyCache.rows;
|
||||
@@ -138,7 +164,7 @@ export function parseSiteProxyUrlInput(input: unknown): ParsedSiteProxyInput {
|
||||
}
|
||||
|
||||
export function invalidateSiteProxyCache(): void {
|
||||
siteProxyCache = { loadedAt: 0, rows: [] };
|
||||
siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null };
|
||||
}
|
||||
|
||||
export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> {
|
||||
@@ -146,11 +172,13 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
|
||||
if (!normalizedRequestUrl) return null;
|
||||
|
||||
const rows = await getCachedSiteProxyRows();
|
||||
const systemProxyUrl = siteProxyCache.systemProxyUrl;
|
||||
if (!systemProxyUrl) return null;
|
||||
let bestMatch: string | null = null;
|
||||
let bestMatchLength = -1;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.proxyUrl) continue;
|
||||
if (!row.useSystemProxy) continue;
|
||||
if (!row.siteUrl) continue;
|
||||
|
||||
const isPrefixMatch = (
|
||||
@@ -161,7 +189,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
|
||||
if (!isPrefixMatch) continue;
|
||||
|
||||
if (row.siteUrl.length > bestMatchLength) {
|
||||
bestMatch = row.proxyUrl;
|
||||
bestMatch = systemProxyUrl;
|
||||
bestMatchLength = row.siteUrl.length;
|
||||
}
|
||||
}
|
||||
@@ -200,3 +228,15 @@ export function withExplicitProxyRequestInit(
|
||||
dispatcher,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
|
||||
if (!site?.useSystemProxy) return null;
|
||||
return normalizeSiteProxyUrl(config.systemProxyUrl);
|
||||
}
|
||||
|
||||
export function withSiteRecordProxyRequestInit(
|
||||
site: SiteProxyConfigLike | null | undefined,
|
||||
options?: UndiciRequestInit,
|
||||
): UndiciRequestInit {
|
||||
return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user