1
0
forked from boos/BoosAPI

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:
cita
2026-03-07 18:04:05 +08:00
parent c31bbcbb51
commit c437d93715
65 changed files with 3984 additions and 486 deletions
@@ -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',
+27 -1
View File
@@ -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',
+1
View File
@@ -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([
{
+20 -3
View File
@@ -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;
+17 -7
View File
@@ -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,
+23 -22
View File
@@ -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', {
+53 -13
View File
@@ -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);
}