mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
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>
418 lines
11 KiB
TypeScript
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,
|
|
}),
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
})
|