返回 AiToEarn
media.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / media / media.service.spec.ts
1 import type { InternalAxiosRequestConfig } from 'axios'
2 import { readdir } from 'node:fs/promises'
3 import { tmpdir } from 'node:os'
4 import { Readable } from 'node:stream'
5 import { AccountType } from '@yikart/common'
6 import { AxiosError } from 'axios'
7 import sharp from 'sharp'
8 import { describe, expect, it, vi } from 'vitest'
9 import { PlatformErrorCategory } from '../platforms/platforms.exception'
10 import { PublishMediaAdaptationImageFormat } from '../platforms/publish-media-adaptation.schema'
11 import { PublishValidationIssueCode } from '../platforms/publish.schema'
12 import { MediaService } from './media.service'
13
14 const PUBLIC_MEDIA_URL = 'https://93.184.216.34/video.mp4'
15
16 vi.mock('@yikart/assets', () => ({
17 AssetsService: class AssetsService {},
18 VideoMetadataService: class VideoMetadataService {},
19 }))
20
21 vi.mock('@yikart/mongodb', () => ({
22 AssetType: {
23 PublishMedia: 'publishMedia',
24 },
25 }))
26
27 function createService(assetsService = { uploadFromBuffer: vi.fn() }) {
28 return new MediaService({} as never, assetsService as never)
29 }
30
31 function setHttpAdapter(service: MediaService, adapter: (config: InternalAxiosRequestConfig) => unknown) {
32 const serviceWithHttp = service as unknown as { http: { defaults: { adapter: unknown } } }
33 serviceWithHttp.http.defaults.adapter = adapter
34 }
35
36 async function readStream(stream: Readable): Promise<Buffer> {
37 const chunks: Buffer[] = []
38 for await (const chunk of stream) {
39 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
40 }
41 return Buffer.concat(chunks)
42 }
43
44 async function listMediaTempDirs(): Promise<string[]> {
45 return (await readdir(tmpdir())).filter(name => name.startsWith('aitoearn-media-'))
46 }
47
48 describe('media service http downloads', () => {
49 it('returns media downloads as Buffer', async () => {
50 const service = createService()
51 setHttpAdapter(service, async config => ({
52 data: Buffer.from('media-bytes'),
53 status: 200,
54 statusText: 'OK',
55 headers: {},
56 config,
57 }))
58
59 const buffer = await service.getBuffer({
60 platform: AccountType.TikTok,
61 endpoint: 'downloadVideo',
62 url: PUBLIC_MEDIA_URL,
63 })
64
65 expect(Buffer.isBuffer(buffer)).toBe(true)
66 expect(buffer.toString()).toBe('media-bytes')
67 })
68
69 it('creates upload sources with range streams and blobs without retaining temp files', async () => {
70 const service = createService()
71 setHttpAdapter(service, async config => ({
72 data: Readable.from(Buffer.from('0123456789')),
73 status: 200,
74 statusText: 'OK',
75 headers: { 'content-type': 'video/mp4' },
76 config,
77 }))
78 const before = new Set(await listMediaTempDirs())
79
80 const result = await service.withUploadSource({
81 platform: AccountType.TikTok,
82 endpoint: 'downloadVideo',
83 url: 'https://cdn.example.test/video.mp4',
84 }, async source => ({
85 sizeBytes: source.sizeBytes,
86 contentType: source.contentType,
87 filename: source.filename,
88 streamBytes: (await readStream(source.stream({ start: 2, end: 5 }))).toString(),
89 blobBytes: Buffer.from(await (await source.blob({ start: 6, end: 8 })).arrayBuffer()).toString(),
90 }))
91 const after = new Set(await listMediaTempDirs())
92
93 expect(result).toEqual({
94 sizeBytes: 10,
95 contentType: 'video/mp4',
96 filename: 'video.mp4',
97 streamBytes: '2345',
98 blobBytes: '678',
99 })
100 expect([...after].filter(name => !before.has(name))).toEqual([])
101 })
102
103 it('cleans upload source temp files when the handler fails', async () => {
104 const service = createService()
105 setHttpAdapter(service, async config => ({
106 data: Readable.from(Buffer.from('video-data')),
107 status: 200,
108 statusText: 'OK',
109 headers: { 'content-type': 'video/mp4' },
110 config,
111 }))
112 const before = new Set(await listMediaTempDirs())
113
114 await expect(service.withUploadSource({
115 platform: AccountType.TikTok,
116 endpoint: 'downloadVideo',
117 url: 'https://cdn.example.test/video.mp4',
118 }, async () => {
119 throw new Error('handler failed')
120 })).rejects.toThrow('handler failed')
121 const after = new Set(await listMediaTempDirs())
122
123 expect([...after].filter(name => !before.has(name))).toEqual([])
124 })
125
126 it('converts platform media download errors through the response interceptor', async () => {
127 const service = createService()
128 setHttpAdapter(service, async (config) => {
129 throw new AxiosError('connect failed', 'ECONNRESET', config)
130 })
131
132 await expect(service.getBuffer({
133 platform: AccountType.TikTok,
134 endpoint: 'downloadVideo',
135 url: PUBLIC_MEDIA_URL,
136 taskId: 'task-id',
137 })).rejects.toMatchObject({
138 category: PlatformErrorCategory.Network,
139 context: {
140 endpoint: 'downloadVideo',
141 taskId: 'task-id',
142 metadata: { url: PUBLIC_MEDIA_URL },
143 },
144 platformCause: {
145 platformMessage: 'connect failed',
146 },
147 })
148 })
149
150 it('keeps probe and conversion http errors unwrapped without platform context', async () => {
151 const service = createService()
152 const error = new AxiosError('probe failed', 'ECONNRESET')
153 setHttpAdapter(service, async () => {
154 throw error
155 })
156
157 await expect(service.probeImage('https://93.184.216.34/image.png')).rejects.toBe(error)
158 })
159 })
160
161 describe('media service publish validation', () => {
162 it('uses server metadata type for media probing and validates covers', async () => {
163 const service = createService()
164 const probeVideo = vi.spyOn(service, 'probeVideo').mockResolvedValue({
165 width: 1920,
166 height: 1080,
167 format: 'mp4',
168 durationSec: 30,
169 codec: 'unknown',
170 sizeBytes: 1024,
171 })
172 const probeImage = vi.spyOn(service, 'probeImage').mockResolvedValue({
173 width: 1200,
174 height: 900,
175 format: 'jpeg',
176 sizeBytes: 512,
177 })
178
179 await expect(service.validateMedia({
180 media: [{
181 url: 'https://93.184.216.34/media?id=video',
182 metadata: { type: 'video' },
183 }],
184 cover: {
185 url: 'https://93.184.216.34/cover.jpeg',
186 },
187 }, {
188 videoFormats: ['mp4'],
189 imageFormats: ['jpeg'],
190 })).resolves.toEqual([])
191
192 expect(probeVideo).toHaveBeenCalledWith('https://93.184.216.34/media?id=video')
193 expect(probeImage).toHaveBeenCalledWith('https://93.184.216.34/cover.jpeg')
194 })
195
196 it('accepts extensionless video media normalized with metadata when probe format is unknown', async () => {
197 const service = createService()
198 vi.spyOn(service, 'probeVideo').mockResolvedValue({
199 width: 1920,
200 height: 1080,
201 format: 'unknown',
202 durationSec: 30,
203 codec: 'unknown',
204 sizeBytes: 1024,
205 })
206
207 await expect(service.validateMedia({
208 media: [{
209 url: 'https://93.184.216.34/signed-video',
210 metadata: { type: 'video' },
211 }],
212 }, {
213 videoFormats: ['mp4'],
214 maxVideoDuration: 90,
215 })).resolves.toEqual([])
216 })
217
218 it('keeps feed image aspect validation separate from reel and story cover rules', () => {
219 const service = createService()
220 const portraitImage = {
221 width: 1080,
222 height: 1920,
223 format: 'jpeg',
224 sizeBytes: 1024,
225 }
226
227 expect(service.validateImage(portraitImage, {
228 imageFormats: ['jpeg'],
229 aspectRatio: { min: 0.8, max: 1.91 },
230 }, ['content', 'media', 0])).toEqual([expect.objectContaining({
231 code: PublishValidationIssueCode.TooSmall,
232 params: expect.objectContaining({
233 current: 0.56,
234 minimum: 0.8,
235 }),
236 })])
237
238 expect(service.validateImage(portraitImage, {
239 imageFormats: ['jpeg'],
240 }, ['content', 'cover'])).toEqual([])
241 })
242 })
243
244 describe('media service publish media preparation', () => {
245 it('keeps media URLs unchanged and strips media adaptation options when adaptation is off', async () => {
246 const assetsService = { uploadFromBuffer: vi.fn() }
247 const service = createService(assetsService)
248 vi.spyOn(service, 'probeImage').mockImplementation(async url => ({
249 width: url.includes('cover') ? 1200 : 800,
250 height: url.includes('cover') ? 900 : 600,
251 format: 'png',
252 sizeBytes: 512,
253 }))
254 const content = {
255 media: [{
256 url: 'https://cdn.example.test/source.png',
257 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Off } },
258 }],
259 cover: {
260 url: 'https://cdn.example.test/cover.png',
261 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Off } },
262 },
263 }
264
265 await expect(service.preparePublishContentMedia({
266 userId: 'user-1',
267 content,
268 mediaRules: { imageFormats: ['jpg', 'jpeg', 'png'] },
269 })).resolves.toEqual({
270 content: {
271 media: [{
272 url: 'https://cdn.example.test/source.png',
273 metadata: {
274 type: 'image',
275 width: 800,
276 height: 600,
277 format: 'png',
278 sizeBytes: 512,
279 },
280 }],
281 cover: {
282 url: 'https://cdn.example.test/cover.png',
283 metadata: {
284 type: 'image',
285 width: 1200,
286 height: 900,
287 format: 'png',
288 sizeBytes: 512,
289 },
290 },
291 },
292 issues: [],
293 })
294 expect(assetsService.uploadFromBuffer).not.toHaveBeenCalled()
295 })
296
297 it('keeps auto media unchanged when the source format already matches platform rules', async () => {
298 const assetsService = { uploadFromBuffer: vi.fn() }
299 const service = createService(assetsService)
300 vi.spyOn(service, 'probeImage').mockResolvedValue({
301 width: 800,
302 height: 600,
303 format: 'jpeg',
304 sizeBytes: 512,
305 })
306
307 await expect(service.preparePublishContentMedia({
308 userId: 'user-1',
309 content: {
310 media: [{
311 url: 'https://cdn.example.test/source.jpg',
312 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Auto } },
313 }],
314 },
315 mediaRules: { imageFormats: ['jpg', 'jpeg', 'png'] },
316 })).resolves.toEqual({
317 content: {
318 media: [{
319 url: 'https://cdn.example.test/source.jpg',
320 metadata: {
321 type: 'image',
322 width: 800,
323 height: 600,
324 format: 'jpeg',
325 sizeBytes: 512,
326 },
327 }],
328 },
329 issues: [],
330 })
331 expect(assetsService.uploadFromBuffer).not.toHaveBeenCalled()
332 })
333
334 it('normalizes extensionless video media from backend probe metadata', async () => {
335 const assetsService = { uploadFromBuffer: vi.fn() }
336 const service = createService(assetsService)
337 const probeVideo = vi.spyOn(service, 'probeVideo').mockResolvedValue({
338 width: 1920,
339 height: 1080,
340 format: 'video/mp4',
341 durationSec: 30,
342 codec: 'unknown',
343 sizeBytes: 1024,
344 })
345 setHttpAdapter(service, async config => ({
346 data: undefined,
347 status: 200,
348 statusText: 'OK',
349 headers: { 'content-type': 'video/mp4' },
350 config,
351 }))
352
353 await expect(service.preparePublishContentMedia({
354 userId: 'user-1',
355 content: {
356 media: [{ url: 'https://cdn.example.test/signed-media' }],
357 },
358 mediaRules: { videoFormats: ['mp4'] },
359 })).resolves.toEqual({
360 content: {
361 media: [{
362 url: 'https://cdn.example.test/signed-media',
363 metadata: {
364 type: 'video',
365 width: 1920,
366 height: 1080,
367 durationSec: 30,
368 codec: 'unknown',
369 format: 'video/mp4',
370 sizeBytes: 1024,
371 },
372 }],
373 },
374 issues: [],
375 })
376 expect(probeVideo).toHaveBeenCalledWith('https://cdn.example.test/signed-media')
377 expect(assetsService.uploadFromBuffer).not.toHaveBeenCalled()
378 })
379
380 it('normalizes extensionless image media from backend probe metadata', async () => {
381 const assetsService = { uploadFromBuffer: vi.fn() }
382 const service = createService(assetsService)
383 const source = await sharp({
384 create: {
385 width: 4,
386 height: 2,
387 channels: 3,
388 background: '#ffffff',
389 },
390 }).png().toBuffer()
391 setHttpAdapter(service, async config => ({
392 data: config.method === 'head' ? undefined : source,
393 status: 200,
394 statusText: 'OK',
395 headers: { 'content-type': 'image/png', 'content-length': String(source.length) },
396 config,
397 }))
398
399 await expect(service.preparePublishContentMedia({
400 userId: 'user-1',
401 content: {
402 media: [{ url: 'https://cdn.example.test/signed-image' }],
403 },
404 mediaRules: { imageFormats: ['png'] },
405 })).resolves.toEqual({
406 content: {
407 media: [{
408 url: 'https://cdn.example.test/signed-image',
409 metadata: {
410 type: 'image',
411 width: 4,
412 height: 2,
413 format: 'png',
414 sizeBytes: source.length,
415 },
416 }],
417 },
418 issues: [],
419 })
420 expect(assetsService.uploadFromBuffer).not.toHaveBeenCalled()
421 })
422
423 it('converts auto media to the first platform-supported target format when source format is unsupported', async () => {
424 const source = await sharp({
425 create: {
426 width: 4,
427 height: 2,
428 channels: 3,
429 background: '#ffffff',
430 },
431 }).png().toBuffer()
432 const assetsService = {
433 uploadFromBuffer: vi.fn(async () => ({
434 url: 'https://assets.example.test/publish/media/source.jpeg',
435 })),
436 }
437 const service = createService(assetsService)
438 setHttpAdapter(service, async config => ({
439 data: config.method === 'head' ? undefined : source,
440 status: 200,
441 statusText: 'OK',
442 headers: { 'content-length': String(source.length) },
443 config,
444 }))
445
446 const result = await service.preparePublishContentMedia({
447 userId: 'user-1',
448 content: {
449 media: [{
450 url: 'https://cdn.example.test/source.gif',
451 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Auto } },
452 }],
453 },
454 mediaRules: { imageFormats: ['jpeg'], maxImageSize: 1024 * 1024 },
455 })
456
457 expect(result.issues).toEqual([])
458 expect(result.content).toEqual({
459 media: [{
460 url: 'https://assets.example.test/publish/media/source.jpeg',
461 metadata: {
462 type: 'image',
463 width: 4,
464 height: 2,
465 format: 'jpeg',
466 sizeBytes: expect.any(Number),
467 },
468 }],
469 })
470 expect(assetsService.uploadFromBuffer).toHaveBeenCalledWith(
471 'user-1',
472 expect.any(Buffer),
473 expect.objectContaining({
474 type: 'publishMedia',
475 mimeType: 'image/jpeg',
476 }),
477 )
478 })
479
480 it('rejects target image formats that are not allowed by platform media rules', async () => {
481 const assetsService = { uploadFromBuffer: vi.fn() }
482 const service = createService(assetsService)
483 vi.spyOn(service, 'probeImage').mockResolvedValue({
484 width: 800,
485 height: 600,
486 format: 'png',
487 sizeBytes: 512,
488 })
489 const content = {
490 media: [{
491 url: 'https://cdn.example.test/source.png',
492 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Webp } },
493 }],
494 }
495
496 const result = await service.preparePublishContentMedia({
497 userId: 'user-1',
498 content,
499 mediaRules: { imageFormats: ['jpg', 'jpeg', 'png'] },
500 })
501
502 expect(result.content).toBe(content)
503 expect(result.issues).toEqual([expect.objectContaining({
504 code: PublishValidationIssueCode.InvalidOption,
505 path: ['content', 'media', 0, 'options', 'adaptation', 'imageFormat'],
506 })])
507 expect(assetsService.uploadFromBuffer).not.toHaveBeenCalled()
508 })
509
510 it('converts publish images and reuses the uploaded asset for the same source URL', async () => {
511 const source = await sharp({
512 create: {
513 width: 4,
514 height: 2,
515 channels: 3,
516 background: '#ffffff',
517 },
518 }).png().toBuffer()
519 const assetsService = {
520 uploadFromBuffer: vi.fn(async () => ({
521 url: 'https://assets.example.test/publish/media/source.jpeg',
522 })),
523 }
524 const service = createService(assetsService)
525 setHttpAdapter(service, async config => ({
526 data: config.method === 'head' ? undefined : source,
527 status: 200,
528 statusText: 'OK',
529 headers: { 'content-length': String(source.length) },
530 config,
531 }))
532
533 const result = await service.preparePublishContentMedia({
534 userId: 'user-1',
535 content: {
536 media: [{
537 url: 'https://cdn.example.test/source.png',
538 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Jpeg } },
539 }],
540 cover: {
541 url: 'https://cdn.example.test/source.png',
542 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Jpeg } },
543 },
544 },
545 mediaRules: { imageFormats: ['jpg', 'jpeg'], maxImageSize: 1024 * 1024 },
546 })
547
548 expect(result.issues).toEqual([])
549 expect(result.content).toEqual({
550 media: [{
551 url: 'https://assets.example.test/publish/media/source.jpeg',
552 metadata: {
553 type: 'image',
554 width: 4,
555 height: 2,
556 format: 'jpeg',
557 sizeBytes: expect.any(Number),
558 },
559 }],
560 cover: {
561 url: 'https://assets.example.test/publish/media/source.jpeg',
562 metadata: {
563 type: 'image',
564 width: 4,
565 height: 2,
566 format: 'jpeg',
567 sizeBytes: expect.any(Number),
568 },
569 },
570 })
571 expect(assetsService.uploadFromBuffer).toHaveBeenCalledTimes(1)
572 expect(assetsService.uploadFromBuffer).toHaveBeenCalledWith(
573 'user-1',
574 expect.any(Buffer),
575 expect.objectContaining({
576 type: 'publishMedia',
577 mimeType: 'image/jpeg',
578 metadata: { width: 4, height: 2 },
579 }),
580 )
581 })
582
583 it('rejects oversized conversion sources before uploading them', async () => {
584 const assetsService = { uploadFromBuffer: vi.fn() }
585 const service = createService(assetsService)
586 const source = await sharp({
587 create: {
588 width: 4,
589 height: 2,
590 channels: 3,
591 background: '#ffffff',
592 },
593 }).png().toBuffer()
594 setHttpAdapter(service, async config => ({
595 data: config.method === 'head' ? undefined : source,
596 status: 200,
597 statusText: 'OK',
598 headers: { 'content-length': String(26 * 1024 * 1024) },
599 config,
600 }))
601
602 const result = await service.preparePublishContentMedia({
603 userId: 'user-1',
604 content: {
605 media: [{
606 url: 'https://cdn.example.test/source.png',
607 options: { adaptation: { imageFormat: PublishMediaAdaptationImageFormat.Jpeg } },
608 }],
609 },
610 mediaRules: { imageFormats: ['jpg', 'jpeg'] },
611 })
612
613 expect(result.issues).toEqual([expect.objectContaining({
614 code: PublishValidationIssueCode.TooBig,
615 path: ['content', 'media', 0],
616 })])
617 expect(assetsService.uploadFromBuffer).not.toHaveBeenCalled()
618 })
619 })
620
621 describe('media service video validation', () => {
622 it('accepts video formats configured as extensions when probe format is MIME', () => {
623 const issues = createService().validateVideo({
624 width: 1920,
625 height: 1080,
626 format: 'video/mp4',
627 durationSec: 30,
628 codec: 'unknown',
629 sizeBytes: 1024,
630 }, {
631 videoFormats: ['mp4', 'mov'],
632 }, ['content', 'media', 0])
633
634 expect(issues).toEqual([])
635 })
636
637 it('reports unsupported video formats using normalized extension names', () => {
638 const issues = createService().validateVideo({
639 width: 1920,
640 height: 1080,
641 format: 'video/x-msvideo',
642 durationSec: 30,
643 codec: 'unknown',
644 sizeBytes: 1024,
645 }, {
646 videoFormats: ['mp4', 'mov'],
647 }, ['content', 'media', 0])
648
649 expect(issues).toEqual([expect.objectContaining({
650 code: PublishValidationIssueCode.UnsupportedFormat,
651 params: expect.objectContaining({
652 format: 'avi',
653 }),
654 })])
655 })
656
657 it('reports unknown video formats by default', () => {
658 const issues = createService().validateVideo({
659 width: 1920,
660 height: 1080,
661 format: 'unknown',
662 durationSec: 30,
663 codec: 'unknown',
664 sizeBytes: 1024,
665 }, {
666 videoFormats: ['mp4', 'mov'],
667 }, ['content', 'media', 0])
668
669 expect(issues).toEqual([expect.objectContaining({
670 code: PublishValidationIssueCode.UnsupportedFormat,
671 params: expect.objectContaining({
672 format: 'unknown',
673 }),
674 })])
675 })
676
677 it('reports video duration limits from media rules', () => {
678 const service = createService()
679
680 const tooShort = service.validateVideo({
681 width: 1920,
682 height: 1080,
683 format: 'video/mp4',
684 durationSec: 2,
685 codec: 'unknown',
686 sizeBytes: 1024,
687 }, {
688 videoFormats: ['mp4'],
689 minVideoDuration: 3,
690 maxVideoDuration: 90,
691 }, ['content', 'media', 0])
692
693 const tooLong = service.validateVideo({
694 width: 1920,
695 height: 1080,
696 format: 'video/mp4',
697 durationSec: 120,
698 codec: 'unknown',
699 sizeBytes: 1024,
700 }, {
701 videoFormats: ['mp4'],
702 minVideoDuration: 3,
703 maxVideoDuration: 90,
704 }, ['content', 'media', 0])
705
706 expect(tooShort).toEqual([expect.objectContaining({
707 code: PublishValidationIssueCode.InvalidDuration,
708 params: expect.objectContaining({ current: 2, minimum: 3, maximum: 90 }),
709 })])
710 expect(tooLong).toEqual([expect.objectContaining({
711 code: PublishValidationIssueCode.InvalidDuration,
712 params: expect.objectContaining({ current: 120, minimum: 3, maximum: 90 }),
713 })])
714 })
715 })
716
716 lines TYPESCRIPT