返回 AiToEarn
video.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / video / video.service.spec.ts
1 import type { AssetsService, VideoMetadataService } from '@yikart/assets'
2 import type { AiLog, AiLogRepository, MaterialGroupRepository, MediaRepository, UserRepository } from '@yikart/mongodb'
3 import { FileUtil, ResponseCode, UserType } from '@yikart/common'
4 import { AiLogChannel, AiLogStatus, AiLogType, MediaType } from '@yikart/mongodb'
5 import { vi } from 'vitest'
6 import { TaskStatus } from '../../../common'
7 import { VideoService } from './video.service'
8
9 describe('videoService', () => {
10 let service: VideoService
11 let mockAiLogRepo: {
12 updateById: ReturnType<typeof vi.fn>
13 getById: ReturnType<typeof vi.fn>
14 }
15 let mockMaterialGroupRepository: {
16 getInfo: ReturnType<typeof vi.fn>
17 }
18 let mockMediaRepository: {
19 create: ReturnType<typeof vi.fn>
20 }
21 let mockAssetsService: {
22 uploadFromBuffer: ReturnType<typeof vi.fn>
23 }
24 let mockVideoMetadataService: {
25 extractThumbnailFromUrl: ReturnType<typeof vi.fn>
26 }
27 let mockGrokVideoService: {
28 createFromRequest: ReturnType<typeof vi.fn>
29 extractInput: ReturnType<typeof vi.fn>
30 getTaskResult: ReturnType<typeof vi.fn>
31 }
32 let mockVolcengineVideoService: {
33 createFromRequest: ReturnType<typeof vi.fn>
34 extractInput: ReturnType<typeof vi.fn>
35 getTaskResult: ReturnType<typeof vi.fn>
36 }
37 let mockDashscopeVideoService: {
38 createFromRequest: ReturnType<typeof vi.fn>
39 extractInput: ReturnType<typeof vi.fn>
40 getTaskResult: ReturnType<typeof vi.fn>
41 }
42
43 beforeEach(() => {
44 FileUtil.init({ endpoint: 'https://cdn.example.com' })
45
46 const generationModels = [
47 {
48 name: 'grok-imagine-video',
49 description: 'Grok Video',
50 channel: 'grok',
51 modes: ['text2video', 'image2video', 'video2video'],
52 resolutions: ['720p'],
53 durations: [5, 8],
54 maxInputImages: 1,
55 aspectRatios: ['9:16'],
56 tags: [],
57 defaults: {
58 duration: 8,
59 aspectRatio: '9:16',
60 },
61 },
62 {
63 name: 'mode-fallback-model',
64 description: 'Mode Fallback Model',
65 channel: 'volcengine',
66 modes: ['text2video', 'video2video'],
67 resolutions: ['720p'],
68 durations: [5],
69 maxInputImages: 1,
70 aspectRatios: ['9:16'],
71 tags: [],
72 defaults: {
73 duration: 5,
74 aspectRatio: '9:16',
75 resolution: '720p',
76 },
77 },
78 ]
79
80 mockAiLogRepo = {
81 updateById: vi.fn(),
82 getById: vi.fn(),
83 }
84 mockMaterialGroupRepository = {
85 getInfo: vi.fn(),
86 }
87 mockMediaRepository = {
88 create: vi.fn(),
89 }
90 mockAssetsService = {
91 uploadFromBuffer: vi.fn(),
92 }
93 mockVideoMetadataService = {
94 extractThumbnailFromUrl: vi.fn(),
95 }
96 mockGrokVideoService = {
97 createFromRequest: vi.fn(),
98 extractInput: vi.fn(),
99 getTaskResult: vi.fn(),
100 }
101 mockVolcengineVideoService = {
102 createFromRequest: vi.fn(),
103 extractInput: vi.fn(),
104 getTaskResult: vi.fn(),
105 }
106 mockDashscopeVideoService = {
107 createFromRequest: vi.fn(),
108 extractInput: vi.fn(),
109 getTaskResult: vi.fn(),
110 }
111
112 service = new VideoService(
113 {} as UserRepository,
114 mockAiLogRepo as unknown as AiLogRepository,
115 { config: { video: { generation: generationModels } } } as never,
116 mockAssetsService as unknown as AssetsService,
117 mockVideoMetadataService as unknown as VideoMetadataService,
118 mockMaterialGroupRepository as unknown as MaterialGroupRepository,
119 mockMediaRepository as unknown as MediaRepository,
120 mockVolcengineVideoService as never,
121 {} as never,
122 mockGrokVideoService as never,
123 mockDashscopeVideoService as never,
124 )
125 })
126
127 it('groupId 非法时在提交阶段直接报错', async () => {
128 mockMaterialGroupRepository.getInfo.mockResolvedValue(null)
129
130 await expect(service.userVideoGeneration({
131 userId: 'user-1',
132 userType: UserType.User,
133 model: 'grok-imagine-video',
134 prompt: 'test prompt',
135 groupId: 'group-1',
136 })).rejects.toMatchObject({ code: ResponseCode.MaterialGroupNotFound })
137
138 expect(mockGrokVideoService.createFromRequest).not.toHaveBeenCalled()
139 })
140
141 it('grok 渠道将请求分发给渠道服务', async () => {
142 mockGrokVideoService.createFromRequest.mockResolvedValue({ id: 'task-1' })
143
144 const request = {
145 userId: 'user-1',
146 userType: UserType.User,
147 model: 'grok-imagine-video',
148 prompt: 'test prompt',
149 image: ['https://example.com/1.png', 'https://example.com/2.png'],
150 duration: 10,
151 metadata: {
152 aspectRatio: '16:9',
153 resolution: '720p',
154 },
155 }
156
157 const result = await service.userVideoGeneration(request)
158
159 expect(mockGrokVideoService.createFromRequest).toHaveBeenCalledWith(request)
160 expect(result).toEqual({
161 id: 'task-1',
162 status: TaskStatus.Submitted,
163 })
164 })
165
166 it('成功任务带 groupId 时会自动保存到素材并返回保存结果', async () => {
167 mockGrokVideoService.extractInput.mockReturnValue({ prompt: 'test prompt' })
168 mockGrokVideoService.getTaskResult.mockReturnValue({
169 status: TaskStatus.Success,
170 videoUrl: 'https://cdn.example.com/videos/generated.mp4',
171 })
172 mockVideoMetadataService.extractThumbnailFromUrl.mockResolvedValue(Buffer.from('thumb'))
173 mockAssetsService.uploadFromBuffer.mockResolvedValue({ asset: { path: 'covers/generated.png' } })
174 mockMediaRepository.create.mockResolvedValue({ id: 'media-1' })
175
176 const aiLog = createAiLog({
177 channel: AiLogChannel.Grok,
178 request: { prompt: 'test prompt', groupId: 'group-1' },
179 response: { status: 'done', videoUrl: 'videos/generated.mp4' },
180 })
181
182 const result = await service.transformToCommonResponse(aiLog)
183
184 expect(mockVideoMetadataService.extractThumbnailFromUrl).toHaveBeenCalledWith(
185 'https://cdn.example.com/videos/generated.mp4',
186 2,
187 )
188 expect(mockMediaRepository.create).toHaveBeenCalledWith({
189 userId: 'user-1',
190 userType: UserType.User,
191 materialGroupId: 'group-1',
192 type: MediaType.VIDEO,
193 url: 'videos/generated.mp4',
194 thumbUrl: 'covers/generated.png',
195 })
196 expect(mockAiLogRepo.updateById).toHaveBeenCalledWith('ai-1', {
197 $set: {
198 response: expect.objectContaining({
199 status: 'done',
200 videoUrl: 'videos/generated.mp4',
201 mediaId: 'media-1',
202 coverUrl: 'covers/generated.png',
203 groupId: 'group-1',
204 }),
205 },
206 })
207 expect(result).toMatchObject({
208 status: TaskStatus.Success,
209 videoUrl: 'https://cdn.example.com/videos/generated.mp4',
210 coverUrl: 'https://cdn.example.com/covers/generated.png',
211 mediaId: 'media-1',
212 groupId: 'group-1',
213 input: {
214 prompt: 'test prompt',
215 groupId: 'group-1',
216 },
217 })
218 })
219
220 it('已有 mediaId 时不会重复创建素材', async () => {
221 mockGrokVideoService.extractInput.mockReturnValue({ prompt: 'test prompt' })
222 mockGrokVideoService.getTaskResult.mockReturnValue({
223 status: TaskStatus.Success,
224 videoUrl: 'https://cdn.example.com/videos/generated.mp4',
225 })
226
227 const aiLog = createAiLog({
228 channel: AiLogChannel.Grok,
229 request: { prompt: 'test prompt', groupId: 'group-1' },
230 response: {
231 status: 'done',
232 videoUrl: 'videos/generated.mp4',
233 mediaId: 'media-existing',
234 coverUrl: 'covers/existing.png',
235 },
236 })
237
238 const result = await service.transformToCommonResponse(aiLog)
239
240 expect(mockMediaRepository.create).not.toHaveBeenCalled()
241 expect(mockAiLogRepo.updateById).not.toHaveBeenCalled()
242 expect(mockVideoMetadataService.extractThumbnailFromUrl).not.toHaveBeenCalled()
243 expect(result).toMatchObject({
244 mediaId: 'media-existing',
245 groupId: 'group-1',
246 coverUrl: 'https://cdn.example.com/covers/existing.png',
247 })
248 })
249
250 it('未传 groupId 时保持原行为,不创建素材', async () => {
251 mockGrokVideoService.extractInput.mockReturnValue({ prompt: 'test prompt' })
252 mockGrokVideoService.getTaskResult.mockReturnValue({
253 status: TaskStatus.Success,
254 videoUrl: 'https://cdn.example.com/videos/generated.mp4',
255 })
256
257 const aiLog = createAiLog({
258 channel: AiLogChannel.Grok,
259 request: { prompt: 'test prompt' },
260 response: { status: 'done', videoUrl: 'videos/generated.mp4' },
261 })
262
263 const result = await service.transformToCommonResponse(aiLog)
264
265 expect(mockMediaRepository.create).not.toHaveBeenCalled()
266 expect(mockAiLogRepo.updateById).not.toHaveBeenCalled()
267 expect(result).toMatchObject({
268 status: TaskStatus.Success,
269 mediaId: undefined,
270 groupId: undefined,
271 coverUrl: undefined,
272 })
273 })
274
275 it('仅有 request.groupId 但尚未保存时不会返回保存结果', async () => {
276 const aiLog = createAiLog({
277 status: AiLogStatus.Generating,
278 request: { prompt: 'test prompt', groupId: 'group-1' },
279 response: undefined,
280 })
281
282 const result = await service.transformToCommonResponse(aiLog)
283
284 expect(result).toMatchObject({
285 status: TaskStatus.InProgress,
286 mediaId: undefined,
287 groupId: undefined,
288 coverUrl: undefined,
289 })
290 })
291
292 it('失败任务保留 running response 时仍返回失败状态', async () => {
293 mockGrokVideoService.extractInput.mockReturnValue({ prompt: 'test prompt' })
294
295 const aiLog = createAiLog({
296 status: AiLogStatus.Failed,
297 duration: 60 * 60 * 1000,
298 errorMessage: 'AI task timed out after 1 hour',
299 request: { prompt: 'test prompt', groupId: 'group-1' },
300 response: { status: 'pending' },
301 })
302
303 const result = await service.transformToCommonResponse(aiLog)
304
305 expect(mockGrokVideoService.getTaskResult).not.toHaveBeenCalled()
306 expect(result).toMatchObject({
307 status: TaskStatus.Failure,
308 videoUrl: undefined,
309 mediaId: undefined,
310 groupId: undefined,
311 coverUrl: undefined,
312 error: {
313 message: 'AI task timed out after 1 hour',
314 },
315 finishedAt: new Date('2025-01-01T01:00:00.000Z'),
316 })
317 })
318
319 it('volcengine 成功任务即使有 last_frame_url 也会统一截帧生成封面', async () => {
320 mockVolcengineVideoService.extractInput.mockReturnValue({ prompt: 'test prompt' })
321 mockVolcengineVideoService.getTaskResult.mockReturnValue({
322 status: TaskStatus.Success,
323 videoUrl: 'https://cdn.example.com/videos/generated.mp4',
324 })
325 mockVideoMetadataService.extractThumbnailFromUrl.mockResolvedValue(Buffer.from('thumb'))
326 mockAssetsService.uploadFromBuffer.mockResolvedValue({ asset: { path: 'covers/generated-from-thumb.png' } })
327 mockMediaRepository.create.mockResolvedValue({ id: 'media-2' })
328
329 const aiLog = createAiLog({
330 channel: AiLogChannel.Volcengine,
331 request: { prompt: 'test prompt', groupId: 'group-1' },
332 response: {
333 status: 'succeeded',
334 content: {
335 video_url: 'videos/generated.mp4',
336 last_frame_url: 'covers/from-provider.png',
337 },
338 },
339 })
340
341 const result = await service.transformToCommonResponse(aiLog)
342
343 expect(mockVideoMetadataService.extractThumbnailFromUrl).toHaveBeenCalledWith(
344 'https://cdn.example.com/videos/generated.mp4',
345 2,
346 )
347 expect(mockAssetsService.uploadFromBuffer).toHaveBeenCalled()
348 expect(mockMediaRepository.create).toHaveBeenCalledWith({
349 userId: 'user-1',
350 userType: UserType.User,
351 materialGroupId: 'group-1',
352 type: MediaType.VIDEO,
353 url: 'videos/generated.mp4',
354 thumbUrl: 'covers/generated-from-thumb.png',
355 })
356 expect(result).toMatchObject({
357 mediaId: 'media-2',
358 coverUrl: 'https://cdn.example.com/covers/generated-from-thumb.png',
359 groupId: 'group-1',
360 })
361 })
362 })
363
364 function createAiLog(overrides: Partial<AiLog>): AiLog {
365 return {
366 id: 'ai-1',
367 userId: 'user-1',
368 userType: UserType.User,
369 type: AiLogType.Video,
370 model: 'grok-imagine-video',
371 channel: AiLogChannel.Grok,
372 status: AiLogStatus.Success,
373 startedAt: new Date('2025-01-01T00:00:00.000Z'),
374 duration: 1000,
375 request: {},
376 response: {},
377 ...overrides,
378 } as AiLog
379 }
380
380 lines TYPESCRIPT