mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
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>
357 lines
10 KiB
TypeScript
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),
|
|
}),
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
})
|