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
@@ -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 || {});