forked from boos/BoosAPI
feat: enhance MySQL upgrade generation with index prefix length control
- Added support for specifying prefix lengths for live text-backed columns in MySQL upgrade SQL generation. - Introduced `mysqlIndexPrefixRequirements` option to `generateUpgradeSql` function. - Updated tests to validate the new functionality for MySQL upgrade generation. - Removed legacy shared index compatibility specifications to avoid duplication with runtime schema bootstrap. - Improved error handling and response structure in account token coverage refresh logic. - Added tests for coverage refresh failure scenarios to ensure proper handling of token persistence. # Conflicts: # src/server/db/sharedIndexSchemaCompatibility.test.ts # src/server/db/sharedIndexSchemaCompatibility.ts # src/server/index.ts
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const refreshModelsForAccountMock = vi.fn();
|
||||
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
|
||||
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('account token coverage refresh failure handling', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const accountTokenRoutesModule = await import('./accountTokens.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(accountTokenRoutesModule.accountTokensRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
refreshModelsForAccountMock.mockReset();
|
||||
rebuildTokenRoutesFromAvailabilityMock.mockReset();
|
||||
|
||||
await db.delete(schema.proxyLogs).run();
|
||||
await db.delete(schema.checkinLogs).run();
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.tokenModelAvailability).run();
|
||||
await db.delete(schema.modelAvailability).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('returns success after token persistence even when coverage rebuild fails', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-1',
|
||||
url: 'https://site-1.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'user-1',
|
||||
accessToken: 'acc-token-1',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
refreshModelsForAccountMock.mockResolvedValue({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
errorMessage: '',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gpt-4o-mini'],
|
||||
tokenScanned: 1,
|
||||
discoveredByCredential: false,
|
||||
discoveredApiToken: false,
|
||||
});
|
||||
rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed'));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens',
|
||||
payload: {
|
||||
accountId: account.id,
|
||||
name: 'manual-token',
|
||||
token: 'sk-manual-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
coverageRefresh: {
|
||||
rebuild: {
|
||||
success: false,
|
||||
error: 'rebuild failed',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0]?.token).toBe('sk-manual-token');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
import { getAdapter } from '../../services/platforms/index.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { rebuildTokenRoutesFromAvailability, refreshModelsForAccount } from '../../services/modelService.js';
|
||||
import {
|
||||
rebuildTokenRoutesFromAvailability,
|
||||
refreshModelsForAccount,
|
||||
type ModelRefreshResult,
|
||||
} from '../../services/modelService.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
@@ -39,6 +43,25 @@ type SyncExecutionResult = {
|
||||
defaultTokenId?: number | null;
|
||||
};
|
||||
|
||||
type CoverageRefreshFailureItem = {
|
||||
accountId: number;
|
||||
refreshed: false;
|
||||
status: 'failed';
|
||||
errorCode: 'coverage_refresh_failed';
|
||||
errorMessage: string;
|
||||
modelCount: 0;
|
||||
modelsPreview: string[];
|
||||
reason: 'coverage_refresh_failed';
|
||||
tokenScanned: 0;
|
||||
discoveredByCredential: false;
|
||||
discoveredApiToken: false;
|
||||
};
|
||||
|
||||
type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem;
|
||||
type CoverageRefreshRebuildResult =
|
||||
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
||||
| { success: false; error: string };
|
||||
|
||||
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
|
||||
const SYNC_ALL_BATCH_SIZE = 3;
|
||||
|
||||
@@ -365,13 +388,62 @@ async function refreshCoverageForAccounts(accountIds: number[]) {
|
||||
return { refresh: [], rebuild: null };
|
||||
}
|
||||
|
||||
const refresh = await Promise.all(
|
||||
uniqueAccountIds.map(async (accountId) => refreshModelsForAccount(accountId)),
|
||||
);
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
const refresh: CoverageRefreshItem[] = [];
|
||||
for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) {
|
||||
const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE);
|
||||
const settled = await Promise.allSettled(
|
||||
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
|
||||
);
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
refresh.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = batch[index] || 0;
|
||||
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed');
|
||||
refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage));
|
||||
console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`);
|
||||
});
|
||||
}
|
||||
|
||||
let rebuild: CoverageRefreshRebuildResult | null = null;
|
||||
try {
|
||||
rebuild = {
|
||||
success: true,
|
||||
result: await rebuildTokenRoutesFromAvailability(),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed');
|
||||
rebuild = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return { refresh, rebuild };
|
||||
}
|
||||
|
||||
function buildCoverageRefreshFailureItem(
|
||||
accountId: number,
|
||||
errorMessage: string,
|
||||
): CoverageRefreshFailureItem {
|
||||
return {
|
||||
accountId,
|
||||
refreshed: false,
|
||||
status: 'failed',
|
||||
errorCode: 'coverage_refresh_failed',
|
||||
errorMessage,
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'coverage_refresh_failed',
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: false,
|
||||
discoveredApiToken: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { accountId?: string } }>('/api/account-tokens', async (request) => {
|
||||
const accountId = request.query.accountId ? Number.parseInt(request.query.accountId, 10) : undefined;
|
||||
|
||||
Reference in New Issue
Block a user