forked from boos/BoosAPI
Refactor proxy-core surfaces and transformer boundaries (#163)
* refactor: add canonical request contracts * test: harden transformer boundary contracts * refactor: adapt protocol transformers to canonical contract * refactor: add cli profile registry * refactor: extract provider profiles * refactor: add conductor and provider executors * refactor: add models surface * refactor: migrate responses surface * refactor: migrate chat surface * refactor: migrate gemini surface * refactor: migrate file surfaces * test: align runtime executor undici mock * fix: preserve compact SSE closeout payloads * feat: support all-api-hub v2 backup imports * fix: aggregate codex oauth SSE final payloads * fix: harden responses SSE failure handling
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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 ImportExport from './ImportExport.js';
|
||||
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
exportBackup: vi.fn(),
|
||||
importBackup: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const allApiHubV2Payload = JSON.stringify({
|
||||
version: '2.0',
|
||||
timestamp: 1735689600000,
|
||||
accounts: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'managed-account',
|
||||
site_url: 'https://newapi.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Managed Site',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 7788,
|
||||
username: 'managed-user',
|
||||
access_token: 'managed-session-token',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'direct-account',
|
||||
site_url: 'https://api.openai.com',
|
||||
site_type: 'openai',
|
||||
site_name: 'OpenAI Direct',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'openai-user',
|
||||
access_token: 'sk-openai-account',
|
||||
},
|
||||
},
|
||||
],
|
||||
bookmarks: [
|
||||
{
|
||||
id: 'bookmark-1',
|
||||
name: 'Ignored Bookmark',
|
||||
url: 'https://bookmark.example.com',
|
||||
},
|
||||
],
|
||||
pinnedAccountIds: ['direct-account'],
|
||||
orderedAccountIds: ['managed-account', 'direct-account'],
|
||||
last_updated: 1735689601000,
|
||||
},
|
||||
preferences: {
|
||||
language: 'zh-CN',
|
||||
},
|
||||
channelConfigs: {
|
||||
bySite: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
tagStore: {
|
||||
version: 1,
|
||||
tagsById: {},
|
||||
},
|
||||
apiCredentialProfiles: {
|
||||
version: 2,
|
||||
profiles: [
|
||||
{
|
||||
id: 'profile-openai',
|
||||
name: 'OpenAI Profile',
|
||||
apiType: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-profile-openai',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689602000,
|
||||
updatedAt: 1735689603000,
|
||||
},
|
||||
{
|
||||
id: 'profile-gemini',
|
||||
name: 'Gemini Profile',
|
||||
apiType: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'gemini-profile-key',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689604000,
|
||||
updatedAt: 1735689605000,
|
||||
},
|
||||
],
|
||||
lastUpdated: 1735689606000,
|
||||
},
|
||||
});
|
||||
|
||||
describe('ImportExport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', {
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
apiMock.importBackup.mockResolvedValue({
|
||||
allImported: true,
|
||||
sections: {
|
||||
accounts: true,
|
||||
preferences: true,
|
||||
},
|
||||
appliedSettings: [],
|
||||
summary: {
|
||||
importedSites: 3,
|
||||
importedAccounts: 2,
|
||||
importedProfiles: 2,
|
||||
importedApiKeyConnections: 3,
|
||||
skippedAccounts: 1,
|
||||
ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'],
|
||||
},
|
||||
warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = collectText(root!.root);
|
||||
expect(rendered).toContain('ALL-API-Hub V2');
|
||||
expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2');
|
||||
expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses backend import summary in the completion toast', async () => {
|
||||
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const importButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('导入')
|
||||
)).at(-1);
|
||||
|
||||
expect(importButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
importButton!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
|
||||
);
|
||||
expect(toastMock.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'),
|
||||
);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,16 @@ type ParsedSummary = {
|
||||
hasAccounts: boolean;
|
||||
hasPreferences: boolean;
|
||||
hasLegacyData: boolean;
|
||||
isAllApiHubV2: boolean;
|
||||
sitesCount: number;
|
||||
accountsCount: number;
|
||||
bookmarksCount: number;
|
||||
profilesCount: number;
|
||||
tokensCount: number;
|
||||
routesCount: number;
|
||||
channelsCount: number;
|
||||
settingsCount: number;
|
||||
ignoredSections: string[];
|
||||
};
|
||||
|
||||
function downloadJsonFile(data: unknown, filename: string) {
|
||||
@@ -35,23 +39,29 @@ function downloadJsonFile(data: unknown, filename: string) {
|
||||
function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
const invalidSummary = (): ParsedSummary => ({
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
isAllApiHubV2: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
bookmarksCount: 0,
|
||||
profilesCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
ignoredSections: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw) as any;
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return {
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
};
|
||||
return invalidSummary();
|
||||
}
|
||||
|
||||
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
|
||||
@@ -63,6 +73,20 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
|
||||
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
|
||||
const legacyPrefs = Boolean(data.data?.preferences);
|
||||
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
|
||||
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
|
||||
const isAllApiHubV2 = Boolean(
|
||||
accountsSection
|
||||
&& Array.isArray(accountsSection.accounts)
|
||||
&& (
|
||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||
|| 'last_updated' in accountsSection
|
||||
|| Array.isArray(accountsSection.bookmarks)
|
||||
|| Array.isArray(accountsSection.pinnedAccountIds)
|
||||
|| Array.isArray(accountsSection.orderedAccountIds)
|
||||
|| profilesCount > 0
|
||||
)
|
||||
);
|
||||
|
||||
const hasAccounts = Boolean(
|
||||
data.type === 'accounts'
|
||||
@@ -74,6 +98,10 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
|| preferencesSection
|
||||
|| legacyPrefs,
|
||||
);
|
||||
const ignoredSections: string[] = [];
|
||||
if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks');
|
||||
if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs');
|
||||
if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore');
|
||||
|
||||
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
|
||||
|
||||
@@ -89,31 +117,52 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
hasAccounts,
|
||||
hasPreferences,
|
||||
hasLegacyData: legacyAccounts || legacyPrefs,
|
||||
isAllApiHubV2,
|
||||
sitesCount: toCount(accountsSection?.sites),
|
||||
accountsCount: toCount(accountsSection?.accounts),
|
||||
bookmarksCount,
|
||||
profilesCount,
|
||||
tokensCount: toCount(accountsSection?.accountTokens),
|
||||
routesCount: toCount(accountsSection?.tokenRoutes),
|
||||
channelsCount: toCount(accountsSection?.routeChannels),
|
||||
settingsCount: toCount(preferencesSection?.settings),
|
||||
ignoredSections,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
};
|
||||
return invalidSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function buildImportSuccessMessage(result: any): string {
|
||||
const sections: string[] = [];
|
||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
||||
if (result?.sections?.preferences) sections.push('系统设置');
|
||||
|
||||
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
|
||||
if (result?.summary) {
|
||||
const summary = result.summary;
|
||||
parts.push(
|
||||
[
|
||||
`站点 ${summary.importedSites ?? 0}`,
|
||||
`账号 ${summary.importedAccounts ?? 0}`,
|
||||
`API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`,
|
||||
`跳过 ${summary.skippedAccounts ?? 0}`,
|
||||
].join(' / '),
|
||||
);
|
||||
|
||||
if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) {
|
||||
parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
|
||||
const preview = result.warnings.slice(0, 2).join(';');
|
||||
parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`);
|
||||
}
|
||||
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
export default function ImportExport() {
|
||||
const toast = useToast();
|
||||
const [exportingType, setExportingType] = useState<BackupType | ''>('');
|
||||
@@ -193,7 +242,10 @@ export default function ImportExport() {
|
||||
toast.error('当前 JSON 结构无法识别');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('导入会覆盖账号/路由或系统设置,确认继续?')) {
|
||||
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
||||
? true
|
||||
: window.confirm('导入会覆盖账号/路由或系统设置,确认继续?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,10 +253,7 @@ export default function ImportExport() {
|
||||
try {
|
||||
const parsed = JSON.parse(importData);
|
||||
const result = await api.importBackup(parsed);
|
||||
const sections: string[] = [];
|
||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
||||
if (result?.sections?.preferences) sections.push('系统设置');
|
||||
toast.success(`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`);
|
||||
toast.success(buildImportSuccessMessage(result));
|
||||
setImportData('');
|
||||
setSelectedFileName('');
|
||||
} catch (err: any) {
|
||||
@@ -379,6 +428,17 @@ export default function ImportExport() {
|
||||
<>
|
||||
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
|
||||
<div>包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
|
||||
{summary.isAllApiHubV2 ? (
|
||||
<>
|
||||
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
|
||||
<div>
|
||||
统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount}
|
||||
</div>
|
||||
{summary.ignoredSections.length ? (
|
||||
<div>不会原生导入:{summary.ignoredSections.join('、')}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
|
||||
<div>
|
||||
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount}
|
||||
|
||||
Reference in New Issue
Block a user