ai-gateway.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import assert from 'node:assert/strict';
  2. import type { AddressInfo } from 'node:net';
  3. import test from 'node:test';
  4. import express from 'express';
  5. import { FmodeAiClient, type AiGatewayFetch } from '../src/modules/ai-gateway/client.js';
  6. import { createAiGatewayRouter } from '../src/modules/ai-gateway/routes.js';
  7. import type { AiPromptConfigRecord, AiPromptConfigStore } from '../src/modules/ai-gateway/prompt-config.repository.js';
  8. const config = {
  9. baseUrl: 'https://api.example.test/',
  10. token: 'server-only-token',
  11. defaultModel: 'deepseek-v4-pro',
  12. timeoutMs: 5_000,
  13. };
  14. async function listen(fetchImpl: AiGatewayFetch, token = config.token, promptConfigs?: AiPromptConfigStore) {
  15. const app = express();
  16. app.use(express.json());
  17. app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient({ ...config, token }, fetchImpl), promptConfigs));
  18. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
  19. const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
  20. });
  21. const address = server.address() as AddressInfo;
  22. return {
  23. baseUrl: `http://127.0.0.1:${address.port}`,
  24. close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
  25. };
  26. }
  27. test('AI gateway reports only public configuration and keeps the token server-side', async () => {
  28. const server = await listen(async () => new Response('{}'));
  29. try {
  30. const response = await fetch(`${server.baseUrl}/api/ai/status`);
  31. assert.equal(response.status, 200);
  32. const status = await response.json() as Record<string, unknown>;
  33. assert.deepEqual(status, {
  34. service: 'Fmode AI',
  35. configured: true,
  36. baseUrl: 'https://api.example.test',
  37. defaultModel: 'deepseek-v4-pro',
  38. proxyEndpoint: 'ai.chat',
  39. });
  40. assert.equal(JSON.stringify(status).includes(config.token), false);
  41. } finally {
  42. await server.close();
  43. }
  44. });
  45. test('AI prompt configuration is read and saved through the backend store', async () => {
  46. const records = new Map<string, AiPromptConfigRecord>([
  47. ['shared.analysisPanel.defaultSystem', {
  48. promptKey: 'shared.analysisPanel.defaultSystem',
  49. name: '默认分析提示词',
  50. scope: 'analysis',
  51. template: '仅使用真实数据',
  52. }],
  53. ]);
  54. const promptConfigs: AiPromptConfigStore = {
  55. async list() {
  56. return [...records.values()];
  57. },
  58. async upsert(promptKey, value) {
  59. const saved = { ...value, promptKey };
  60. records.set(promptKey, saved);
  61. return saved;
  62. },
  63. };
  64. const server = await listen(async () => new Response('{}'), config.token, promptConfigs);
  65. try {
  66. const listResponse = await fetch(`${server.baseUrl}/api/ai/prompts`);
  67. assert.equal(listResponse.status, 200);
  68. const list = await listResponse.json() as { items: AiPromptConfigRecord[] };
  69. assert.equal(list.items[0]?.template, '仅使用真实数据');
  70. const promptKey = 'shared.analysisPanel.defaultSystem';
  71. const saveResponse = await fetch(`${server.baseUrl}/api/ai/prompts/${promptKey}`, {
  72. method: 'PUT',
  73. headers: { 'content-type': 'application/json' },
  74. body: JSON.stringify({
  75. promptKey,
  76. name: '默认分析提示词',
  77. scope: 'analysis',
  78. template: '只输出可追溯结论',
  79. }),
  80. });
  81. assert.equal(saveResponse.status, 200);
  82. assert.equal(records.get(promptKey)?.template, '只输出可追溯结论');
  83. } finally {
  84. await server.close();
  85. }
  86. });
  87. test('AI gateway validates requests and injects upstream authorization', async () => {
  88. let upstreamUrl = '';
  89. let upstreamAuthorization = '';
  90. let upstreamBody: Record<string, unknown> = {};
  91. const fetchImpl: AiGatewayFetch = async (input, init) => {
  92. upstreamUrl = String(input);
  93. upstreamAuthorization = new Headers(init?.headers).get('authorization') || '';
  94. upstreamBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>;
  95. return new Response(JSON.stringify({
  96. model: upstreamBody['model'],
  97. choices: [{ message: { role: 'assistant', content: '分析完成' }, finish_reason: 'stop' }],
  98. }), { status: 200, headers: { 'content-type': 'application/json' } });
  99. };
  100. const server = await listen(fetchImpl);
  101. try {
  102. const invalid = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
  103. method: 'POST',
  104. headers: { 'content-type': 'application/json' },
  105. body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], token: 'browser-token' }),
  106. });
  107. assert.equal(invalid.status, 400);
  108. const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
  109. method: 'POST',
  110. headers: { 'content-type': 'application/json' },
  111. body: JSON.stringify({ messages: [{ role: 'user', content: '分析真实评论' }], stream: false }),
  112. });
  113. assert.equal(response.status, 200);
  114. assert.equal(upstreamUrl, 'https://api.example.test/v1/chat/completions');
  115. assert.equal(upstreamAuthorization, 'Bearer server-only-token');
  116. assert.equal(upstreamBody['model'], 'deepseek-v4-pro');
  117. assert.equal(Object.prototype.hasOwnProperty.call(upstreamBody, 'token'), false);
  118. } finally {
  119. await server.close();
  120. }
  121. });
  122. test('AI gateway streams SSE responses without buffering', async () => {
  123. const fetchImpl: AiGatewayFetch = async () => new Response(
  124. 'data: {"choices":[{"delta":{"content":"洞察"}}]}\n\ndata: [DONE]\n\n',
  125. { status: 200, headers: { 'content-type': 'text/event-stream' } },
  126. );
  127. const server = await listen(fetchImpl);
  128. try {
  129. const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
  130. method: 'POST',
  131. headers: { 'content-type': 'application/json' },
  132. body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: true }),
  133. });
  134. assert.equal(response.status, 200);
  135. assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
  136. assert.match(await response.text(), /洞察/);
  137. } finally {
  138. await server.close();
  139. }
  140. });
  141. test('AI gateway returns a controlled error when the server token is missing', async () => {
  142. const server = await listen(async () => new Response('{}'), '');
  143. try {
  144. const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
  145. method: 'POST',
  146. headers: { 'content-type': 'application/json' },
  147. body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: false }),
  148. });
  149. assert.equal(response.status, 503);
  150. assert.equal((await response.json() as any).error.message, 'AI 服务尚未配置,请联系管理员');
  151. } finally {
  152. await server.close();
  153. }
  154. });
  155. test('AI gateway maps upstream transport failures without exposing internals', async () => {
  156. const server = await listen(async () => {
  157. throw new TypeError('socket closed while connecting to upstream');
  158. });
  159. try {
  160. const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
  161. method: 'POST',
  162. headers: { 'content-type': 'application/json' },
  163. body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: false }),
  164. });
  165. assert.equal(response.status, 502);
  166. const payload = await response.json() as { error: { message: string } };
  167. assert.equal(payload.error.message, 'AI 上游连接失败,请稍后重试');
  168. assert.equal(JSON.stringify(payload).includes(config.token), false);
  169. assert.equal(JSON.stringify(payload).includes('socket closed'), false);
  170. } finally {
  171. await server.close();
  172. }
  173. });