forked from boos/BoosAPI
fix backup import preview and refresh race (#167)
This commit is contained in:
@@ -148,6 +148,8 @@ describe('backupService', () => {
|
|||||||
|
|
||||||
expect(result.allImported).toBe(true);
|
expect(result.allImported).toBe(true);
|
||||||
expect(result.sections.accounts).toBe(true);
|
expect(result.sections.accounts).toBe(true);
|
||||||
|
expect(result.summary).toBeUndefined();
|
||||||
|
expect(result.warnings).toBeUndefined();
|
||||||
|
|
||||||
const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
|
const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
|
||||||
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||||
|
|||||||
@@ -283,13 +283,29 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): {
|
|||||||
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
||||||
if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null;
|
if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null;
|
||||||
|
|
||||||
|
if (coerceAccountsSection(accountsContainer)) return null;
|
||||||
|
|
||||||
|
const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => (
|
||||||
|
isRecord(row) && (
|
||||||
|
Object.prototype.hasOwnProperty.call(row, 'site_url')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(row, 'site_type')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(row, 'account_info')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(row, 'cookieAuth')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(row, 'authType')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth')
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
const looksLikeV2 =
|
const looksLikeV2 =
|
||||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
looksLikeLegacyAccountRow
|
||||||
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|
&& (
|
||||||
|| Array.isArray(accountsContainer.bookmarks)
|
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||||
|| Array.isArray(accountsContainer.pinnedAccountIds)
|
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|
||||||
|| Array.isArray(accountsContainer.orderedAccountIds)
|
|| Array.isArray(accountsContainer.bookmarks)
|
||||||
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles));
|
|| Array.isArray(accountsContainer.pinnedAccountIds)
|
||||||
|
|| Array.isArray(accountsContainer.orderedAccountIds)
|
||||||
|
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles))
|
||||||
|
);
|
||||||
|
|
||||||
if (!looksLikeV2) return null;
|
if (!looksLikeV2) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
|
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
|
||||||
|
let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -39,6 +40,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
refreshModelsForAccount = modelService.refreshModelsForAccount;
|
refreshModelsForAccount = modelService.refreshModelsForAccount;
|
||||||
|
refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -142,6 +144,68 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']);
|
expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses one in-flight full refresh when concurrent callers request a rebuild', async () => {
|
||||||
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
let releaseGate: (() => void) | null = null;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
releaseGate = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
getModelsMock.mockImplementation(async () => {
|
||||||
|
await gate;
|
||||||
|
return ['gpt-5-nano'];
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'site-concurrent-refresh',
|
||||||
|
url: 'https://site-concurrent-refresh.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'concurrent-refresh-user',
|
||||||
|
accessToken: 'shared-credential',
|
||||||
|
apiToken: 'shared-credential',
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'default',
|
||||||
|
token: 'shared-credential',
|
||||||
|
source: 'manual',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const firstRefresh = refreshModelsAndRebuildRoutes();
|
||||||
|
const secondRefresh = refreshModelsAndRebuildRoutes();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
releaseGate?.();
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([firstRefresh, secondRefresh]);
|
||||||
|
expect(results.every((item) => item.status === 'fulfilled')).toBe(true);
|
||||||
|
expect(getModelsMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const modelRows = await db.select().from(schema.modelAvailability)
|
||||||
|
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||||
|
.all();
|
||||||
|
expect(modelRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']);
|
||||||
|
|
||||||
|
const token = await db.select().from(schema.accountTokens)
|
||||||
|
.where(eq(schema.accountTokens.accountId, account.id))
|
||||||
|
.get();
|
||||||
|
const tokenRows = await db.select().from(schema.tokenModelAvailability)
|
||||||
|
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
|
||||||
|
.all();
|
||||||
|
expect(tokenRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']);
|
||||||
|
});
|
||||||
|
|
||||||
it('marks runtime health unhealthy when model discovery fails', async () => {
|
it('marks runtime health unhealthy when model discovery fails', async () => {
|
||||||
getApiTokenMock.mockResolvedValue(null);
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
|
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ const GEMINI_CLI_STATIC_MODELS = [
|
|||||||
'gemini-3-flash-preview',
|
'gemini-3-flash-preview',
|
||||||
'gemini-3.1-flash-lite-preview',
|
'gemini-3.1-flash-lite-preview',
|
||||||
];
|
];
|
||||||
|
let inFlightRefreshModelsAndRebuildRoutes: Promise<{
|
||||||
|
refresh: ModelRefreshResult[];
|
||||||
|
rebuild: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>>;
|
||||||
|
}> | null = null;
|
||||||
|
|
||||||
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||||
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
||||||
@@ -1172,8 +1176,24 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshModelsAndRebuildRoutes() {
|
async function runRefreshModelsAndRebuildRoutes() {
|
||||||
const refresh = await refreshModelsForAllActiveAccounts();
|
const refresh = await refreshModelsForAllActiveAccounts();
|
||||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||||
return { refresh, rebuild };
|
return { refresh, rebuild };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshModelsAndRebuildRoutes() {
|
||||||
|
if (inFlightRefreshModelsAndRebuildRoutes) {
|
||||||
|
return inFlightRefreshModelsAndRebuildRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightRefreshModelsAndRebuildRoutes = (async () => {
|
||||||
|
try {
|
||||||
|
return await runRefreshModelsAndRebuildRoutes();
|
||||||
|
} finally {
|
||||||
|
inFlightRefreshModelsAndRebuildRoutes = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlightRefreshModelsAndRebuildRoutes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,6 +121,64 @@ const allApiHubV2Payload = JSON.stringify({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nativeMetapiPayload = JSON.stringify({
|
||||||
|
version: '2.0',
|
||||||
|
timestamp: 1735689600000,
|
||||||
|
accounts: {
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Native Site',
|
||||||
|
url: 'https://native.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
siteId: 1,
|
||||||
|
username: 'native-user',
|
||||||
|
accessToken: 'session-token',
|
||||||
|
apiToken: 'api-token',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accountTokens: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
accountId: 1,
|
||||||
|
name: 'default',
|
||||||
|
token: 'sk-native',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokenRoutes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
modelPattern: 'gpt-5-nano',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routeChannels: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
routeId: 1,
|
||||||
|
accountId: 1,
|
||||||
|
tokenId: 1,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routeGroupSources: [],
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
settings: [
|
||||||
|
{ key: 'locale', value: 'zh-CN' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('ImportExport', () => {
|
describe('ImportExport', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -178,6 +236,31 @@ describe('ImportExport', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not label native metapi backups as ALL-API-Hub V2', 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: nativeMetapiPayload } });
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rendered = collectText(root!.root);
|
||||||
|
expect(rendered).not.toContain('ALL-API-Hub V2');
|
||||||
|
expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 设置 1');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('uses backend import summary in the completion toast', async () => {
|
it('uses backend import summary in the completion toast', async () => {
|
||||||
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
|
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|||||||
@@ -75,8 +75,26 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
|||||||
const legacyPrefs = Boolean(data.data?.preferences);
|
const legacyPrefs = Boolean(data.data?.preferences);
|
||||||
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
|
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
|
||||||
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
|
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
|
||||||
|
const isNativeMetapiBackup = Boolean(
|
||||||
|
accountsSection
|
||||||
|
&& Array.isArray(accountsSection.sites)
|
||||||
|
&& Array.isArray(accountsSection.accountTokens)
|
||||||
|
&& Array.isArray(accountsSection.tokenRoutes)
|
||||||
|
&& Array.isArray(accountsSection.routeChannels)
|
||||||
|
);
|
||||||
|
const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts)
|
||||||
|
&& accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && (
|
||||||
|
'site_url' in row
|
||||||
|
|| 'site_type' in row
|
||||||
|
|| 'account_info' in row
|
||||||
|
|| 'cookieAuth' in row
|
||||||
|
|| 'authType' in row
|
||||||
|
|| 'sub2apiAuth' in row
|
||||||
|
));
|
||||||
const isAllApiHubV2 = Boolean(
|
const isAllApiHubV2 = Boolean(
|
||||||
accountsSection
|
accountsSection
|
||||||
|
&& !isNativeMetapiBackup
|
||||||
|
&& hasLegacyAccountRows
|
||||||
&& Array.isArray(accountsSection.accounts)
|
&& Array.isArray(accountsSection.accounts)
|
||||||
&& (
|
&& (
|
||||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||||
|
|||||||
Reference in New Issue
Block a user