forked from boos/BoosAPI
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 || {});
|
||||
|
||||
Reference in New Issue
Block a user