cms.c2sgmbh/tests/unit/youtube/video-metrics-sync.unit.spec.ts
Martin Porwoll 5ddcd5ab45 feat(youtube): add VideoMetricsSyncService for batch metrics sync
Syncs video performance metrics (views, likes, comments) from YouTube
API to YouTubeContent.performance fields. Supports batch processing
with 50-video API limit, credential validation, and per-batch error
handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:25:12 +00:00

357 lines
10 KiB
TypeScript

/**
* VideoMetricsSyncService Unit Tests
*
* Tests the service that syncs video performance metrics from YouTube API
* to YouTubeContent.performance fields in the database.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock YouTubeClient as a proper class before importing the service
const mockGetVideoStatistics = vi.fn()
vi.mock('@/lib/integrations/youtube/YouTubeClient', () => {
return {
YouTubeClient: class MockYouTubeClient {
getVideoStatistics = mockGetVideoStatistics
},
}
})
const mockFindByID = vi.fn()
const mockFind = vi.fn()
const mockUpdate = vi.fn()
const mockPayload = {
findByID: mockFindByID,
find: mockFind,
update: mockUpdate,
} as unknown as import('payload').Payload
describe('VideoMetricsSyncService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function setupChannel(): void {
mockFindByID.mockResolvedValueOnce({
id: 1,
name: 'BlogWoman',
youtubeChannelId: 'UCxxx',
})
}
function setupSocialAccount(overrides?: Record<string, unknown>): void {
mockFind.mockResolvedValueOnce({
docs: [
{
id: 10,
credentials: {
accessToken: 'token-123',
refreshToken: 'refresh-456',
},
isActive: true,
...overrides,
},
],
totalDocs: 1,
})
}
function setupVideos(
videos: Array<{ id: number; youtube?: { videoId?: string }; status: string }>,
): void {
mockFind.mockResolvedValueOnce({
docs: videos,
totalDocs: videos.length,
})
}
it('should sync metrics for published videos with YouTube videoId', async () => {
setupChannel()
setupSocialAccount()
setupVideos([
{ id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' },
{ id: 101, youtube: { videoId: 'yt-vid-2' }, status: 'tracked' },
])
mockGetVideoStatistics.mockResolvedValueOnce([
{ id: 'yt-vid-1', views: 1500, likes: 120, comments: 45 },
{ id: 'yt-vid-2', views: 3200, likes: 250, comments: 80 },
])
mockUpdate.mockResolvedValue({})
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(true)
expect(result.syncedCount).toBe(2)
expect(result.skippedCount).toBe(0)
expect(result.errors).toHaveLength(0)
expect(mockUpdate).toHaveBeenCalledTimes(2)
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'youtube-content',
id: 100,
data: expect.objectContaining({
performance: expect.objectContaining({
views: 1500,
likes: 120,
comments: 45,
}),
}),
}),
)
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'youtube-content',
id: 101,
data: expect.objectContaining({
performance: expect.objectContaining({
views: 3200,
likes: 250,
comments: 80,
}),
}),
}),
)
})
it('should skip videos without a YouTube videoId', async () => {
setupChannel()
setupSocialAccount()
setupVideos([
{ id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' },
{ id: 101, youtube: {}, status: 'published' },
{ id: 102, status: 'published' },
])
mockGetVideoStatistics.mockResolvedValueOnce([
{ id: 'yt-vid-1', views: 500, likes: 40, comments: 10 },
])
mockUpdate.mockResolvedValue({})
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(true)
expect(result.syncedCount).toBe(1)
expect(result.skippedCount).toBe(2)
expect(mockGetVideoStatistics).toHaveBeenCalledWith(['yt-vid-1'])
})
it('should return early with success when no videos exist', async () => {
setupChannel()
setupSocialAccount()
setupVideos([])
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(true)
expect(result.syncedCount).toBe(0)
expect(result.skippedCount).toBe(0)
expect(mockGetVideoStatistics).not.toHaveBeenCalled()
})
it('should return error when channel is not found', async () => {
mockFindByID.mockResolvedValueOnce(null)
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(999)
expect(result.success).toBe(false)
expect(result.errors).toContainEqual(
expect.stringContaining('not found'),
)
expect(mockFind).not.toHaveBeenCalled()
})
it('should return error when no active social account exists', async () => {
setupChannel()
mockFind.mockResolvedValueOnce({ docs: [], totalDocs: 0 })
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(false)
expect(result.errors).toContainEqual(
expect.stringContaining('No active social account'),
)
})
it('should return error when credentials are missing', async () => {
setupChannel()
setupSocialAccount({ credentials: {} })
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(false)
expect(result.errors).toContainEqual(
expect.stringContaining('No valid API credentials'),
)
})
it('should batch videos into groups of 50', async () => {
setupChannel()
setupSocialAccount()
// Create 75 videos to force 2 batches (50 + 25)
const videos = Array.from({ length: 75 }, (_, i) => ({
id: i + 1,
youtube: { videoId: `yt-vid-${i + 1}` },
status: 'published',
}))
setupVideos(videos)
// First batch: 50 videos
mockGetVideoStatistics.mockResolvedValueOnce(
Array.from({ length: 50 }, (_, i) => ({
id: `yt-vid-${i + 1}`,
views: 100,
likes: 10,
comments: 5,
})),
)
// Second batch: 25 videos
mockGetVideoStatistics.mockResolvedValueOnce(
Array.from({ length: 25 }, (_, i) => ({
id: `yt-vid-${i + 51}`,
views: 200,
likes: 20,
comments: 10,
})),
)
mockUpdate.mockResolvedValue({})
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(true)
expect(result.syncedCount).toBe(75)
expect(mockGetVideoStatistics).toHaveBeenCalledTimes(2)
const firstCallIds = mockGetVideoStatistics.mock.calls[0][0] as string[]
expect(firstCallIds).toHaveLength(50)
const secondCallIds = mockGetVideoStatistics.mock.calls[1][0] as string[]
expect(secondCallIds).toHaveLength(25)
})
it('should record errors for videos missing from API response', async () => {
setupChannel()
setupSocialAccount()
setupVideos([
{ id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' },
{ id: 101, youtube: { videoId: 'yt-vid-2' }, status: 'published' },
])
// Only return stats for one video
mockGetVideoStatistics.mockResolvedValueOnce([
{ id: 'yt-vid-1', views: 500, likes: 40, comments: 10 },
])
mockUpdate.mockResolvedValue({})
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(false)
expect(result.syncedCount).toBe(1)
expect(result.errors).toContainEqual(
expect.stringContaining('No statistics returned for video yt-vid-2'),
)
})
it('should handle API errors gracefully per batch', async () => {
setupChannel()
setupSocialAccount()
setupVideos([
{ id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' },
])
mockGetVideoStatistics.mockRejectedValueOnce(new Error('API quota exceeded'))
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
const result = await service.syncMetrics(1)
expect(result.success).toBe(false)
expect(result.syncedCount).toBe(0)
expect(result.errors).toContainEqual(
expect.stringContaining('Batch API call failed'),
)
})
it('should set lastSyncedAt in performance data', async () => {
setupChannel()
setupSocialAccount()
setupVideos([
{ id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' },
])
mockGetVideoStatistics.mockResolvedValueOnce([
{ id: 'yt-vid-1', views: 100, likes: 10, comments: 5 },
])
mockUpdate.mockResolvedValue({})
const { VideoMetricsSyncService } = await import(
'@/lib/integrations/youtube/VideoMetricsSyncService'
)
const service = new VideoMetricsSyncService(mockPayload)
await service.syncMetrics(1)
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
performance: expect.objectContaining({
lastSyncedAt: expect.any(String),
}),
}),
}),
)
})
})