cms.c2sgmbh/tests/unit/youtube/video-upload-service.unit.spec.ts
Martin Porwoll 6cc3011804 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>
2026-02-14 13:31:25 +00:00

418 lines
11 KiB
TypeScript

/**
* 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,
}),
}),
}),
)
})
})