1
0
forked from boos/BoosAPI

Add unit tests for backupService to validate import/export functionality

This commit is contained in:
ciat-777
2026-03-03 14:50:59 +08:00
parent 5cebc1554f
commit eff6f22f6d
17 changed files with 274 additions and 74 deletions
+16 -9
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq, gte, lt } from 'drizzle-orm';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
@@ -159,18 +159,25 @@ export async function accountsRoutes(app: FastifyInstance) {
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc();
// Aggregate today's spend per account from proxy logs
const todayLogs = db.select().from(schema.proxyLogs)
const todaySpendRows = db.select({
accountId: schema.proxyLogs.accountId,
totalSpend: sql<number>`coalesce(sum(${schema.proxyLogs.estimatedCost}), 0)`,
}).from(schema.proxyLogs)
.where(and(gte(schema.proxyLogs.createdAt, startUtc), lt(schema.proxyLogs.createdAt, endUtc)))
.groupBy(schema.proxyLogs.accountId)
.all();
const spendByAccount: Record<number, number> = {};
for (const log of todayLogs) {
if (log.accountId == null) continue;
const cost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
spendByAccount[log.accountId] = (spendByAccount[log.accountId] || 0) + cost;
}
for (const row of todaySpendRows) {
if (row.accountId == null) continue;
spendByAccount[row.accountId] = Number(row.totalSpend || 0);
}
// Aggregate today's checkin rewards per account
const todayCheckins = db.select().from(schema.checkinLogs)
const todayCheckins = db.select({
accountId: schema.checkinLogs.accountId,
reward: schema.checkinLogs.reward,
message: schema.checkinLogs.message,
}).from(schema.checkinLogs)
.where(and(
gte(schema.checkinLogs.createdAt, startUtc),
lt(schema.checkinLogs.createdAt, endUtc),
+9 -4
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
@@ -35,11 +35,16 @@ export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
const siteRows = db.select().from(schema.sites).all();
const accountRows = db.select().from(schema.accounts).all();
const accountBalanceRows = db.select({
siteId: schema.accounts.siteId,
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
}).from(schema.accounts)
.groupBy(schema.accounts.siteId)
.all();
const totalBalanceBySiteId: Record<number, number> = {};
for (const account of accountRows) {
totalBalanceBySiteId[account.siteId] = (totalBalanceBySiteId[account.siteId] || 0) + (account.balance || 0);
for (const row of accountBalanceRows) {
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
}
return siteRows.map((site) => ({
+198
View File
@@ -0,0 +1,198 @@
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 { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type BackupServiceModule = typeof import('./backupService.js');
describe('backupService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let backupService: BackupServiceModule;
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-backup-service-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const serviceModule = await import('./backupService.js');
db = dbModule.db;
schema = dbModule.schema;
backupService = serviceModule;
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
db.delete(schema.settings).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('preserves extended fields in full backup import/export roundtrip', () => {
const now = new Date().toISOString();
const site = db.insert(schema.sites).values({
name: 'roundtrip-site',
url: 'https://roundtrip.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:8080',
status: 'active',
isPinned: true,
sortOrder: 9,
apiKey: 'site-api-key',
createdAt: now,
updatedAt: now,
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'roundtrip-user',
accessToken: 'session-token',
apiToken: 'api-token',
balance: 12.3,
balanceUsed: 4.5,
quota: 99.9,
unitCost: 0.2,
valueScore: 1.1,
status: 'active',
isPinned: true,
sortOrder: 7,
checkinEnabled: true,
extraConfig: JSON.stringify({ platformUserId: 123 }),
createdAt: now,
updatedAt: now,
}).returning().get();
const accountToken = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-roundtrip-token',
source: 'manual',
enabled: true,
isDefault: true,
createdAt: now,
updatedAt: now,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
displayName: 'gpt-route',
displayIcon: 'icon-gpt',
modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }),
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: accountToken.id,
sourceModel: 'gpt-4o',
priority: 3,
weight: 5,
enabled: true,
manualOverride: false,
successCount: 10,
failCount: 1,
totalLatencyMs: 2500,
totalCost: 2.5,
lastUsedAt: now,
lastFailAt: now,
cooldownUntil: now,
}).run();
const exported = backupService.exportBackup('all');
const result = backupService.importBackup(exported as unknown as Record<string, unknown>);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
const restoredSite = db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
const restoredAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const restoredRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
const restoredChannel = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get();
expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080');
expect(restoredSite?.isPinned).toBe(true);
expect(restoredSite?.sortOrder).toBe(9);
expect(restoredAccount?.isPinned).toBe(true);
expect(restoredAccount?.sortOrder).toBe(7);
expect(restoredRoute?.displayName).toBe('gpt-route');
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
});
it('imports ALL-API-Hub style payload with accounts and preferences', () => {
const payload = {
timestamp: Date.now(),
accounts: {
accounts: [
{
site_url: 'https://legacy.example.com',
site_type: 'new-api',
site_name: 'legacy-site',
username: 'legacy-user',
authType: 'session',
account_info: {
id: 7788,
username: 'legacy-user',
access_token: 'legacy-session-token',
quota: 100000,
today_quota_consumption: 50000,
},
checkIn: {
autoCheckInEnabled: true,
},
created_at: '2026-02-01T00:00:00.000Z',
updated_at: '2026-02-02T00:00:00.000Z',
},
],
},
preferences: {
locale: 'zh-CN',
},
channelConfigs: {
order: ['a', 'b'],
},
apiCredentialProfiles: {
default: 'main',
},
tagStore: {
groups: ['test'],
},
} as Record<string, unknown>;
const result = backupService.importBackup(payload);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
expect(result.sections.preferences).toBe(true);
expect(result.appliedSettings.length).toBeGreaterThan(0);
const sites = db.select().from(schema.sites).all();
const accounts = db.select().from(schema.accounts).all();
const settings = db.select().from(schema.settings).all();
expect(sites.length).toBe(1);
expect(accounts.length).toBe(1);
expect(accounts[0].username).toBe('legacy-user');
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
});
});
+8
View File
@@ -461,7 +461,10 @@ function importAccountsSection(section: AccountsBackupSection) {
name: row.name,
url: row.url,
platform: row.platform,
proxyUrl: row.proxyUrl ?? null,
status: row.status || 'active',
isPinned: row.isPinned ?? false,
sortOrder: row.sortOrder ?? 0,
apiKey: row.apiKey,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -481,6 +484,8 @@ function importAccountsSection(section: AccountsBackupSection) {
unitCost: row.unitCost,
valueScore: row.valueScore,
status: row.status,
isPinned: row.isPinned ?? false,
sortOrder: row.sortOrder ?? 0,
checkinEnabled: row.checkinEnabled,
lastCheckinAt: row.lastCheckinAt,
lastBalanceRefresh: row.lastBalanceRefresh,
@@ -508,6 +513,8 @@ function importAccountsSection(section: AccountsBackupSection) {
tx.insert(schema.tokenRoutes).values({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName ?? null,
displayIcon: row.displayIcon ?? null,
modelMapping: row.modelMapping,
enabled: row.enabled,
createdAt: row.createdAt,
@@ -521,6 +528,7 @@ function importAccountsSection(section: AccountsBackupSection) {
routeId: row.routeId,
accountId: row.accountId,
tokenId: row.tokenId,
sourceModel: row.sourceModel ?? null,
priority: row.priority,
weight: row.weight,
enabled: row.enabled,
@@ -167,4 +167,28 @@ describe('downstreamApiKeyService', () => {
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
});
it('accumulates managed key request/cost usage and applies limits', () => {
const row = db.insert(schema.downstreamApiKeys).values({
name: 'metered-key',
key: 'sk-metered-key',
enabled: true,
maxRequests: 2,
maxCost: 1,
usedRequests: 0,
usedCost: 0,
}).returning().get();
service.consumeManagedKeyRequest(row.id);
service.consumeManagedKeyRequest(row.id);
service.recordManagedKeyCostUsage(row.id, 0.4);
service.recordManagedKeyCostUsage(row.id, 0.6);
const latest = service.getDownstreamApiKeyById(row.id);
expect(latest?.usedRequests).toBe(2);
expect(latest?.usedCost).toBeCloseTo(1);
const authResult = service.authorizeDownstreamToken(row.key);
expect(authResult.ok).toBe(false);
});
});
+11 -18
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray } from 'drizzle-orm';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
@@ -360,31 +360,24 @@ export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResu
}
export function consumeManagedKeyRequest(keyId: number): void {
const row = db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, keyId))
.get();
if (!row) return;
const nowIso = new Date().toISOString();
db.update(schema.downstreamApiKeys).set({
usedRequests: Math.max(0, (row.usedRequests || 0) + 1),
lastUsedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// Atomic increment to avoid lost updates under multi-process concurrency.
usedRequests: sql`coalesce(${schema.downstreamApiKeys.usedRequests}, 0) + 1`,
lastUsedAt: nowIso,
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
}
export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void {
const cost = Number(estimatedCost);
if (!Number.isFinite(cost) || cost <= 0) return;
const row = db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, keyId))
.get();
if (!row) return;
const nowIso = new Date().toISOString();
db.update(schema.downstreamApiKeys).set({
usedCost: Math.max(0, (row.usedCost || 0) + cost),
lastUsedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// Atomic increment to avoid lost updates under multi-process concurrency.
usedCost: sql`coalesce(${schema.downstreamApiKeys.usedCost}, 0) + ${cost}`,
lastUsedAt: nowIso,
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
}