Add tests for settings database runtime configuration API

- Implement tests for retrieving and saving runtime database configuration.
- Validate handling of invalid configuration payloads.
- Utilize Fastify for API testing with Vitest.
- Ensure proper cleanup of database state between tests.
This commit is contained in:
cita
2026-03-05 18:57:18 +08:00
parent 283c052fa5
commit b122893796
6 changed files with 715 additions and 180 deletions
+32 -3
View File
@@ -9,7 +9,7 @@ import { config } from '../config.js';
import { mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
type RuntimeDbDialect = 'sqlite' | 'mysql' | 'postgres';
export type RuntimeDbDialect = 'sqlite' | 'mysql' | 'postgres';
type SqlMethod = 'all' | 'get' | 'run' | 'values' | 'execute';
const TABLES_WITH_NUMERIC_ID = new Set([
@@ -26,7 +26,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
'events',
]);
export const runtimeDbDialect: RuntimeDbDialect = config.dbType;
export let runtimeDbDialect: RuntimeDbDialect = config.dbType;
let sqliteConnection: Database.Database | null = null;
let mysqlPool: mysql.Pool | null = null;
@@ -590,7 +590,13 @@ function initDb(): AppDb {
return initSqliteDb();
}
export const db: any = initDb();
let activeDb: AppDb = initDb();
export const db: any = new Proxy({}, {
get(_target, prop) {
return (activeDb as any)?.[prop as keyof typeof activeDb];
},
});
export { schema };
export async function closeDbConnections(): Promise<void> {
@@ -607,3 +613,26 @@ export async function closeDbConnections(): Promise<void> {
sqliteConnection = null;
}
}
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string): Promise<void> {
const previousDialect = runtimeDbDialect;
const previousDbUrl = config.dbUrl;
const previousConfigDialect = config.dbType;
await closeDbConnections();
runtimeDbDialect = nextDialect;
config.dbType = nextDialect;
config.dbUrl = nextDbUrl;
try {
activeDb = initDb();
} catch (error) {
await closeDbConnections();
runtimeDbDialect = previousDialect;
config.dbType = previousConfigDialect;
config.dbUrl = previousDbUrl;
activeDb = initDb();
throw error;
}
}
+98 -50
View File
@@ -23,51 +23,80 @@ import { startProxyLogRetentionService, stopProxyLogRetentionService } from './s
import { buildStartupSummaryLines } from './services/startupInfo.js';
import { existsSync } from 'fs';
import { normalize, resolve, sep } from 'path';
import { db, schema } from './db/index.js';
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
// Load runtime config overrides from settings
try {
const rows = await db.select().from(schema.settings).all();
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
function toSettingsMap(rows: Array<{ key: string; value: string }>) {
return new Map(rows.map((row) => [row.key, row.value]));
}
const parseSetting = <T>(key: string): T | undefined => {
const raw = settingsMap.get(key);
if (typeof raw !== 'string' || !raw) return undefined;
try {
return JSON.parse(raw) as T;
} catch {
return undefined;
}
};
function parseSettingFromMap<T>(settingsMap: Map<string, string>, key: string): T | undefined {
const raw = settingsMap.get(key);
if (typeof raw !== 'string' || !raw) return undefined;
try {
return JSON.parse(raw) as T;
} catch {
return undefined;
}
}
const toStringList = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return [];
};
function toStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return [];
}
const authToken = parseSetting<string>('auth_token');
function normalizeSavedDbType(value: unknown): RuntimeDbDialect | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
if (normalized === 'sqlite') return 'sqlite';
if (normalized === 'mysql') return 'mysql';
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
return null;
}
function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
if (!normalized) return null;
if (dialect === 'sqlite') return normalized;
if (dialect === 'mysql' && normalized.startsWith('mysql://')) return normalized;
if (dialect === 'postgres' && (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://'))) return normalized;
return null;
}
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string } | null {
const rawType = parseSettingFromMap<unknown>(settingsMap, 'db_type');
const rawUrl = parseSettingFromMap<unknown>(settingsMap, 'db_url');
const dialect = normalizeSavedDbType(rawType);
if (!dialect) return null;
const dbUrl = validateSavedDbUrl(dialect, rawUrl);
if (!dbUrl) return null;
return { dialect, dbUrl };
}
function applyRuntimeSettings(settingsMap: Map<string, string>) {
const authToken = parseSettingFromMap<string>(settingsMap, 'auth_token');
if (typeof authToken === 'string' && authToken) config.authToken = authToken;
const proxyToken = parseSetting<string>('proxy_token');
const proxyToken = parseSettingFromMap<string>(settingsMap, 'proxy_token');
if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken;
const checkinCron = parseSetting<string>('checkin_cron');
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
const balanceRefreshCron = parseSetting<string>('balance_refresh_cron');
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
const routingWeights = parseSetting<Partial<typeof config.routingWeights>>('routing_weights');
const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights');
if (routingWeights && typeof routingWeights === 'object') {
config.routingWeights = {
...config.routingWeights,
@@ -75,65 +104,84 @@ try {
};
}
const routingFallbackUnitCost = parseSetting<number>('routing_fallback_unit_cost');
const routingFallbackUnitCost = parseSettingFromMap<number>(settingsMap, 'routing_fallback_unit_cost');
if (typeof routingFallbackUnitCost === 'number' && Number.isFinite(routingFallbackUnitCost) && routingFallbackUnitCost > 0) {
config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost);
}
const webhookUrl = parseSetting<string>('webhook_url');
const webhookUrl = parseSettingFromMap<string>(settingsMap, 'webhook_url');
if (typeof webhookUrl === 'string') config.webhookUrl = webhookUrl;
const barkUrl = parseSetting<string>('bark_url');
const barkUrl = parseSettingFromMap<string>(settingsMap, 'bark_url');
if (typeof barkUrl === 'string') config.barkUrl = barkUrl;
const serverChanKey = parseSetting<string>('serverchan_key');
const serverChanKey = parseSettingFromMap<string>(settingsMap, 'serverchan_key');
if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey;
const telegramEnabled = parseSetting<boolean>('telegram_enabled');
const telegramEnabled = parseSettingFromMap<boolean>(settingsMap, 'telegram_enabled');
if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled;
const telegramBotToken = parseSetting<string>('telegram_bot_token');
const telegramBotToken = parseSettingFromMap<string>(settingsMap, 'telegram_bot_token');
if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken;
const telegramChatId = parseSetting<string>('telegram_chat_id');
const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id');
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
const smtpEnabled = parseSetting<boolean>('smtp_enabled');
const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled');
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
const smtpHost = parseSetting<string>('smtp_host');
const smtpHost = parseSettingFromMap<string>(settingsMap, 'smtp_host');
if (typeof smtpHost === 'string') config.smtpHost = smtpHost;
const smtpPort = parseSetting<number>('smtp_port');
const smtpPort = parseSettingFromMap<number>(settingsMap, 'smtp_port');
if (typeof smtpPort === 'number' && Number.isFinite(smtpPort) && smtpPort > 0) {
config.smtpPort = smtpPort;
}
const smtpSecure = parseSetting<boolean>('smtp_secure');
const smtpSecure = parseSettingFromMap<boolean>(settingsMap, 'smtp_secure');
if (typeof smtpSecure === 'boolean') config.smtpSecure = smtpSecure;
const smtpUser = parseSetting<string>('smtp_user');
const smtpUser = parseSettingFromMap<string>(settingsMap, 'smtp_user');
if (typeof smtpUser === 'string') config.smtpUser = smtpUser;
const smtpPass = parseSetting<string>('smtp_pass');
const smtpPass = parseSettingFromMap<string>(settingsMap, 'smtp_pass');
if (typeof smtpPass === 'string') config.smtpPass = smtpPass;
const smtpFrom = parseSetting<string>('smtp_from');
const smtpFrom = parseSettingFromMap<string>(settingsMap, 'smtp_from');
if (typeof smtpFrom === 'string') config.smtpFrom = smtpFrom;
const smtpTo = parseSetting<string>('smtp_to');
const smtpTo = parseSettingFromMap<string>(settingsMap, 'smtp_to');
if (typeof smtpTo === 'string') config.smtpTo = smtpTo;
const notifyCooldownSec = parseSetting<number>('notify_cooldown_sec');
const notifyCooldownSec = parseSettingFromMap<number>(settingsMap, 'notify_cooldown_sec');
if (typeof notifyCooldownSec === 'number' && Number.isFinite(notifyCooldownSec) && notifyCooldownSec >= 0) {
config.notifyCooldownSec = Math.trunc(notifyCooldownSec);
}
const adminIpAllowlist = parseSetting<string[] | string>('admin_ip_allowlist');
const adminIpAllowlist = parseSettingFromMap<string[] | string>(settingsMap, 'admin_ip_allowlist');
if (adminIpAllowlist !== undefined) {
config.adminIpAllowlist = toStringList(adminIpAllowlist);
}
}
// Load runtime config overrides from settings
try {
const initialRows = await db.select().from(schema.settings).all();
const initialMap = toSettingsMap(initialRows);
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
const activeDbUrl = (config.dbUrl || '').trim();
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl)) {
try {
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl);
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
} catch (error) {
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
}
}
const finalRows = await db.select().from(schema.settings).all();
const finalMap = toSettingsMap(finalRows);
applyRuntimeSettings(finalMap);
console.log('Loaded runtime settings overrides');
} catch { /* first run, table may not exist */ }
@@ -0,0 +1,109 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
type ConfigModule = typeof import('../../config.js');
describe('settings database runtime config api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let config: ConfigModule['config'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-db-runtime-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const configModule = await import('../../config.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.settings).run();
await db.delete(schema.events).run();
config.dbType = 'sqlite';
config.dbUrl = '';
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('gets runtime database config state', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/settings/database/runtime',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success?: boolean;
active?: { dialect?: string };
saved?: unknown;
restartRequired?: boolean;
};
expect(body.success).toBe(true);
expect(body.active?.dialect).toBe('sqlite');
expect(body.saved).toBeNull();
expect(body.restartRequired).toBe(false);
});
it('saves runtime database config and marks restart required', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/database/runtime',
payload: {
dialect: 'postgres',
connectionString: 'postgres://metapi:secret@127.0.0.1:5432/postgres',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success?: boolean;
saved?: { dialect?: string; connection?: string };
restartRequired?: boolean;
};
expect(body.success).toBe(true);
expect(body.saved?.dialect).toBe('postgres');
expect(body.saved?.connection).toContain('***');
expect(body.restartRequired).toBe(true);
const dbType = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get();
const dbUrl = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get();
expect(dbType?.value).toBe(JSON.stringify('postgres'));
expect(dbUrl?.value).toBe(JSON.stringify('postgres://metapi:secret@127.0.0.1:5432/postgres'));
});
it('rejects invalid runtime database config payload', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/database/runtime',
payload: {
dialect: 'postgres',
connectionString: 'mysql://root:pass@127.0.0.1:3306/mysql',
},
});
expect(response.statusCode).toBe(400);
const body = response.json() as { success?: boolean; message?: string };
expect(body.success).toBe(false);
expect(body.message || '').toContain('postgres');
});
});
+113 -2
View File
@@ -1,13 +1,19 @@
import { FastifyInstance } from 'fastify';
import cron from 'node-cron';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { migrateCurrentDatabase, testDatabaseConnection } from '../../services/databaseMigrationService.js';
import {
maskConnectionString,
migrateCurrentDatabase,
normalizeMigrationInput,
testDatabaseConnection,
type MigrationDialect,
} from '../../services/databaseMigrationService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
type RoutingWeights = typeof config.routingWeights;
@@ -45,7 +51,14 @@ interface DatabaseMigrationBody {
overwrite?: unknown;
}
type RuntimeDatabaseConfig = {
dialect: MigrationDialect;
connectionString: string;
};
const PROXY_TOKEN_PREFIX = 'sk-';
const DB_TYPE_SETTING_KEY = 'db_type';
const DB_URL_SETTING_KEY = 'db_url';
function isValidProxyToken(value: string): boolean {
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
@@ -281,6 +294,66 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
};
}
function parseJsonValue(raw: unknown): unknown {
if (typeof raw !== 'string' || !raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function maskRuntimeConnection(dialect: MigrationDialect, connectionString: string): string {
const normalized = connectionString.trim();
if (dialect === 'sqlite' && !normalized) return '(default sqlite path)';
return maskConnectionString(normalized);
}
async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig | null> {
const settingsRows = await db.select().from(schema.settings).all();
const map = new Map(settingsRows.map((row) => [row.key, row.value]));
const rawDialect = parseJsonValue(map.get(DB_TYPE_SETTING_KEY));
const rawConnection = parseJsonValue(map.get(DB_URL_SETTING_KEY));
if (typeof rawDialect !== 'string' || typeof rawConnection !== 'string') {
return null;
}
try {
const normalized = normalizeMigrationInput({
dialect: rawDialect,
connectionString: rawConnection,
});
return {
dialect: normalized.dialect,
connectionString: normalized.connectionString,
};
} catch {
return null;
}
}
function buildRuntimeDatabaseState(saved: RuntimeDatabaseConfig | null) {
const activeDialect = runtimeDbDialect;
const activeConnection = (config.dbUrl || '').trim();
const restartRequired = !!saved && (
saved.dialect !== activeDialect || saved.connectionString.trim() !== activeConnection
);
return {
active: {
dialect: activeDialect,
connection: maskRuntimeConnection(activeDialect, activeConnection),
},
saved: saved
? {
dialect: saved.dialect,
connection: maskRuntimeConnection(saved.dialect, saved.connectionString),
}
: null,
restartRequired,
};
}
export async function settingsRoutes(app: FastifyInstance) {
await app.get('/api/settings/runtime', async (request) => {
const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
@@ -602,6 +675,44 @@ export async function settingsRoutes(app: FastifyInstance) {
};
});
app.get('/api/settings/database/runtime', async () => {
const saved = await loadSavedRuntimeDatabaseConfig();
return {
success: true,
...buildRuntimeDatabaseState(saved),
};
});
app.put<{ Body: DatabaseMigrationBody }>('/api/settings/database/runtime', async (request, reply) => {
try {
const normalized = normalizeMigrationInput(request.body || {});
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
await appendSettingsEvent({
type: 'status',
title: '数据库运行配置已更新',
message: `已保存运行数据库配置:${normalized.dialect}(重启后生效)`,
});
const saved: RuntimeDatabaseConfig = {
dialect: normalized.dialect,
connectionString: normalized.connectionString,
};
return {
success: true,
message: '数据库运行配置已保存,重启容器后生效',
...buildRuntimeDatabaseState(saved),
};
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '数据库运行配置保存失败',
});
}
});
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => {
try {
const result = await testDatabaseConnection(request.body || {});
+6
View File
@@ -202,6 +202,12 @@ export const api = {
method: 'PUT',
body: JSON.stringify(data),
}),
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string }) =>
request('/api/settings/database/runtime', {
method: 'PUT',
body: JSON.stringify(data),
}),
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
request('/api/settings/database/test-connection', {
method: 'POST',
+357 -125
View File
File diff suppressed because it is too large Load Diff