mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
feat(youtube): add VideoMetricsSyncService for batch metrics sync
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>
This commit is contained in:
parent
b52e668ecb
commit
5ddcd5ab45
2 changed files with 539 additions and 0 deletions
182
src/lib/integrations/youtube/VideoMetricsSyncService.ts
Normal file
182
src/lib/integrations/youtube/VideoMetricsSyncService.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// src/lib/integrations/youtube/VideoMetricsSyncService.ts
|
||||||
|
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
|
import { YouTubeClient } from './YouTubeClient.js'
|
||||||
|
|
||||||
|
const YOUTUBE_API_BATCH_LIMIT = 50
|
||||||
|
|
||||||
|
interface MetricsSyncResult {
|
||||||
|
success: boolean
|
||||||
|
syncedCount: number
|
||||||
|
skippedCount: number
|
||||||
|
errors: string[]
|
||||||
|
syncedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs video performance metrics from YouTube API to YouTubeContent.performance fields.
|
||||||
|
*
|
||||||
|
* Fetches view counts, like counts, and comment counts for all published/tracked
|
||||||
|
* videos of a given channel and updates their performance fields in the database.
|
||||||
|
*/
|
||||||
|
export class VideoMetricsSyncService {
|
||||||
|
private payload: Payload
|
||||||
|
|
||||||
|
constructor(payload: Payload) {
|
||||||
|
this.payload = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync metrics for all published/tracked videos of a YouTube channel.
|
||||||
|
*/
|
||||||
|
async syncMetrics(channelId: number): Promise<MetricsSyncResult> {
|
||||||
|
const result: MetricsSyncResult = {
|
||||||
|
success: false,
|
||||||
|
syncedCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errors: [],
|
||||||
|
syncedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Load the YouTube channel
|
||||||
|
const channel = await this.payload.findByID({
|
||||||
|
collection: 'youtube-channels',
|
||||||
|
id: channelId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
result.errors.push(`YouTube channel with ID ${channelId} not found`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find the social account linked to this channel
|
||||||
|
const socialAccounts = await this.payload.find({
|
||||||
|
collection: 'social-accounts',
|
||||||
|
where: {
|
||||||
|
linkedChannel: { equals: channelId },
|
||||||
|
isActive: { equals: true },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = socialAccounts.docs[0]
|
||||||
|
if (!account) {
|
||||||
|
result.errors.push(`No active social account found for channel ${channelId}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate credentials
|
||||||
|
const credentials = account.credentials as {
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (!credentials?.accessToken || !credentials?.refreshToken) {
|
||||||
|
result.errors.push('No valid API credentials on social account')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Find all published/tracked videos with a YouTube videoId
|
||||||
|
const videos = await this.payload.find({
|
||||||
|
collection: 'youtube-content',
|
||||||
|
where: {
|
||||||
|
channel: { equals: channelId },
|
||||||
|
status: { in: ['published', 'tracked'] },
|
||||||
|
},
|
||||||
|
limit: 0,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const videosWithYouTubeId = videos.docs.filter(
|
||||||
|
(video: any) => video.youtube?.videoId,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.skippedCount = videos.docs.length - videosWithYouTubeId.length
|
||||||
|
|
||||||
|
if (videosWithYouTubeId.length === 0) {
|
||||||
|
result.success = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Batch videos into groups of 50 and fetch statistics
|
||||||
|
const batches = createBatches(videosWithYouTubeId, YOUTUBE_API_BATCH_LIMIT)
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
try {
|
||||||
|
const videoIds = batch.map((video: any) => video.youtube.videoId as string)
|
||||||
|
const statistics = await youtubeClient.getVideoStatistics(videoIds)
|
||||||
|
|
||||||
|
// Build a lookup map for quick access
|
||||||
|
const statsMap = new Map(statistics.map((s) => [s.id, s]))
|
||||||
|
|
||||||
|
// 7. Update each video's performance fields
|
||||||
|
for (const video of batch) {
|
||||||
|
const youtubeVideoId = (video as any).youtube.videoId as string
|
||||||
|
const stats = statsMap.get(youtubeVideoId)
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
result.errors.push(
|
||||||
|
`No statistics returned for video ${youtubeVideoId} (doc ${video.id})`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.payload.update({
|
||||||
|
collection: 'youtube-content',
|
||||||
|
id: video.id,
|
||||||
|
data: {
|
||||||
|
performance: {
|
||||||
|
views: stats.views,
|
||||||
|
likes: stats.likes,
|
||||||
|
comments: stats.comments,
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result.syncedCount++
|
||||||
|
} catch (updateError) {
|
||||||
|
result.errors.push(
|
||||||
|
`Failed to update video ${video.id}: ${updateError}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (batchError) {
|
||||||
|
result.errors.push(`Batch API call failed: ${batchError}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.errors.length === 0
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Sync error: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an array into chunks of a given size.
|
||||||
|
*/
|
||||||
|
function createBatches<T>(items: T[], batchSize: number): T[][] {
|
||||||
|
const batches: T[][] = []
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
batches.push(items.slice(i, i + batchSize))
|
||||||
|
}
|
||||||
|
return batches
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { MetricsSyncResult }
|
||||||
357
tests/unit/youtube/video-metrics-sync.unit.spec.ts
Normal file
357
tests/unit/youtube/video-metrics-sync.unit.spec.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue