forked from boos/BoosAPI
feat: implement macOS notarization and ad-hoc signing in afterSign script
feat: add run-server script to manage server execution and compatibility checks feat: create main application logic for desktop with window management, server handling, and auto-updater test: add unit tests for desktop runtime helpers feat: implement desktop server environment configuration and health check test: add tests for server configuration and desktop routes feat: add native module guard for better-sqlite3 compatibility test: add tests for native module guard functionality test: add comprehensive tests for account verification feedback handling feat: implement account verification feedback helpers chore: add TypeScript configuration for desktop builds
This commit is contained in:
+3
-3
@@ -203,17 +203,17 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
|
||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string }) =>
|
||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||
request('/api/settings/database/runtime', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||
request('/api/settings/database/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
migrateExternalDatabase: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||
migrateExternalDatabase: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean; ssl?: boolean }) =>
|
||||
request('/api/settings/database/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -352,6 +352,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'其他': 'other',
|
||||
'其他未归类的模型': 'Other uncategorized models',
|
||||
'启用': 'enable',
|
||||
'启用 SSL/TLS 加密连接': 'Enable SSL/TLS encrypted connection',
|
||||
'启用站点后再试': 'Try again after enabling the site',
|
||||
'签到': 'Sign in',
|
||||
'签到 Cron': 'Sign in Cron',
|
||||
|
||||
+22
-12
@@ -5,6 +5,11 @@ import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
@@ -80,6 +85,8 @@ export default function Accounts() {
|
||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||
[accounts, sortMode],
|
||||
);
|
||||
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
|
||||
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -134,11 +141,11 @@ export default function Accounts() {
|
||||
toast.success(`Session 验证成功: ${result.userInfo?.username || '未知用户'}`);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
toast.error(normalizeVerifyFailureMessage(result.message || 'Token 无效'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '验证失败');
|
||||
setVerifyResult({ success: false, message: e.message });
|
||||
toast.error(normalizeVerifyFailureMessage(e?.message));
|
||||
setVerifyResult({ success: false, message: e?.message });
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
@@ -223,9 +230,10 @@ export default function Accounts() {
|
||||
};
|
||||
|
||||
const resolveRuntimeHealth = (account: any) => {
|
||||
const capabilities = resolveAccountCapabilities(account);
|
||||
const fallbackState = account.status === 'disabled' || account.site?.status === 'disabled'
|
||||
? 'disabled'
|
||||
: (account.status === 'expired' ? 'unhealthy' : 'unknown');
|
||||
: (!capabilities.proxyOnly && account.status === 'expired' ? 'unhealthy' : 'unknown');
|
||||
const state = account.runtimeHealth?.state || fallbackState;
|
||||
const cfg = runtimeHealthMap[state] || runtimeHealthMap.unknown;
|
||||
const reason = account.runtimeHealth?.reason
|
||||
@@ -378,11 +386,11 @@ export default function Accounts() {
|
||||
} else if (result.success && result.tokenType !== 'session') {
|
||||
toast.error('当前是 API Key,不是 Session Token');
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
toast.error(normalizeVerifyFailureMessage(result.message || 'Token 无效'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '验证失败');
|
||||
setRebindVerifyResult({ success: false, message: e.message });
|
||||
toast.error(normalizeVerifyFailureMessage(e?.message));
|
||||
setRebindVerifyResult({ success: false, message: e?.message });
|
||||
} finally {
|
||||
setRebindVerifying(false);
|
||||
}
|
||||
@@ -436,7 +444,7 @@ export default function Accounts() {
|
||||
setHighlightAccountId((current) => (current === accountId ? null : current));
|
||||
}, 2200);
|
||||
|
||||
if (openRebind && target.status === 'expired') {
|
||||
if (openRebind && target.status === 'expired' && !resolveAccountCapabilities(target).proxyOnly) {
|
||||
setShowAdd(false);
|
||||
if (!rebindTarget || rebindTarget.id !== target.id) {
|
||||
openRebindPanel(target);
|
||||
@@ -645,9 +653,11 @@ export default function Accounts() {
|
||||
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
|
||||
<div className="alert alert-error animate-scale-in">
|
||||
<div className="alert-title">
|
||||
{verifyResult.message || 'Token 无效或已过期'}
|
||||
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{verifyFailureHint || '请检查 Token 是否正确'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>请检查 Token 是否正确</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -663,7 +673,7 @@ export default function Accounts() {
|
||||
</div>
|
||||
{!verifyResult?.success && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
请先点击“验证 Token”,验证成功后才能添加账号
|
||||
{addAccountPrereqHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -949,7 +959,7 @@ export default function Accounts() {
|
||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'expired' && (
|
||||
{a.status === 'expired' && !capabilities.proxyOnly && (
|
||||
<button
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
|
||||
@@ -85,10 +85,12 @@ type RuntimeDatabaseState = {
|
||||
active: {
|
||||
dialect: DbDialect;
|
||||
connection: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
saved: {
|
||||
dialect: DbDialect;
|
||||
connection: string;
|
||||
ssl: boolean;
|
||||
} | null;
|
||||
restartRequired: boolean;
|
||||
};
|
||||
@@ -200,6 +202,7 @@ export default function Settings() {
|
||||
database: 'postgres',
|
||||
});
|
||||
const [migrationOverwrite, setMigrationOverwrite] = useState(true);
|
||||
const [migrationSsl, setMigrationSsl] = useState(false);
|
||||
const [testingMigrationConnection, setTestingMigrationConnection] = useState(false);
|
||||
const [migratingDatabase, setMigratingDatabase] = useState(false);
|
||||
const [savingRuntimeDatabase, setSavingRuntimeDatabase] = useState(false);
|
||||
@@ -391,11 +394,13 @@ export default function Settings() {
|
||||
active: {
|
||||
dialect: (runtimeDatabaseInfo?.active?.dialect || 'sqlite') as DbDialect,
|
||||
connection: String(runtimeDatabaseInfo?.active?.connection || ''),
|
||||
ssl: !!runtimeDatabaseInfo?.active?.ssl,
|
||||
},
|
||||
saved: runtimeDatabaseInfo?.saved
|
||||
? {
|
||||
dialect: runtimeDatabaseInfo.saved.dialect as DbDialect,
|
||||
connection: String(runtimeDatabaseInfo.saved.connection || ''),
|
||||
ssl: !!runtimeDatabaseInfo.saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired: !!runtimeDatabaseInfo?.restartRequired,
|
||||
@@ -701,7 +706,7 @@ export default function Settings() {
|
||||
const res = await api.testExternalDatabaseConnection({
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
overwrite: migrationOverwrite,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
toast.success(`Connection success: ${res.connection || migrationDialect}`);
|
||||
} catch (err: any) {
|
||||
@@ -734,6 +739,7 @@ export default function Settings() {
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
overwrite: migrationOverwrite,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
setMigrationSummary(res);
|
||||
toast.success(res?.message || 'Database migration completed');
|
||||
@@ -761,16 +767,19 @@ export default function Settings() {
|
||||
const res = await api.updateRuntimeDatabaseConfig({
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
setRuntimeDatabaseState({
|
||||
active: {
|
||||
dialect: (res?.active?.dialect || 'sqlite') as DbDialect,
|
||||
connection: String(res?.active?.connection || ''),
|
||||
ssl: !!res?.active?.ssl,
|
||||
},
|
||||
saved: res?.saved
|
||||
? {
|
||||
dialect: res.saved.dialect as DbDialect,
|
||||
connection: String(res.saved.connection || ''),
|
||||
ssl: !!res.saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired: !!res?.restartRequired,
|
||||
@@ -1164,6 +1173,18 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationDialect !== 'sqlite' && (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={migrationSsl}
|
||||
onChange={(e) => setMigrationSsl(e.target.checked)}
|
||||
style={{ width: 14, height: 14, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
启用 SSL/TLS 加密连接
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1202,11 +1223,11 @@ export default function Settings() {
|
||||
|
||||
{runtimeDatabaseState && (
|
||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8, marginBottom: migrationSummary ? 12 : 0 }}>
|
||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' })</div>
|
||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' }){runtimeDatabaseState.active.ssl && ' [SSL]'}</div>
|
||||
<div>
|
||||
已保存待生效:
|
||||
{runtimeDatabaseState.saved
|
||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})`
|
||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? ' [SSL]' : ''}`
|
||||
: ' 未保存'}
|
||||
</div>
|
||||
{runtimeDatabaseState.restartRequired && (
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts proxy-only expired state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not show session-expired health copy or rebind action for proxy-only accounts', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: '',
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
todayReward: 0,
|
||||
todaySpend: 0,
|
||||
accessToken: 'api-key-only-token',
|
||||
status: 'expired',
|
||||
checkinEnabled: false,
|
||||
capabilities: {
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: true,
|
||||
},
|
||||
siteId: 10,
|
||||
site: { id: 10, name: '小呆api', status: 'active', url: 'https://example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([{ id: 10, name: '小呆api', platform: 'new-api', status: 'active' }]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('仅代理');
|
||||
expect(rendered).not.toContain('访问令牌已过期');
|
||||
|
||||
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
|
||||
expect(actionTexts).not.toContain('重新绑定');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
verifyToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts verify feedback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows connectivity guidance instead of blaming the token when verification request fails', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.verifyToken.mockRejectedValueOnce(new Error('Failed to fetch'));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const addButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-primary')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
addButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
expect(selects.length).toBeGreaterThan(1);
|
||||
|
||||
await act(async () => {
|
||||
selects[1]!.props.onChange('10');
|
||||
});
|
||||
|
||||
const textareas = root.root.findAll((node) => node.type === 'textarea');
|
||||
expect(textareas.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
textareas[0]!.props.onChange({ target: { value: 'demo-token' } });
|
||||
});
|
||||
|
||||
const verifyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-ghost')
|
||||
&& collectText(node).includes('Token')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await verifyButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain(normalizeVerifyFailureMessage('Failed to fetch'));
|
||||
expect(rendered).toContain(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })!);
|
||||
expect(rendered).toContain(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' }));
|
||||
expect(rendered).not.toContain('请检查 Token 是否正确');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Dashboard from './Dashboard.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getDashboard: vi.fn(),
|
||||
getSiteDistribution: vi.fn(),
|
||||
getSiteTrend: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Dashboard site speed buttons', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getDashboard.mockResolvedValue({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 0,
|
||||
totalAccounts: 0,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||
modelAnalysis: null,
|
||||
});
|
||||
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
|
||||
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Demo Site', url: 'https://example.com', status: 'active' },
|
||||
]);
|
||||
|
||||
globalThis.document = {
|
||||
visibilityState: 'visible',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getElementById: vi.fn(() => null),
|
||||
} as unknown as Document;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('updates site speed status without imperative document lookups', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<Dashboard />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const speedButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('娴嬮€?')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await speedButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './accountVerifyFeedback.js';
|
||||
|
||||
describe('account verify feedback', () => {
|
||||
it('treats fetch failures as connectivity issues instead of token mistakes', () => {
|
||||
expect(normalizeVerifyFailureMessage('Failed to fetch')).not.toBe('Failed to fetch');
|
||||
expect(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })).not.toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' })).toMatch(/metapi|站点|代理/i);
|
||||
});
|
||||
|
||||
it('treats timeout failures as reachability issues instead of token mistakes', () => {
|
||||
const timeoutMessage = 'Token verification timed out (10s)';
|
||||
expect(normalizeVerifyFailureMessage(timeoutMessage)).toBe(timeoutMessage);
|
||||
expect(buildVerifyFailureHint({ success: false, message: timeoutMessage })).toMatch(/Token|站点|代理|超时/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: timeoutMessage })).toMatch(/站点|代理|超时/i);
|
||||
});
|
||||
|
||||
it('keeps token guidance for actual credential failures', () => {
|
||||
const invalidHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
expect(invalidHint).toMatch(/Token/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Token invalid' })).toMatch(/Token/i);
|
||||
});
|
||||
|
||||
it('normalizes invalid user-id mismatch messages for display', () => {
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.toMatch(/ID/i);
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.not.toContain('does not match this token');
|
||||
});
|
||||
|
||||
it('prioritizes user-id guidance when the site requires it', () => {
|
||||
expect(buildVerifyFailureHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toMatch(/ID/i);
|
||||
});
|
||||
|
||||
it('treats invalid user-id as a mismatch instead of a missing-value hint', () => {
|
||||
const invalidUserIdHint = buildVerifyFailureHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericTokenHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
const invalidUserIdPrereq = buildAddAccountPrereqHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericPrereq = buildAddAccountPrereqHint({ success: false, message: 'Token invalid' });
|
||||
|
||||
expect(invalidUserIdHint).toMatch(/ID/i);
|
||||
expect(invalidUserIdHint).not.toBe(genericTokenHint);
|
||||
expect(invalidUserIdPrereq).toMatch(/ID/i);
|
||||
expect(invalidUserIdPrereq).not.toBe(genericPrereq);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
type VerifyResultLike = {
|
||||
success?: boolean;
|
||||
needsUserId?: boolean;
|
||||
invalidUserId?: boolean;
|
||||
message?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
function normalizeMessageText(message: unknown): string {
|
||||
return typeof message === 'string' ? message.trim() : '';
|
||||
}
|
||||
|
||||
export function isNetworkFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('failed to fetch')
|
||||
|| lowered.includes('fetch failed')
|
||||
|| lowered.includes('networkerror')
|
||||
|| lowered.includes('load failed');
|
||||
}
|
||||
|
||||
export function isTimeoutFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('timed out')
|
||||
|| lowered.includes('timeout')
|
||||
|| lowered.includes('请求超时');
|
||||
}
|
||||
|
||||
export function normalizeVerifyFailureMessage(message: unknown): string {
|
||||
const text = normalizeMessageText(message);
|
||||
if (!text) return '验证失败';
|
||||
const lowered = text.toLowerCase();
|
||||
if (isNetworkFailureMessage(text)) {
|
||||
return '无法连接到 metapi 服务端,请检查服务状态或网络连接';
|
||||
}
|
||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function buildVerifyFailureHint(result: VerifyResultLike): string | null {
|
||||
if (!result || result.success || result.needsUserId) return null;
|
||||
if (result.invalidUserId) {
|
||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。请检查 metapi 服务是否在线,以及目标站点或代理是否可达。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||
}
|
||||
return '请检查 Token 是否正确';
|
||||
}
|
||||
|
||||
export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
||||
if (!result) {
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.success) return '';
|
||||
if (result.needsUserId) {
|
||||
return '请先补充用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.invalidUserId) {
|
||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '验证请求未成功完成,请先检查 metapi 服务、站点网络或代理配置。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||
}
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
Reference in New Issue
Block a user