| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- import assert from 'node:assert/strict';
- import type { AddressInfo } from 'node:net';
- import test from 'node:test';
- import express from 'express';
- import { FmodeAiClient, type AiGatewayFetch } from '../src/modules/ai-gateway/client.js';
- import { createAiGatewayRouter } from '../src/modules/ai-gateway/routes.js';
- import type { AiPromptConfigRecord, AiPromptConfigStore } from '../src/modules/ai-gateway/prompt-config.repository.js';
- const config = {
- baseUrl: 'https://api.example.test/',
- token: 'server-only-token',
- defaultModel: 'deepseek-v4-pro',
- timeoutMs: 5_000,
- };
- async function listen(fetchImpl: AiGatewayFetch, token = config.token, promptConfigs?: AiPromptConfigStore) {
- const app = express();
- app.use(express.json());
- app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient({ ...config, token }, fetchImpl), promptConfigs));
- const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
- const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
- });
- const address = server.address() as AddressInfo;
- return {
- baseUrl: `http://127.0.0.1:${address.port}`,
- close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
- };
- }
- test('AI gateway reports only public configuration and keeps the token server-side', async () => {
- const server = await listen(async () => new Response('{}'));
- try {
- const response = await fetch(`${server.baseUrl}/api/ai/status`);
- assert.equal(response.status, 200);
- const status = await response.json() as Record<string, unknown>;
- assert.deepEqual(status, {
- service: 'Fmode AI',
- configured: true,
- baseUrl: 'https://api.example.test',
- defaultModel: 'deepseek-v4-pro',
- proxyEndpoint: 'ai.chat',
- });
- assert.equal(JSON.stringify(status).includes(config.token), false);
- } finally {
- await server.close();
- }
- });
- test('AI prompt configuration is read and saved through the backend store', async () => {
- const records = new Map<string, AiPromptConfigRecord>([
- ['shared.analysisPanel.defaultSystem', {
- promptKey: 'shared.analysisPanel.defaultSystem',
- name: '默认分析提示词',
- scope: 'analysis',
- template: '仅使用真实数据',
- }],
- ]);
- const promptConfigs: AiPromptConfigStore = {
- async list() {
- return [...records.values()];
- },
- async upsert(promptKey, value) {
- const saved = { ...value, promptKey };
- records.set(promptKey, saved);
- return saved;
- },
- };
- const server = await listen(async () => new Response('{}'), config.token, promptConfigs);
- try {
- const listResponse = await fetch(`${server.baseUrl}/api/ai/prompts`);
- assert.equal(listResponse.status, 200);
- const list = await listResponse.json() as { items: AiPromptConfigRecord[] };
- assert.equal(list.items[0]?.template, '仅使用真实数据');
- const promptKey = 'shared.analysisPanel.defaultSystem';
- const saveResponse = await fetch(`${server.baseUrl}/api/ai/prompts/${promptKey}`, {
- method: 'PUT',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({
- promptKey,
- name: '默认分析提示词',
- scope: 'analysis',
- template: '只输出可追溯结论',
- }),
- });
- assert.equal(saveResponse.status, 200);
- assert.equal(records.get(promptKey)?.template, '只输出可追溯结论');
- } finally {
- await server.close();
- }
- });
- test('AI gateway validates requests and injects upstream authorization', async () => {
- let upstreamUrl = '';
- let upstreamAuthorization = '';
- let upstreamBody: Record<string, unknown> = {};
- const fetchImpl: AiGatewayFetch = async (input, init) => {
- upstreamUrl = String(input);
- upstreamAuthorization = new Headers(init?.headers).get('authorization') || '';
- upstreamBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>;
- return new Response(JSON.stringify({
- model: upstreamBody['model'],
- choices: [{ message: { role: 'assistant', content: '分析完成' }, finish_reason: 'stop' }],
- }), { status: 200, headers: { 'content-type': 'application/json' } });
- };
- const server = await listen(fetchImpl);
- try {
- const invalid = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], token: 'browser-token' }),
- });
- assert.equal(invalid.status, 400);
- const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ messages: [{ role: 'user', content: '分析真实评论' }], stream: false }),
- });
- assert.equal(response.status, 200);
- assert.equal(upstreamUrl, 'https://api.example.test/v1/chat/completions');
- assert.equal(upstreamAuthorization, 'Bearer server-only-token');
- assert.equal(upstreamBody['model'], 'deepseek-v4-pro');
- assert.equal(Object.prototype.hasOwnProperty.call(upstreamBody, 'token'), false);
- } finally {
- await server.close();
- }
- });
- test('AI gateway streams SSE responses without buffering', async () => {
- const fetchImpl: AiGatewayFetch = async () => new Response(
- 'data: {"choices":[{"delta":{"content":"洞察"}}]}\n\ndata: [DONE]\n\n',
- { status: 200, headers: { 'content-type': 'text/event-stream' } },
- );
- const server = await listen(fetchImpl);
- try {
- const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: true }),
- });
- assert.equal(response.status, 200);
- assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
- assert.match(await response.text(), /洞察/);
- } finally {
- await server.close();
- }
- });
- test('AI gateway returns a controlled error when the server token is missing', async () => {
- const server = await listen(async () => new Response('{}'), '');
- try {
- const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: false }),
- });
- assert.equal(response.status, 503);
- assert.equal((await response.json() as any).error.message, 'AI 服务尚未配置,请联系管理员');
- } finally {
- await server.close();
- }
- });
- test('AI gateway maps upstream transport failures without exposing internals', async () => {
- const server = await listen(async () => {
- throw new TypeError('socket closed while connecting to upstream');
- });
- try {
- const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: false }),
- });
- assert.equal(response.status, 502);
- const payload = await response.json() as { error: { message: string } };
- assert.equal(payload.error.message, 'AI 上游连接失败,请稍后重试');
- assert.equal(JSON.stringify(payload).includes(config.token), false);
- assert.equal(JSON.stringify(payload).includes('socket closed'), false);
- } finally {
- await server.close();
- }
- });
|