From b52e668ecbeece2f0ddd3022b3f8192c45ed3c38 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 13:24:57 +0000 Subject: [PATCH] feat(youtube): add ChannelMetricsSyncService Syncs channel-level statistics (subscribers, views, video count) from YouTube Data API to YouTubeChannels.currentMetrics fields for all active channels. Follows the same credential-loading pattern as existing sync services. Co-Authored-By: Claude Opus 4.6 --- .../youtube/ChannelMetricsSyncService.ts | 143 +++++++ .../youtube/channel-metrics-sync.unit.spec.ts | 365 ++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 src/lib/integrations/youtube/ChannelMetricsSyncService.ts create mode 100644 tests/unit/youtube/channel-metrics-sync.unit.spec.ts diff --git a/src/lib/integrations/youtube/ChannelMetricsSyncService.ts b/src/lib/integrations/youtube/ChannelMetricsSyncService.ts new file mode 100644 index 0000000..bb81f09 --- /dev/null +++ b/src/lib/integrations/youtube/ChannelMetricsSyncService.ts @@ -0,0 +1,143 @@ +// src/lib/integrations/youtube/ChannelMetricsSyncService.ts + +import type { Payload } from 'payload' + +import { YouTubeClient } from './YouTubeClient.js' + +interface ChannelMetricsSyncResult { + success: boolean + channelsSynced: number + errors: string[] +} + +/** + * Syncs channel-level statistics from YouTube API to YouTubeChannels.currentMetrics fields. + * + * For each active YouTube channel, fetches subscriber count, video count, and total views + * via the YouTube Data API and persists them in the database. + */ +export class ChannelMetricsSyncService { + private payload: Payload + + constructor(payload: Payload) { + this.payload = payload + } + + /** + * Sync metrics for all active YouTube channels. + * + * Finds every channel with status "active", resolves its linked social account + * for API credentials, calls getChannelStats, and updates currentMetrics. + */ + async syncAllChannels(): Promise { + const result: ChannelMetricsSyncResult = { + success: false, + channelsSynced: 0, + errors: [], + } + + try { + // 1. Find all active YouTube channels + const channels = await this.payload.find({ + collection: 'youtube-channels', + where: { + status: { equals: 'active' }, + }, + limit: 0, + depth: 0, + }) + + if (channels.docs.length === 0) { + result.success = true + return result + } + + // 2. Process each channel + for (const channel of channels.docs) { + const channelName = (channel as any).name ?? `ID ${channel.id}` + const youtubeChannelId = (channel as any).youtubeChannelId as string | undefined + + if (!youtubeChannelId) { + result.errors.push(`Channel "${channelName}" has no youtubeChannelId, skipping`) + continue + } + + try { + await this.syncSingleChannel(channel, youtubeChannelId) + result.channelsSynced++ + } catch (error) { + result.errors.push(`Failed to sync channel "${channelName}": ${error}`) + } + } + + result.success = result.errors.length === 0 + } catch (error) { + result.errors.push(`Sync error: ${error}`) + } + + return result + } + + /** + * Sync metrics for a single YouTube channel. + */ + private async syncSingleChannel( + channel: { id: number | string }, + youtubeChannelId: string, + ): Promise { + // 1. Find the social account linked to this channel + const socialAccounts = await this.payload.find({ + collection: 'social-accounts', + where: { + linkedChannel: { equals: channel.id }, + isActive: { equals: true }, + }, + limit: 1, + }) + + const account = socialAccounts.docs[0] + if (!account) { + throw new Error(`No active social account found for channel ${channel.id}`) + } + + // 2. Validate credentials + const credentials = account.credentials as { + accessToken?: string + refreshToken?: string + } | undefined + + if (!credentials?.accessToken || !credentials?.refreshToken) { + throw new Error('No valid API credentials on social account') + } + + // 3. Initialize YouTube client + const youtubeClient = new YouTubeClient( + { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + }, + this.payload, + ) + + // 4. Fetch channel statistics from YouTube + const stats = await youtubeClient.getChannelStats(youtubeChannelId) + + // 5. Update currentMetrics fields + await this.payload.update({ + collection: 'youtube-channels', + id: channel.id, + data: { + currentMetrics: { + subscriberCount: stats.subscriberCount, + totalViews: stats.viewCount, + videoCount: stats.videoCount, + lastSyncedAt: new Date().toISOString(), + }, + }, + }) + } +} + +export type { ChannelMetricsSyncResult } diff --git a/tests/unit/youtube/channel-metrics-sync.unit.spec.ts b/tests/unit/youtube/channel-metrics-sync.unit.spec.ts new file mode 100644 index 0000000..366ba5f --- /dev/null +++ b/tests/unit/youtube/channel-metrics-sync.unit.spec.ts @@ -0,0 +1,365 @@ +/** + * 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) + }) +})