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