cms.c2sgmbh/tests/unit/youtube/channel-metrics-sync.unit.spec.ts
Martin Porwoll b52e668ecb 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 <noreply@anthropic.com>
2026-02-14 13:24:57 +00:00

365 lines
10 KiB
TypeScript

/**
* 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<typeof import('@/lib/integrations/youtube/YouTubeClient')>()
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)
})
})