mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
feat(youtube): add VideoUploadService
Implements a service that uploads videos to YouTube via the Data API v3. Resolves OAuth credentials from social-accounts, reads media files from disk, and handles scheduled publishes by setting privacyStatus to private with a publishAt timestamp. Includes 12 unit tests covering successful uploads, scheduled publishing, credential/media validation, and API errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fb4d5a8fe5
commit
6cc3011804
2 changed files with 612 additions and 0 deletions
194
src/lib/integrations/youtube/VideoUploadService.ts
Normal file
194
src/lib/integrations/youtube/VideoUploadService.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
// src/lib/integrations/youtube/VideoUploadService.ts
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import { google } from 'googleapis'
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
|
interface VideoMetadata {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
tags: string[]
|
||||||
|
visibility: 'public' | 'unlisted' | 'private'
|
||||||
|
categoryId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadOptions {
|
||||||
|
mediaId: number
|
||||||
|
metadata: VideoMetadata
|
||||||
|
scheduledPublishAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
success: boolean
|
||||||
|
youtubeVideoId?: string
|
||||||
|
youtubeUrl?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads videos to YouTube via the Data API v3.
|
||||||
|
*
|
||||||
|
* Reads video files from the Payload media collection, resolves OAuth credentials
|
||||||
|
* from the social-accounts collection, and calls youtube.videos.insert().
|
||||||
|
*/
|
||||||
|
export class VideoUploadService {
|
||||||
|
private payload: Payload
|
||||||
|
|
||||||
|
constructor(payload: Payload) {
|
||||||
|
this.payload = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a video file from the media collection to YouTube.
|
||||||
|
*
|
||||||
|
* When scheduledPublishAt is provided, the video is uploaded as private
|
||||||
|
* with a publishAt timestamp so YouTube publishes it automatically.
|
||||||
|
*/
|
||||||
|
async uploadVideo(options: UploadOptions): Promise<UploadResult> {
|
||||||
|
const { mediaId, metadata, scheduledPublishAt } = options
|
||||||
|
|
||||||
|
// 1. Resolve OAuth credentials from social-accounts
|
||||||
|
const credentials = await this.loadCredentials()
|
||||||
|
if (!credentials) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No active YouTube social account with valid credentials found',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load the media document
|
||||||
|
const media = await this.payload.findByID({
|
||||||
|
collection: 'media',
|
||||||
|
id: mediaId,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
return { success: false, error: `Media with ID ${mediaId} not found` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = media.filename as string | undefined
|
||||||
|
if (!filename) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Media ${mediaId} has no filename`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify the file exists on disk
|
||||||
|
const filePath = path.join(process.cwd(), 'media', filename)
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `File not found on disk: ${filePath}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the OAuth2 client
|
||||||
|
const oauth2Client = new google.auth.OAuth2(
|
||||||
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
)
|
||||||
|
oauth2Client.setCredentials({
|
||||||
|
access_token: credentials.accessToken,
|
||||||
|
refresh_token: credentials.refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
|
||||||
|
|
||||||
|
// 5. Determine privacy status and optional scheduled publish time
|
||||||
|
let privacyStatus: string = metadata.visibility
|
||||||
|
let publishAt: string | undefined
|
||||||
|
|
||||||
|
if (scheduledPublishAt) {
|
||||||
|
privacyStatus = 'private'
|
||||||
|
publishAt = scheduledPublishAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Execute the upload
|
||||||
|
try {
|
||||||
|
const response = await youtube.videos.insert({
|
||||||
|
part: ['snippet', 'status'],
|
||||||
|
requestBody: {
|
||||||
|
snippet: {
|
||||||
|
title: metadata.title,
|
||||||
|
description: metadata.description,
|
||||||
|
tags: metadata.tags,
|
||||||
|
categoryId: metadata.categoryId,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
privacyStatus,
|
||||||
|
...(publishAt ? { publishAt } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
body: fs.createReadStream(filePath),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoId = response.data.id
|
||||||
|
if (!videoId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'YouTube API returned no video ID',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
youtubeVideoId: videoId,
|
||||||
|
youtubeUrl: `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return { success: false, error: `YouTube upload failed: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first active YouTube social account with valid credentials.
|
||||||
|
*/
|
||||||
|
private async loadCredentials(): Promise<{
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
} | null> {
|
||||||
|
const platforms = await this.payload.find({
|
||||||
|
collection: 'social-platforms',
|
||||||
|
where: { slug: { equals: 'youtube' } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const platformId = platforms.docs[0]?.id
|
||||||
|
if (!platformId) return null
|
||||||
|
|
||||||
|
const accounts = await this.payload.find({
|
||||||
|
collection: 'social-accounts',
|
||||||
|
where: {
|
||||||
|
platform: { equals: platformId },
|
||||||
|
isActive: { equals: true },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = accounts.docs[0]
|
||||||
|
if (!account) return null
|
||||||
|
|
||||||
|
const creds = account.credentials as {
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (!creds?.accessToken || !creds?.refreshToken) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: creds.accessToken,
|
||||||
|
refreshToken: creds.refreshToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { VideoMetadata, UploadOptions, UploadResult }
|
||||||
418
tests/unit/youtube/video-upload-service.unit.spec.ts
Normal file
418
tests/unit/youtube/video-upload-service.unit.spec.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
/**
|
||||||
|
* VideoUploadService Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the service that uploads videos to YouTube via the Data API v3.
|
||||||
|
* Validates credential resolution, media file handling, and YouTube API interaction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
const mockVideosInsert = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('googleapis', () => {
|
||||||
|
class MockOAuth2 {
|
||||||
|
setCredentials(): void {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
google: {
|
||||||
|
auth: {
|
||||||
|
OAuth2: MockOAuth2,
|
||||||
|
},
|
||||||
|
youtube: () => ({
|
||||||
|
videos: {
|
||||||
|
insert: mockVideosInsert,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockExistsSync = vi.fn()
|
||||||
|
const mockCreateReadStream = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
default: {
|
||||||
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||||
|
createReadStream: (...args: unknown[]) => mockCreateReadStream(...args),
|
||||||
|
},
|
||||||
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||||
|
createReadStream: (...args: unknown[]) => mockCreateReadStream(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockFindByID = vi.fn()
|
||||||
|
const mockFind = vi.fn()
|
||||||
|
|
||||||
|
const mockPayload = {
|
||||||
|
findByID: mockFindByID,
|
||||||
|
find: mockFind,
|
||||||
|
} as unknown as import('payload').Payload
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function setupYouTubePlatform(): void {
|
||||||
|
mockFind.mockResolvedValueOnce({
|
||||||
|
docs: [{ id: 100, slug: 'youtube' }],
|
||||||
|
totalDocs: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSocialAccount(overrides?: Record<string, unknown>): void {
|
||||||
|
mockFind.mockResolvedValueOnce({
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
isActive: true,
|
||||||
|
credentials: {
|
||||||
|
accessToken: 'yt-access-token',
|
||||||
|
refreshToken: 'yt-refresh-token',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalDocs: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMedia(overrides?: Record<string, unknown>): void {
|
||||||
|
mockFindByID.mockResolvedValueOnce({
|
||||||
|
id: 1,
|
||||||
|
filename: 'test-video.mp4',
|
||||||
|
mimeType: 'video/mp4',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMetadata = {
|
||||||
|
title: 'My Test Video',
|
||||||
|
description: 'A great video about testing',
|
||||||
|
tags: ['test', 'video'],
|
||||||
|
visibility: 'public' as const,
|
||||||
|
categoryId: '22',
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe('VideoUploadService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.GOOGLE_CLIENT_ID = 'test-client-id'
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should upload a video and return the YouTube video ID', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||||
|
|
||||||
|
mockVideosInsert.mockResolvedValueOnce({
|
||||||
|
data: { id: 'yt-abc123' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.youtubeVideoId).toBe('yt-abc123')
|
||||||
|
expect(result.youtubeUrl).toBe('https://www.youtube.com/watch?v=yt-abc123')
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
|
||||||
|
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
part: ['snippet', 'status'],
|
||||||
|
requestBody: expect.objectContaining({
|
||||||
|
snippet: {
|
||||||
|
title: 'My Test Video',
|
||||||
|
description: 'A great video about testing',
|
||||||
|
tags: ['test', 'video'],
|
||||||
|
categoryId: '22',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
privacyStatus: 'public',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
media: { body: 'fake-stream' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set privacyStatus to private and include publishAt for scheduled uploads', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||||
|
|
||||||
|
mockVideosInsert.mockResolvedValueOnce({
|
||||||
|
data: { id: 'yt-scheduled-1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: { ...defaultMetadata, visibility: 'public' },
|
||||||
|
scheduledPublishAt: '2026-03-01T12:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.youtubeVideoId).toBe('yt-scheduled-1')
|
||||||
|
|
||||||
|
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestBody: expect.objectContaining({
|
||||||
|
status: {
|
||||||
|
privacyStatus: 'private',
|
||||||
|
publishAt: '2026-03-01T12:00:00Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when no YouTube social account exists', async () => {
|
||||||
|
// Platform found, but no account
|
||||||
|
setupYouTubePlatform()
|
||||||
|
mockFind.mockResolvedValueOnce({ docs: [], totalDocs: 0 })
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('No active YouTube social account')
|
||||||
|
expect(mockFindByID).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when social account has no credentials', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount({ credentials: {} })
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('No active YouTube social account')
|
||||||
|
expect(mockFindByID).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when YouTube platform is not found', async () => {
|
||||||
|
mockFind.mockResolvedValueOnce({ docs: [], totalDocs: 0 })
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('No active YouTube social account')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when media document is not found', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
mockFindByID.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 999,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('Media with ID 999 not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when media has no filename', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia({ filename: undefined })
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('has no filename')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when file does not exist on disk', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(false)
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('File not found on disk')
|
||||||
|
expect(result.error).toContain('test-video.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when YouTube API returns no video ID', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||||
|
|
||||||
|
mockVideosInsert.mockResolvedValueOnce({
|
||||||
|
data: { id: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('YouTube API returned no video ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return error when YouTube API throws', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||||
|
|
||||||
|
mockVideosInsert.mockRejectedValueOnce(new Error('quotaExceeded'))
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: defaultMetadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('YouTube upload failed')
|
||||||
|
expect(result.error).toContain('quotaExceeded')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support unlisted visibility', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||||
|
|
||||||
|
mockVideosInsert.mockResolvedValueOnce({
|
||||||
|
data: { id: 'yt-unlisted-1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: { ...defaultMetadata, visibility: 'unlisted' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestBody: expect.objectContaining({
|
||||||
|
status: { privacyStatus: 'unlisted' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should omit categoryId from snippet when not provided', async () => {
|
||||||
|
setupYouTubePlatform()
|
||||||
|
setupSocialAccount()
|
||||||
|
setupMedia()
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||||
|
|
||||||
|
mockVideosInsert.mockResolvedValueOnce({
|
||||||
|
data: { id: 'yt-nocat-1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { VideoUploadService } = await import(
|
||||||
|
'@/lib/integrations/youtube/VideoUploadService'
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new VideoUploadService(mockPayload)
|
||||||
|
const { categoryId: _, ...metadataWithoutCategory } = defaultMetadata
|
||||||
|
const result = await service.uploadVideo({
|
||||||
|
mediaId: 1,
|
||||||
|
metadata: metadataWithoutCategory,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestBody: expect.objectContaining({
|
||||||
|
snippet: expect.objectContaining({
|
||||||
|
title: 'My Test Video',
|
||||||
|
categoryId: undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue