From 065e75b0145c3500bf8a28f213d0a250f8f1ad3f Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 13:21:33 +0000 Subject: [PATCH] feat(youtube): add getVideoStatistics to YouTubeClient Add batch video statistics retrieval method that fetches view counts, like counts, and comment counts for up to 50 videos per request. Includes unit tests covering normal operation, empty input, missing statistics defaults, null API response, and error propagation. Co-Authored-By: Claude Opus 4.6 --- src/lib/integrations/youtube/YouTubeClient.ts | 31 +++ .../youtube/youtube-client-stats.unit.spec.ts | 200 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 tests/unit/youtube/youtube-client-stats.unit.spec.ts diff --git a/src/lib/integrations/youtube/YouTubeClient.ts b/src/lib/integrations/youtube/YouTubeClient.ts index c1b74b9..88e097e 100644 --- a/src/lib/integrations/youtube/YouTubeClient.ts +++ b/src/lib/integrations/youtube/YouTubeClient.ts @@ -201,6 +201,37 @@ export class YouTubeClient { } } + /** + * Video-Statistiken für mehrere Videos abrufen (Batch) + * YouTube API erlaubt max. 50 IDs pro Request + */ + async getVideoStatistics(videoIds: string[]): Promise> { + if (videoIds.length === 0) return [] + + try { + const response = await this.youtube.videos.list({ + part: ['statistics'], + id: videoIds, + maxResults: 50, + }) + + return (response.data.items || []).map((item) => ({ + id: item.id!, + views: parseInt(item.statistics?.viewCount || '0', 10), + likes: parseInt(item.statistics?.likeCount || '0', 10), + comments: parseInt(item.statistics?.commentCount || '0', 10), + })) + } catch (error) { + console.error('Error fetching video statistics:', error) + throw error + } + } + /** * Kanal-Statistiken abrufen */ diff --git a/tests/unit/youtube/youtube-client-stats.unit.spec.ts b/tests/unit/youtube/youtube-client-stats.unit.spec.ts new file mode 100644 index 0000000..693c361 --- /dev/null +++ b/tests/unit/youtube/youtube-client-stats.unit.spec.ts @@ -0,0 +1,200 @@ +/** + * YouTubeClient.getVideoStatistics Unit Tests + * + * Tests the batch video statistics retrieval method that fetches + * view counts, like counts, and comment counts for multiple videos. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockVideosList = vi.fn() + +vi.mock('googleapis', () => { + class MockOAuth2 { + setCredentials(): void { + // no-op + } + } + + return { + google: { + auth: { + OAuth2: MockOAuth2, + }, + youtube: () => ({ + videos: { + list: mockVideosList, + }, + commentThreads: { list: vi.fn() }, + comments: { + insert: vi.fn(), + setModerationStatus: vi.fn(), + delete: vi.fn(), + }, + channels: { list: vi.fn() }, + }), + }, + } +}) + +const mockPayload = {} as import('payload').Payload + +describe('YouTubeClient.getVideoStatistics', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return parsed statistics for multiple videos', async () => { + mockVideosList.mockResolvedValueOnce({ + data: { + items: [ + { + id: 'vid1', + statistics: { + viewCount: '1500', + likeCount: '120', + commentCount: '45', + }, + }, + { + id: 'vid2', + statistics: { + viewCount: '3200', + likeCount: '250', + commentCount: '80', + }, + }, + ], + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getVideoStatistics(['vid1', 'vid2']) + + expect(result).toEqual([ + { id: 'vid1', views: 1500, likes: 120, comments: 45 }, + { id: 'vid2', views: 3200, likes: 250, comments: 80 }, + ]) + + expect(mockVideosList).toHaveBeenCalledWith({ + part: ['statistics'], + id: ['vid1', 'vid2'], + maxResults: 50, + }) + }) + + it('should return empty array for empty input without calling API', async () => { + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getVideoStatistics([]) + + expect(result).toEqual([]) + expect(mockVideosList).not.toHaveBeenCalled() + }) + + it('should default missing statistics to zero', async () => { + mockVideosList.mockResolvedValueOnce({ + data: { + items: [ + { + id: 'vid3', + statistics: {}, + }, + ], + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getVideoStatistics(['vid3']) + + expect(result).toEqual([ + { id: 'vid3', views: 0, likes: 0, comments: 0 }, + ]) + }) + + it('should handle null items in API response', async () => { + mockVideosList.mockResolvedValueOnce({ + data: { + items: null, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getVideoStatistics(['vid-missing']) + + expect(result).toEqual([]) + }) + + it('should propagate API errors', async () => { + mockVideosList.mockRejectedValueOnce(new Error('API quota exceeded')) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + await expect( + client.getVideoStatistics(['vid1']), + ).rejects.toThrow('API quota exceeded') + }) +})