diff --git a/src/main/domain/top-clips-crawler.test.ts b/src/main/domain/top-clips-crawler.test.ts new file mode 100644 index 0000000..e4c6a99 --- /dev/null +++ b/src/main/domain/top-clips-crawler.test.ts @@ -0,0 +1,137 @@ +import { test, expect, describe } from 'vitest'; +import { fetchTopClips, rangeLastDays } from './top-clips-crawler'; + +function fakeFetch(rows: Array>, status = 200): typeof fetch { + return (async (url: string | URL | Request, init?: RequestInit): Promise => { + // verify request shape lightly inside the fake + const headers = init?.headers as Record | undefined; + if (status === 200 && (!headers?.['Authorization'] || !headers?.['Client-Id'])) { + return new Response('missing auth headers', { status: 401 }); + } + return new Response(JSON.stringify({ data: rows }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; +} + +describe('fetchTopClips', () => { + test('returns parsed clips sorted by view_count desc', async () => { + const fakeRows = [ + { + id: 'C2', url: 'u2', embed_url: 'e2', broadcaster_id: 'b', broadcaster_name: 'B', + creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en', + title: 'mid', view_count: 50, created_at: '2026-05-10T00:00:00Z', + thumbnail_url: 't', duration: 30, vod_offset: 120, + }, + { + id: 'C1', url: 'u1', embed_url: 'e1', broadcaster_id: 'b', broadcaster_name: 'B', + creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en', + title: 'high', view_count: 200, created_at: '2026-05-09T00:00:00Z', + thumbnail_url: 't', duration: 45, vod_offset: null, + }, + ]; + const clips = await fetchTopClips({ + clientId: 'CID', accessToken: 'TOK', broadcasterId: 'b', + fetchImpl: fakeFetch(fakeRows), + }); + + expect(clips).toHaveLength(2); + expect(clips[0].id).toBe('C1'); + expect(clips[0].viewCount).toBe(200); + expect(clips[1].id).toBe('C2'); + expect(clips[1].vodOffsetSeconds).toBe(120); + expect(clips[0].vodOffsetSeconds).toBeNull(); + }); + + test('snake_case → camelCase mapping for broadcaster fields', async () => { + const fakeRows = [ + { + id: 'X', url: 'u', embed_url: 'e', broadcaster_id: 'bid', broadcaster_name: 'BName', + creator_id: 'cid', creator_name: 'CName', video_id: 'vid', game_id: 'gid', + language: 'de', title: 'T', view_count: 10, created_at: '2026-05-01T00:00:00Z', + thumbnail_url: 'th', duration: 12, + }, + ]; + const [c] = await fetchTopClips({ + clientId: 'CID', accessToken: 'TOK', broadcasterId: 'bid', + fetchImpl: fakeFetch(fakeRows), + }); + expect(c.broadcasterId).toBe('bid'); + expect(c.broadcasterName).toBe('BName'); + expect(c.creatorId).toBe('cid'); + expect(c.creatorName).toBe('CName'); + expect(c.videoId).toBe('vid'); + expect(c.gameId).toBe('gid'); + }); + + test('builds query string with broadcaster_id + first + date range', async () => { + let capturedUrl: string | null = null; + const captureFetch = (async (url: string | URL | Request): Promise => { + capturedUrl = String(url); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchTopClips({ + clientId: 'CID', accessToken: 'TOK', broadcasterId: '12345', + startedAt: '2026-05-01T00:00:00Z', endedAt: '2026-05-11T00:00:00Z', + first: 50, fetchImpl: captureFetch, + }); + expect(capturedUrl).toContain('broadcaster_id=12345'); + expect(capturedUrl).toContain('first=50'); + expect(capturedUrl).toContain('started_at=2026-05-01T00%3A00%3A00Z'); + expect(capturedUrl).toContain('ended_at=2026-05-11T00%3A00%3A00Z'); + }); + + test('clamps first to [1, 100]', async () => { + let capturedUrl: string | null = null; + const captureFetch = (async (url: string | URL | Request): Promise => { + capturedUrl = String(url); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 999, fetchImpl: captureFetch }); + expect(capturedUrl).toContain('first=100'); + + await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 0, fetchImpl: captureFetch }); + expect(capturedUrl).toContain('first=1'); + }); + + test('throws on non-2xx response', async () => { + await expect(fetchTopClips({ + clientId: 'C', accessToken: 'T', broadcasterId: 'b', + fetchImpl: fakeFetch([], 503), + })).rejects.toThrow(/503/); + }); + + test('throws on malformed JSON', async () => { + const brokenFetch = (async (): Promise => new Response('{not-json', { status: 200 })) as unknown as typeof fetch; + await expect(fetchTopClips({ + clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch, + })).rejects.toThrow(/parse failed/); + }); + + test('empty data returns empty array (not null)', async () => { + const emptyFetch = (async (): Promise => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch; + const clips = await fetchTopClips({ + clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: emptyFetch, + }); + expect(clips).toEqual([]); + }); +}); + +describe('rangeLastDays', () => { + test('produces ISO RFC3339 strings exactly N days apart', () => { + const now = new Date('2026-05-11T12:00:00Z'); + const range = rangeLastDays(7, now); + expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z'); + expect(range.startedAt).toBe('2026-05-04T12:00:00.000Z'); + }); + + test('1-day range', () => { + const now = new Date('2026-05-11T12:00:00Z'); + const range = rangeLastDays(1, now); + expect(range.startedAt).toBe('2026-05-10T12:00:00.000Z'); + expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z'); + }); +}); diff --git a/src/main/domain/top-clips-crawler.ts b/src/main/domain/top-clips-crawler.ts new file mode 100644 index 0000000..e415c54 --- /dev/null +++ b/src/main/domain/top-clips-crawler.ts @@ -0,0 +1,135 @@ +// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl +// aufgerufen (Tests koennen mocken). Helix-Endpunkt: +// GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N +// +// Auth: Client-Credentials (app-token) reicht — kein User-Token noetig. +// Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen. + +const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips'; + +export interface TopClip { + id: string; + url: string; + embedUrl: string; + broadcasterId: string; + broadcasterName: string; + creatorId: string; + creatorName: string; + videoId: string; + gameId: string; + language: string; + title: string; + viewCount: number; + createdAt: string; // ISO timestamp + thumbnailUrl: string; + duration: number; // seconds + vodOffsetSeconds: number | null; +} + +interface HelixClipRow { + id: string; + url: string; + embed_url: string; + broadcaster_id: string; + broadcaster_name: string; + creator_id: string; + creator_name: string; + video_id: string; + game_id: string; + language: string; + title: string; + view_count: number; + created_at: string; + thumbnail_url: string; + duration: number; + vod_offset?: number | null; +} + +interface HelixClipsResponse { + data?: HelixClipRow[]; + pagination?: { cursor?: string }; +} + +export interface FetchTopClipsOptions { + clientId: string; + accessToken: string; + broadcasterId: string; + startedAt?: string; // ISO RFC3339 + endedAt?: string; + first?: number; // 1-100, default 20 + fetchImpl?: typeof fetch; +} + +function rowToClip(row: HelixClipRow): TopClip { + return { + id: row.id, + url: row.url, + embedUrl: row.embed_url, + broadcasterId: row.broadcaster_id, + broadcasterName: row.broadcaster_name, + creatorId: row.creator_id, + creatorName: row.creator_name, + videoId: row.video_id, + gameId: row.game_id, + language: row.language, + title: row.title, + viewCount: row.view_count, + createdAt: row.created_at, + thumbnailUrl: row.thumbnail_url, + duration: row.duration, + vodOffsetSeconds: row.vod_offset ?? null, + }; +} + +export async function fetchTopClips(opts: FetchTopClipsOptions): Promise { + const fetchFn = opts.fetchImpl ?? fetch; + const first = Math.min(100, Math.max(1, opts.first ?? 20)); + + const params = new URLSearchParams({ + broadcaster_id: opts.broadcasterId, + first: String(first), + }); + if (opts.startedAt) params.set('started_at', opts.startedAt); + if (opts.endedAt) params.set('ended_at', opts.endedAt); + + const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, { + headers: { + 'Authorization': `Bearer ${opts.accessToken}`, + 'Client-Id': opts.clientId, + }, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`); + } + let parsed: HelixClipsResponse; + try { + parsed = JSON.parse(text) as HelixClipsResponse; + } catch (e) { + throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`); + } + + const rows = parsed.data ?? []; + // Helix returns clips already sorted by view_count desc, but we re-sort + // defensively in case that order ever changes. + return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount); +} + +export interface DateRange { + startedAt: string; + endedAt: string; +} + +/** + * Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet + * RFC3339 Format (`2026-05-11T00:00:00Z`). + */ +export function rangeLastDays(days: number, now: Date = new Date()): DateRange { + const end = new Date(now.getTime()); + const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + return { + startedAt: start.toISOString(), + endedAt: end.toISOString(), + }; +}