/** * ChannelMetricsSyncService Unit Tests * * Tests the service that syncs channel-level statistics from YouTube API * to YouTubeChannels.currentMetrics fields. */ import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock YouTubeClient before importing the service const mockGetChannelStats = vi.fn() vi.mock('googleapis', () => { class MockOAuth2 { setCredentials(): void { // no-op } } return { google: { auth: { OAuth2: MockOAuth2, }, youtube: () => ({ channels: { list: vi.fn() }, videos: { list: vi.fn() }, commentThreads: { list: vi.fn() }, comments: { insert: vi.fn(), setModerationStatus: vi.fn(), delete: vi.fn(), }, }), }, } }) // Patch getChannelStats on the prototype after mocking googleapis vi.mock('@/lib/integrations/youtube/YouTubeClient', async (importOriginal) => { const mod = await importOriginal() const OriginalClient = mod.YouTubeClient class MockedYouTubeClient extends OriginalClient { async getChannelStats(channelId: string) { return mockGetChannelStats(channelId) } } return { ...mod, YouTubeClient: MockedYouTubeClient, } }) vi.stubEnv('GOOGLE_CLIENT_ID', 'test-client-id') vi.stubEnv('GOOGLE_CLIENT_SECRET', 'test-client-secret') // --- Payload mock --- function createMockPayload(overrides: { channels?: any[] socialAccounts?: any[] }) { const { channels = [], socialAccounts = [] } = overrides const updatedDocs: Array<{ collection: string; id: any; data: any }> = [] return { find: vi.fn(async ({ collection, where }: any) => { if (collection === 'youtube-channels') { return { docs: channels, totalDocs: channels.length } } if (collection === 'social-accounts') { const linkedId = where?.linkedChannel?.equals const matching = socialAccounts.filter( (a: any) => a.linkedChannel === linkedId && a.isActive, ) return { docs: matching, totalDocs: matching.length } } return { docs: [], totalDocs: 0 } }), update: vi.fn(async ({ collection, id, data }: any) => { updatedDocs.push({ collection, id, data }) return { id, ...data } }), _updatedDocs: updatedDocs, } } describe('ChannelMetricsSyncService', () => { beforeEach(() => { vi.clearAllMocks() }) it('should sync metrics for active channels with valid credentials', async () => { const mockPayload = createMockPayload({ channels: [ { id: 1, name: 'BlogWoman', youtubeChannelId: 'UC_test_123', status: 'active', }, ], socialAccounts: [ { id: 10, linkedChannel: 1, isActive: true, credentials: { accessToken: 'token-abc', refreshToken: 'refresh-xyz', }, }, ], }) mockGetChannelStats.mockResolvedValueOnce({ subscriberCount: 15000, videoCount: 245, viewCount: 3200000, }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.success).toBe(true) expect(result.channelsSynced).toBe(1) expect(result.errors).toHaveLength(0) expect(mockGetChannelStats).toHaveBeenCalledWith('UC_test_123') expect(mockPayload.update).toHaveBeenCalledWith( expect.objectContaining({ collection: 'youtube-channels', id: 1, data: expect.objectContaining({ currentMetrics: expect.objectContaining({ subscriberCount: 15000, totalViews: 3200000, videoCount: 245, }), }), }), ) // Verify lastSyncedAt is an ISO string const updateCall = mockPayload.update.mock.calls[0][0] expect(updateCall.data.currentMetrics.lastSyncedAt).toBeDefined() expect(new Date(updateCall.data.currentMetrics.lastSyncedAt).toISOString()).toBe( updateCall.data.currentMetrics.lastSyncedAt, ) }) it('should skip channels without youtubeChannelId', async () => { const mockPayload = createMockPayload({ channels: [ { id: 2, name: 'Planned Channel', youtubeChannelId: undefined, status: 'active', }, ], socialAccounts: [], }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.channelsSynced).toBe(0) expect(result.errors).toHaveLength(1) expect(result.errors[0]).toContain('has no youtubeChannelId') expect(mockGetChannelStats).not.toHaveBeenCalled() expect(mockPayload.update).not.toHaveBeenCalled() }) it('should return success with zero synced when no active channels exist', async () => { const mockPayload = createMockPayload({ channels: [], socialAccounts: [], }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.success).toBe(true) expect(result.channelsSynced).toBe(0) expect(result.errors).toHaveLength(0) }) it('should record error when no social account is linked to channel', async () => { const mockPayload = createMockPayload({ channels: [ { id: 3, name: 'Orphan Channel', youtubeChannelId: 'UC_orphan', status: 'active', }, ], socialAccounts: [], }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.channelsSynced).toBe(0) expect(result.errors).toHaveLength(1) expect(result.errors[0]).toContain('No active social account') expect(mockPayload.update).not.toHaveBeenCalled() }) it('should record error when credentials are missing', async () => { const mockPayload = createMockPayload({ channels: [ { id: 4, name: 'No Creds Channel', youtubeChannelId: 'UC_nocreds', status: 'active', }, ], socialAccounts: [ { id: 20, linkedChannel: 4, isActive: true, credentials: { accessToken: undefined, refreshToken: undefined, }, }, ], }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.channelsSynced).toBe(0) expect(result.errors).toHaveLength(1) expect(result.errors[0]).toContain('No valid API credentials') }) it('should handle YouTube API errors gracefully per channel', async () => { const mockPayload = createMockPayload({ channels: [ { id: 5, name: 'Failing Channel', youtubeChannelId: 'UC_fail', status: 'active', }, { id: 6, name: 'Working Channel', youtubeChannelId: 'UC_works', status: 'active', }, ], socialAccounts: [ { id: 30, linkedChannel: 5, isActive: true, credentials: { accessToken: 'tok', refreshToken: 'ref' }, }, { id: 31, linkedChannel: 6, isActive: true, credentials: { accessToken: 'tok2', refreshToken: 'ref2' }, }, ], }) mockGetChannelStats .mockRejectedValueOnce(new Error('API quota exceeded')) .mockResolvedValueOnce({ subscriberCount: 500, videoCount: 20, viewCount: 100000, }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.success).toBe(false) expect(result.channelsSynced).toBe(1) expect(result.errors).toHaveLength(1) expect(result.errors[0]).toContain('API quota exceeded') // The working channel should still have been updated expect(mockPayload.update).toHaveBeenCalledTimes(1) expect(mockPayload.update).toHaveBeenCalledWith( expect.objectContaining({ collection: 'youtube-channels', id: 6, }), ) }) it('should sync multiple channels in a single call', async () => { const mockPayload = createMockPayload({ channels: [ { id: 7, name: 'Channel A', youtubeChannelId: 'UC_a', status: 'active' }, { id: 8, name: 'Channel B', youtubeChannelId: 'UC_b', status: 'active' }, ], socialAccounts: [ { id: 40, linkedChannel: 7, isActive: true, credentials: { accessToken: 'a_tok', refreshToken: 'a_ref' }, }, { id: 41, linkedChannel: 8, isActive: true, credentials: { accessToken: 'b_tok', refreshToken: 'b_ref' }, }, ], }) mockGetChannelStats .mockResolvedValueOnce({ subscriberCount: 1000, videoCount: 50, viewCount: 200000 }) .mockResolvedValueOnce({ subscriberCount: 2000, videoCount: 100, viewCount: 400000 }) const { ChannelMetricsSyncService } = await import( '@/lib/integrations/youtube/ChannelMetricsSyncService' ) const service = new ChannelMetricsSyncService(mockPayload as any) const result = await service.syncAllChannels() expect(result.success).toBe(true) expect(result.channelsSynced).toBe(2) expect(result.errors).toHaveLength(0) expect(mockPayload.update).toHaveBeenCalledTimes(2) }) })