返回 AiToEarn
relay-video.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / video / relay / relay-video.service.spec.ts
1 import type { AiLog, AiLogRepository } from '@yikart/mongodb'
2 import type { AiAvailabilityService } from '../../../ai-availability/ai-availability.service'
3 import type { RelayLibService } from '../../libs/relay'
4 import type { ModelsConfigService } from '../../models-config'
5 import type { RelayMediaResolverService } from '../../relay-media'
6 import { UserType } from '@yikart/common'
7 import { AiLogChannel, AiLogStatus, AiLogType } from '@yikart/mongodb'
8 import { beforeEach, describe, expect, it, vi } from 'vitest'
9 import { RelayVideoService } from './relay-video.service'
10
11 vi.mock('../../models-config', () => ({ ModelsConfigService: class ModelsConfigService {} }))
12
13 vi.mock('../../../ai-availability/ai-availability.service', () => ({
14 AiAvailabilityService: class AiAvailabilityService {},
15 }))
16
17 vi.mock('../../relay-media', () => ({
18 RelayMediaResolverService: class RelayMediaResolverService {},
19 }))
20
21 vi.mock('@yikart/mongodb', () => ({
22 AiLog: class AiLog {},
23 AiLogChannel: {
24 Relay: 'relay',
25 },
26 AiLogRepository: class AiLogRepository {},
27 AiLogStatus: {
28 Generating: 'generating',
29 Success: 'success',
30 Failed: 'failed',
31 },
32 AiLogType: {
33 Video: 'video',
34 },
35 }))
36
37 describe('relayVideoService', () => {
38 let service: RelayVideoService
39 let mockRelayLibService: vi.Mocked<Pick<RelayLibService, 'createVideo'>>
40 let mockAiLogRepo: vi.Mocked<Pick<AiLogRepository, 'create' | 'getByTaskId' | 'updateByIdAndStatus'>>
41 let mockAiAvailability: vi.Mocked<Pick<AiAvailabilityService, 'executeAsync' | 'recordAsyncComplete'>>
42 let mockRelayMediaResolver: vi.Mocked<Pick<RelayMediaResolverService, 'resolveJson'>>
43
44 const relayModel = {
45 name: 'relay-video-model',
46 channel: AiLogChannel.Relay,
47 defaults: {
48 resolution: '720p',
49 aspectRatio: '9:16',
50 duration: 5,
51 },
52 durations: [5],
53 aspectRatios: ['9:16', '16:9'],
54 maxInputImages: 9,
55 modes: ['text2video', 'multi-ref'],
56 }
57
58 beforeEach(() => {
59 mockRelayLibService = {
60 createVideo: vi.fn().mockResolvedValue({ id: 'relay-task-1', status: 'queued' }),
61 }
62 mockAiLogRepo = {
63 create: vi.fn().mockResolvedValue({ id: 'ai-log-1' }),
64 getByTaskId: vi.fn(),
65 updateByIdAndStatus: vi.fn(),
66 }
67 mockAiAvailability = {
68 executeAsync: vi.fn((_context, execute) => execute()),
69 recordAsyncComplete: vi.fn().mockResolvedValue(undefined),
70 } as never
71 mockRelayMediaResolver = {
72 resolveJson: vi.fn(async value => value),
73 }
74
75 service = new RelayVideoService(
76 mockRelayLibService as RelayLibService,
77 mockAiLogRepo as AiLogRepository,
78 { config: { video: { generation: [relayModel] } } } as unknown as ModelsConfigService,
79 mockAiAvailability as AiAvailabilityService,
80 mockRelayMediaResolver as RelayMediaResolverService,
81 )
82 })
83
84 it('forwards only original video fields and creates local ai log', async () => {
85 await service.createFromRequest({
86 userId: 'user-1',
87 userType: UserType.User,
88 model: 'relay-video-model',
89 prompt: 'A vertical product video',
90 })
91
92 expect(mockRelayLibService.createVideo).toHaveBeenCalledWith(expect.objectContaining({
93 model: 'relay-video-model',
94 prompt: 'A vertical product video',
95 }))
96
97 const payload = mockRelayLibService.createVideo.mock.calls[0][0]
98 expect('userId' in payload).toBe(false)
99 expect('userType' in payload).toBe(false)
100 expect('groupId' in payload).toBe(false)
101 expect(payload.ratio).toBeUndefined()
102 expect(payload.resolution).toBeUndefined()
103 expect(payload.duration).toBeUndefined()
104
105 const createdLog = mockAiLogRepo.create.mock.calls[0][0]
106 expect(createdLog).toMatchObject({
107 userId: 'user-1',
108 userType: UserType.User,
109 taskId: 'relay-task-1',
110 model: 'relay-video-model',
111 channel: AiLogChannel.Relay,
112 type: AiLogType.Video,
113 request: {
114 model: 'relay-video-model',
115 prompt: 'A vertical product video',
116 remoteTaskId: 'relay-task-1',
117 },
118 response: {
119 id: 'relay-task-1',
120 status: 'queued',
121 },
122 status: AiLogStatus.Generating,
123 })
124 })
125
126 it('resolves media fields before forwarding relay payload while keeping local log input unchanged', async () => {
127 mockRelayMediaResolver.resolveJson.mockImplementationOnce(async value => ({
128 ...(value as Record<string, unknown>),
129 image: 'https://relay.example.com/assets/start.png',
130 image_tail: 'https://relay.example.com/assets/end.png',
131 video_url: 'https://relay.example.com/assets/source.mp4',
132 images: ['https://relay.example.com/assets/ref.png', 'https://external.example.com/ref.png'],
133 videos: ['https://relay.example.com/assets/ref.mp4'],
134 audios: ['https://relay.example.com/assets/ref.mp3'],
135 }) as never)
136
137 await service.createFromRequest({
138 userId: 'user-1',
139 userType: UserType.User,
140 model: 'relay-video-model',
141 prompt: 'A video with references',
142 image: 'images/start.png',
143 image_tail: 'https://cdn.example.com/images/end.png?version=1',
144 video_url: 'videos/source.mp4',
145 images: ['images/ref.png', 'https://external.example.com/ref.png'],
146 videos: ['https://storage.example.com/videos/ref.mp4'],
147 audios: ['audios/ref.mp3'],
148 })
149
150 expect(mockRelayLibService.createVideo).toHaveBeenCalledWith(expect.objectContaining({
151 image: 'https://relay.example.com/assets/start.png',
152 image_tail: 'https://relay.example.com/assets/end.png',
153 video_url: 'https://relay.example.com/assets/source.mp4',
154 images: ['https://relay.example.com/assets/ref.png', 'https://external.example.com/ref.png'],
155 videos: ['https://relay.example.com/assets/ref.mp4'],
156 audios: ['https://relay.example.com/assets/ref.mp3'],
157 }))
158
159 const createdLog = mockAiLogRepo.create.mock.calls[0][0]
160 expect(createdLog.request).toMatchObject({
161 image: 'images/start.png',
162 image_tail: 'https://cdn.example.com/images/end.png?version=1',
163 video_url: 'videos/source.mp4',
164 images: ['images/ref.png', 'https://external.example.com/ref.png'],
165 videos: ['https://storage.example.com/videos/ref.mp4'],
166 audios: ['audios/ref.mp3'],
167 })
168 })
169
170 it('keeps local groupId in ai log but does not forward it to upstream relay', async () => {
171 await service.createFromRequest({
172 userId: 'user-1',
173 userType: UserType.User,
174 model: 'relay-video-model',
175 prompt: 'A video saved locally',
176 groupId: 'local-group-1',
177 })
178
179 const payload = mockRelayLibService.createVideo.mock.calls[0][0]
180 expect('groupId' in payload).toBe(false)
181
182 const createdLog = mockAiLogRepo.create.mock.calls[0][0]
183 expect(createdLog.request).toMatchObject({
184 groupId: 'local-group-1',
185 })
186 })
187
188 it('uploads local media references before forwarding relay payload while keeping local log input unchanged', async () => {
189 mockRelayMediaResolver.resolveJson.mockImplementationOnce(async value => ({
190 ...(value as Record<string, unknown>),
191 image: 'https://relay.example.com/assets/start.png',
192 images: ['https://relay.example.com/assets/ref.png'],
193 }) as never)
194
195 await service.createFromRequest({
196 userId: 'user-1',
197 userType: UserType.User,
198 model: 'relay-video-model',
199 prompt: 'A video with references',
200 image: 'images/start.png',
201 images: ['images/ref.png'],
202 })
203
204 expect(mockRelayMediaResolver.resolveJson).toHaveBeenCalledWith(expect.objectContaining({
205 image: 'images/start.png',
206 images: ['images/ref.png'],
207 }))
208 expect(mockRelayLibService.createVideo).toHaveBeenCalledWith(expect.objectContaining({
209 image: 'https://relay.example.com/assets/start.png',
210 images: ['https://relay.example.com/assets/ref.png'],
211 }))
212
213 const createdLog = mockAiLogRepo.create.mock.calls[0][0]
214 expect(createdLog.request).toMatchObject({
215 image: 'images/start.png',
216 images: ['images/ref.png'],
217 })
218 })
219
220 it('rejects unsupported model mode before calling upstream relay', async () => {
221 await expect(service.createFromRequest({
222 userId: 'user-1',
223 userType: UserType.User,
224 model: 'relay-video-model',
225 prompt: 'A video',
226 mode: 'image2video',
227 })).rejects.toThrow()
228
229 expect(mockRelayLibService.createVideo).not.toHaveBeenCalled()
230 })
231
232 it('returns relay media inputs in task input', () => {
233 expect(service.extractInput({
234 model: 'relay-video-model',
235 prompt: 'A video with references',
236 groupId: 'local-group-1',
237 image: ['images/start.png', 'images/second.png'],
238 images: ['images/ref.png'],
239 video_url: 'videos/source.mp4',
240 videos: ['videos/ref.mp4'],
241 audios: ['audios/ref.mp3'],
242 duration: 5,
243 resolution: '720p',
244 ratio: '9:16',
245 watermark: true,
246 })).toEqual({
247 prompt: 'A video with references',
248 groupId: 'local-group-1',
249 image: ['images/start.png', 'images/second.png'],
250 images: ['images/ref.png'],
251 videoUrl: 'videos/source.mp4',
252 videos: ['videos/ref.mp4'],
253 audios: ['audios/ref.mp3'],
254 duration: 5,
255 resolution: '720p',
256 aspectRatio: '9:16',
257 watermark: true,
258 })
259 })
260
261 it('resolves array image references before forwarding relay payload', async () => {
262 mockRelayMediaResolver.resolveJson.mockImplementationOnce(async value => ({
263 ...(value as Record<string, unknown>),
264 image: ['https://relay.example.com/assets/start.png', 'https://external.example.com/start.png'],
265 }) as never)
266
267 await service.createFromRequest({
268 userId: 'user-1',
269 userType: UserType.User,
270 model: 'relay-video-model',
271 prompt: 'A video with image array',
272 image: ['images/start.png', 'https://external.example.com/start.png'],
273 })
274
275 expect(mockRelayLibService.createVideo).toHaveBeenCalledWith(expect.objectContaining({
276 image: ['https://relay.example.com/assets/start.png', 'https://external.example.com/start.png'],
277 }))
278 })
279
280 it('keeps relay callback video urls unchanged on success', async () => {
281 const aiLog = {
282 id: 'ai-log-1',
283 userId: 'user-1',
284 userType: UserType.User,
285 taskId: 'relay-task-1',
286 model: 'relay-video-model',
287 channel: AiLogChannel.Relay,
288 type: AiLogType.Video,
289 status: AiLogStatus.Generating,
290 startedAt: new Date(Date.now() - 1000),
291 request: {
292 model: 'relay-video-model',
293 prompt: 'A video',
294 },
295 } as unknown as AiLog
296 mockAiLogRepo.getByTaskId.mockResolvedValue(aiLog)
297 mockAiLogRepo.updateByIdAndStatus.mockResolvedValue(aiLog)
298
299 await expect(service.callback({
300 id: 'relay-task-1',
301 status: 'success',
302 videoUrl: 'https://relay.example.com/videos/out.mp4',
303 coverUrl: 'https://relay.example.com/covers/out.png',
304 })).resolves.toEqual({
305 id: 'relay-task-1',
306 status: 'success',
307 videoUrl: 'https://relay.example.com/videos/out.mp4',
308 coverUrl: 'https://relay.example.com/covers/out.png',
309 })
310
311 expect(mockAiLogRepo.updateByIdAndStatus).toHaveBeenCalledWith(
312 'ai-log-1',
313 AiLogStatus.Generating,
314 expect.objectContaining({
315 $set: expect.objectContaining({
316 status: AiLogStatus.Success,
317 response: {
318 id: 'relay-task-1',
319 status: 'success',
320 videoUrl: 'https://relay.example.com/videos/out.mp4',
321 coverUrl: 'https://relay.example.com/covers/out.png',
322 },
323 }),
324 }),
325 )
326 expect(mockAiAvailability.recordAsyncComplete).toHaveBeenCalledWith(
327 'relay-task-1',
328 { provider: 'relay', operation: 'videoGeneration', model: 'relay-video-model' },
329 expect.objectContaining({ success: true }),
330 )
331 })
332
333 it('marks terminal relay failures', async () => {
334 const aiLog = {
335 id: 'ai-log-1',
336 userId: 'user-1',
337 userType: UserType.User,
338 taskId: 'relay-task-1',
339 model: 'relay-video-model',
340 channel: AiLogChannel.Relay,
341 type: AiLogType.Video,
342 status: AiLogStatus.Generating,
343 startedAt: new Date(Date.now() - 1000),
344 request: {
345 model: 'relay-video-model',
346 prompt: 'A video',
347 },
348 } as unknown as AiLog
349 mockAiLogRepo.getByTaskId.mockResolvedValue(aiLog)
350 mockAiLogRepo.updateByIdAndStatus.mockResolvedValue(aiLog)
351
352 await service.callback({
353 id: 'relay-task-1',
354 status: 'failed',
355 error: { message: 'upstream failed' },
356 })
357
358 expect(mockAiLogRepo.updateByIdAndStatus).toHaveBeenCalledWith(
359 'ai-log-1',
360 AiLogStatus.Generating,
361 expect.objectContaining({
362 $set: expect.objectContaining({
363 status: AiLogStatus.Failed,
364 errorMessage: 'upstream failed',
365 }),
366 }),
367 )
368 expect(mockAiAvailability.recordAsyncComplete).toHaveBeenCalledWith(
369 'relay-task-1',
370 { provider: 'relay', operation: 'videoGeneration', model: 'relay-video-model' },
371 expect.objectContaining({ success: false, errorMessage: 'upstream failed' }),
372 )
373 })
374 })
375
375 lines TYPESCRIPT