返回 AiToEarn
mcp.utils.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / agent / mcp / mcp.utils.spec.ts
1 import type { AiAvailabilityService } from '../../ai-availability'
2 import { Logger } from '@nestjs/common'
3 import { AppException, ResponseCode } from '@yikart/common'
4 import { vi } from 'vitest'
5 import { z } from 'zod'
6 import {
7 errorResult,
8 formatList,
9 formatObject,
10 secondsToSrtTimestamp,
11 srtTimestampToMs,
12 successResult,
13 wrapTool,
14 } from './mcp.utils'
15
16 describe('mcp.utils', () => {
17 describe('srtTimestampToMs', () => {
18 it('should convert SRT timestamp to milliseconds', () => {
19 expect(srtTimestampToMs('00:01:20,460')).toBe(80460)
20 })
21
22 it('should handle zero timestamp', () => {
23 expect(srtTimestampToMs('00:00:00,000')).toBe(0)
24 })
25
26 it('should handle hours correctly', () => {
27 expect(srtTimestampToMs('01:00:00,000')).toBe(3600000)
28 })
29
30 it('should handle minutes correctly', () => {
31 expect(srtTimestampToMs('00:30:00,000')).toBe(1800000)
32 })
33
34 it('should handle seconds correctly', () => {
35 expect(srtTimestampToMs('00:00:45,000')).toBe(45000)
36 })
37
38 it('should handle milliseconds correctly', () => {
39 expect(srtTimestampToMs('00:00:00,500')).toBe(500)
40 })
41
42 it('should handle complex timestamp', () => {
43 expect(srtTimestampToMs('02:15:30,750')).toBe(8130750)
44 })
45 })
46
47 describe('secondsToSrtTimestamp', () => {
48 it('should convert seconds to SRT timestamp', () => {
49 expect(secondsToSrtTimestamp(80.46)).toBe('00:01:20,460')
50 })
51
52 it('should handle zero seconds', () => {
53 expect(secondsToSrtTimestamp(0)).toBe('00:00:00,000')
54 })
55
56 it('should handle hours', () => {
57 expect(secondsToSrtTimestamp(3600)).toBe('01:00:00,000')
58 })
59
60 it('should handle minutes', () => {
61 expect(secondsToSrtTimestamp(1800)).toBe('00:30:00,000')
62 })
63
64 it('should handle complex time', () => {
65 expect(secondsToSrtTimestamp(8130.75)).toBe('02:15:30,750')
66 })
67
68 it('should pad numbers correctly', () => {
69 expect(secondsToSrtTimestamp(1.001)).toBe('00:00:01,001')
70 })
71 })
72
73 describe('successResult', () => {
74 it('should create success result with string content', () => {
75 const result = successResult('test message')
76 expect(result.content).toEqual([{ type: 'text', text: 'test message' }])
77 expect(result.isError).toBeUndefined()
78 })
79
80 it('should create success result with object content', () => {
81 const obj = { key: 'value', num: 123 }
82 const result = successResult(obj)
83 expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(obj) }])
84 expect(result.isError).toBeUndefined()
85 })
86
87 it('should create success result with array content', () => {
88 const arr = ['item1', 'item2']
89 const result = successResult(arr)
90 expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(arr) }])
91 expect(result.isError).toBeUndefined()
92 })
93
94 it('should pass through content array with type property', () => {
95 const content = [{ type: 'text', text: 'hello' }]
96 const result = successResult(content)
97 expect(result.content).toEqual(content)
98 })
99 })
100
101 describe('errorResult', () => {
102 it('should create error result with string message', () => {
103 const result = errorResult('error message')
104 expect(result.content).toEqual([{ type: 'text', text: 'error message' }])
105 expect(result.isError).toBe(true)
106 })
107
108 it('should create error result with object message', () => {
109 const obj = { error: 'details' }
110 const result = errorResult(obj)
111 expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(obj) }])
112 expect(result.isError).toBe(true)
113 })
114 })
115
116 describe('wrapTool', () => {
117 let mockLogger: Logger
118 let mockAiAvailability: vi.Mocked<Pick<AiAvailabilityService, 'execute'>>
119
120 beforeEach(() => {
121 mockLogger = {
122 debug: vi.fn(),
123 warn: vi.fn(),
124 error: vi.fn(),
125 fatal: vi.fn(),
126 } as unknown as Logger
127
128 mockAiAvailability = {
129 execute: vi.fn().mockImplementation((_ctx, fn) => fn()),
130 }
131 })
132
133 it('should wrap tool and have correct name/description', async () => {
134 const handler = vi.fn().mockResolvedValue(successResult('success'))
135 const schema = { param: z.string() }
136
137 const wrappedTool = wrapTool(
138 mockLogger,
139 'testTool',
140 'Test description',
141 schema,
142 handler,
143 mockAiAvailability as unknown as AiAvailabilityService,
144 )
145
146 expect(wrappedTool).toBeDefined()
147 expect(wrappedTool.name).toBe('testTool')
148 expect(wrappedTool.description).toBe('Test description')
149 })
150
151 it('should delegate to aiAvailability.execute on handler call', async () => {
152 const handler = vi.fn().mockResolvedValue(successResult('success'))
153 const schema = { param: z.string() }
154
155 const wrappedTool = wrapTool(
156 mockLogger,
157 'testTool',
158 'Test description',
159 schema,
160 handler,
161 mockAiAvailability as unknown as AiAvailabilityService,
162 )
163
164 const result = await wrappedTool.handler({ param: 'test' }, {})
165
166 expect(result.isError).toBeUndefined()
167 expect(mockAiAvailability.execute).toHaveBeenCalledWith(
168 expect.objectContaining({ provider: 'mcp', operation: 'testTool' }),
169 expect.any(Function),
170 )
171 })
172
173 it('should return errorResult for handler returning error', async () => {
174 const handler = vi.fn().mockResolvedValue(errorResult('failed'))
175 const schema = { param: z.string() }
176
177 const wrappedTool = wrapTool(
178 mockLogger,
179 'testTool',
180 'Test description',
181 schema,
182 handler,
183 mockAiAvailability as unknown as AiAvailabilityService,
184 )
185
186 const result = await wrappedTool.handler({ param: 'test' }, {})
187
188 expect(result.isError).toBe(true)
189 })
190
191 it('should catch non-AppException errors and return errorResult with fatal log', async () => {
192 const testError = new Error('Test error')
193 const handler = vi.fn().mockRejectedValue(testError)
194 mockAiAvailability.execute.mockRejectedValue(testError)
195 const schema = { param: z.string() }
196
197 const wrappedTool = wrapTool(
198 mockLogger,
199 'testTool',
200 'Test description',
201 schema,
202 handler,
203 mockAiAvailability as unknown as AiAvailabilityService,
204 )
205
206 const result = await wrappedTool.handler({ param: 'test' }, {})
207
208 expect(result.isError).toBe(true)
209 expect(result.content).toEqual([{ type: 'text', text: 'Test error' }])
210 expect(mockLogger.fatal).toHaveBeenCalled()
211 })
212
213 it('should catch AppException and return errorResult without fatal log', async () => {
214 const handler = vi.fn().mockRejectedValue(
215 new AppException(ResponseCode.InvalidModel),
216 )
217 const schema = { param: z.string() }
218
219 const wrappedTool = wrapTool(
220 mockLogger,
221 'testTool',
222 'Test description',
223 schema,
224 handler,
225 mockAiAvailability as unknown as AiAvailabilityService,
226 )
227
228 const result = await wrappedTool.handler({ param: 'test' }, {})
229
230 expect(result.isError).toBe(true)
231 expect(mockLogger.fatal).not.toHaveBeenCalled()
232 expect(mockLogger.warn).toHaveBeenCalled()
233 })
234 })
235
236 describe('formatObject', () => {
237 it('should format object as YAML-like string', () => {
238 const obj = { name: 'test', value: 123 }
239 const result = formatObject(obj)
240 expect(result).toBe('name: test\nvalue: 123')
241 })
242
243 it('should filter out timestamp fields', () => {
244 const obj = {
245 name: 'test',
246 createdAt: new Date(),
247 updatedAt: new Date(),
248 deletedAt: null,
249 __v: 0,
250 _id: 'abc123',
251 }
252 const result = formatObject(obj)
253 expect(result).toBe('name: test')
254 })
255
256 it('should handle nested objects', () => {
257 const obj = { name: 'test', nested: { key: 'value' } }
258 const result = formatObject(obj)
259 expect(result).toContain('name: test')
260 expect(result).toContain('nested: {"key":"value"}')
261 })
262
263 it('should handle arrays', () => {
264 const obj = { name: 'test', items: ['a', 'b', 'c'] }
265 const result = formatObject(obj)
266 expect(result).toContain('name: test')
267 expect(result).toContain('items: a, b, c')
268 })
269
270 it('should skip null and undefined values', () => {
271 const obj = { name: 'test', empty: null, missing: undefined }
272 const result = formatObject(obj)
273 expect(result).toBe('name: test')
274 })
275
276 it('should return empty string for null input', () => {
277 const result = formatObject(null as unknown as Record<string, unknown>)
278 expect(result).toBe('')
279 })
280
281 it('should filter by keepFields when provided', () => {
282 const obj = { name: 'test', value: 123, extra: 'ignored' }
283 const result = formatObject(obj, ['name', 'value'])
284 expect(result).toBe('name: test\nvalue: 123')
285 })
286 })
287
288 describe('formatList', () => {
289 it('should format list with default formatter', () => {
290 const list = ['item1', 'item2', 'item3']
291 const result = formatList(list)
292 expect(result).toBe('Total 3:\n1. item1\n2. item2\n3. item3')
293 })
294
295 it('should format list with custom formatter', () => {
296 const list = [{ name: 'a' }, { name: 'b' }]
297 const result = formatList(list, item => item.name)
298 expect(result).toBe('Total 2:\n1. a\n2. b')
299 })
300
301 it('should return "No data" for empty list', () => {
302 const result = formatList([])
303 expect(result).toBe('No data')
304 })
305
306 it('should return "No data" for null list', () => {
307 const result = formatList(null as unknown as unknown[])
308 expect(result).toBe('No data')
309 })
310
311 it('should pass index to formatter', () => {
312 const list = ['a', 'b']
313 const result = formatList(list, (item, index) => `${index}: ${item}`)
314 expect(result).toBe('Total 2:\n1. 0: a\n2. 1: b')
315 })
316 })
317 })
318
318 lines TYPESCRIPT