import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock the yt-dlp module before importing sources vi.mock('../sources/yt-dlp', () => ({ execYtDlp: vi.fn(), parseSingleJson: vi.fn(), parseJsonLines: vi.fn(), checkYtDlpAvailable: vi.fn(), YtDlpError: class YtDlpError extends Error { stderr: string; exitCode: number; isRateLimit: boolean; constructor(message: string, stderr: string, exitCode: number) { super(message); this.name = 'YtDlpError'; this.stderr = stderr; this.exitCode = exitCode; this.isRateLimit = stderr.toLowerCase().includes('429'); } }, })); import { execYtDlp, parseSingleJson, parseJsonLines } from '../sources/yt-dlp'; import { YouTubeSource, isYouTubeUrl } from '../sources/youtube'; import { SoundCloudSource, isSoundCloudChannelUrl } from '../sources/soundcloud'; import { PlatformRegistry } from '../sources/platform-source'; import { Platform, ContentType } from '../types/index'; import type { Channel } from '../types/index'; const mockExecYtDlp = execYtDlp as ReturnType; const mockParseSingleJson = parseSingleJson as ReturnType; const mockParseJsonLines = parseJsonLines as ReturnType; // ── Canned Fixtures ── const YOUTUBE_CHANNEL_JSON = { channel: 'Linus Tech Tips', channel_id: 'UCXuqSBlHAE6Xw-yeJA0Tunw', channel_url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw', uploader: 'Linus Tech Tips', uploader_id: '@LinusTechTips', uploader_url: 'https://www.youtube.com/@LinusTechTips', thumbnails: [ { url: 'https://i.ytimg.com/vi/thumb_small.jpg', width: 120, height: 90 }, { url: 'https://i.ytimg.com/vi/thumb_large.jpg', width: 1280, height: 720 }, ], }; const YOUTUBE_PLAYLIST_ENTRIES = [ { id: 'dQw4w9WgXcQ', title: 'Never Gonna Give You Up', url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', webpage_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', duration: 212, thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', live_status: null, upload_date: '20240315', }, { id: 'abc123xyz', title: 'Tech Review 2024', url: 'https://www.youtube.com/watch?v=abc123xyz', duration: 845, thumbnails: [ { url: 'https://i.ytimg.com/vi/abc123xyz/sd.jpg' }, { url: 'https://i.ytimg.com/vi/abc123xyz/hd.jpg' }, ], live_status: null, upload_date: '20240620', }, { id: 'live001', title: 'Live Stream Event', url: 'https://www.youtube.com/watch?v=live001', duration: null, thumbnail: 'https://i.ytimg.com/vi/live001/hqdefault.jpg', live_status: 'is_live', upload_date: '20240701', }, ]; const SOUNDCLOUD_ARTIST_JSON = { uploader: 'Deadmau5', uploader_id: 'deadmau5', uploader_url: 'https://soundcloud.com/deadmau5', channel: null, channel_id: null, channel_url: null, thumbnails: [ { url: 'https://i1.sndcdn.com/avatars-small.jpg' }, { url: 'https://i1.sndcdn.com/avatars-large.jpg' }, ], }; const SOUNDCLOUD_TRACK_ENTRIES = [ { id: 'sc-track-001', title: 'Strobe (Club Edit)', url: 'https://soundcloud.com/deadmau5/strobe-club-edit', duration: 421, thumbnail: 'https://i1.sndcdn.com/artworks-track1.jpg', }, { id: 'sc-track-002', title: 'Ghosts n Stuff', url: 'https://soundcloud.com/deadmau5/ghosts-n-stuff', duration: 335, thumbnails: [ { url: 'https://i1.sndcdn.com/artworks-track2-sm.jpg' }, { url: 'https://i1.sndcdn.com/artworks-track2-lg.jpg' }, ], }, { id: 'sc-track-003', title: 'Raise Your Weapon', url: 'https://soundcloud.com/deadmau5/raise-your-weapon', duration: 498, thumbnail: null, }, ]; // ── Helper ── function makeChannel(overrides: Partial = {}): Channel { return { id: 1, name: 'Test Channel', platform: Platform.YouTube, platformId: 'UC123', url: 'https://www.youtube.com/@TestChannel', monitoringEnabled: true, checkInterval: 360, imageUrl: null, metadata: null, formatProfileId: null, monitoringMode: 'all', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', lastCheckedAt: null, lastCheckStatus: null, ...overrides, }; } // ── Tests ── describe('YouTube URL validation', () => { it('accepts @handle format', () => { expect(isYouTubeUrl('https://www.youtube.com/@LinusTechTips')).toBe(true); expect(isYouTubeUrl('https://youtube.com/@LinusTechTips')).toBe(true); }); it('accepts /channel/ format', () => { expect( isYouTubeUrl( 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw' ) ).toBe(true); }); it('accepts /c/ format', () => { expect(isYouTubeUrl('https://www.youtube.com/c/LinusTechTips')).toBe(true); }); it('accepts /user/ format', () => { expect(isYouTubeUrl('https://www.youtube.com/user/LinusTechTips')).toBe( true ); }); it('accepts youtu.be short URLs', () => { expect(isYouTubeUrl('https://youtu.be/dQw4w9WgXcQ')).toBe(true); }); it('rejects non-YouTube URLs', () => { expect(isYouTubeUrl('https://soundcloud.com/artist')).toBe(false); expect(isYouTubeUrl('https://example.com')).toBe(false); }); }); describe('SoundCloud URL validation', () => { it('accepts artist-level URLs', () => { expect(isSoundCloudChannelUrl('https://soundcloud.com/deadmau5')).toBe( true ); expect(isSoundCloudChannelUrl('https://www.soundcloud.com/deadmau5')).toBe( true ); }); it('rejects track URLs', () => { expect( isSoundCloudChannelUrl( 'https://soundcloud.com/deadmau5/tracks/strobe' ) ).toBe(false); }); it('rejects set URLs', () => { expect( isSoundCloudChannelUrl( 'https://soundcloud.com/deadmau5/sets/album' ) ).toBe(false); }); it('rejects non-SoundCloud URLs', () => { expect(isSoundCloudChannelUrl('https://youtube.com/@test')).toBe(false); expect(isSoundCloudChannelUrl('https://example.com')).toBe(false); }); }); describe('YouTubeSource', () => { const youtube = new YouTubeSource(); beforeEach(() => { vi.clearAllMocks(); }); describe('resolveChannel', () => { it('resolves channel metadata from a YouTube URL', async () => { mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(YOUTUBE_CHANNEL_JSON), stderr: '', exitCode: 0, }); mockParseSingleJson.mockReturnValueOnce(YOUTUBE_CHANNEL_JSON); const result = await youtube.resolveChannel( 'https://www.youtube.com/@LinusTechTips' ); expect(result).toEqual({ name: 'Linus Tech Tips', platformId: 'UCXuqSBlHAE6Xw-yeJA0Tunw', imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg', url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw', platform: 'youtube', }); // Verify yt-dlp was called with correct args expect(mockExecYtDlp).toHaveBeenCalledWith( [ '--dump-single-json', '--playlist-items', '0', '--flat-playlist', 'https://www.youtube.com/@LinusTechTips', ], { timeout: 30_000 } ); }); it('falls back to uploader when channel is missing', async () => { const noChannelJson = { ...YOUTUBE_CHANNEL_JSON, channel: undefined, channel_id: undefined, channel_url: undefined, }; mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, }); mockParseSingleJson.mockReturnValueOnce(noChannelJson); const result = await youtube.resolveChannel( 'https://www.youtube.com/@Test' ); expect(result.name).toBe('Linus Tech Tips'); // falls back to uploader }); }); describe('fetchRecentContent', () => { it('Phase 1 discovers items via --flat-playlist, Phase 2 enriches new items', async () => { const channel = makeChannel(); // Phase 1: flat-playlist discovery mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, }); mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES); // Phase 2: enrichment for each new item (3 items, none in existingIds) for (const entry of YOUTUBE_PLAYLIST_ENTRIES) { const enrichedEntry = { ...entry, upload_date: entry.upload_date }; mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enrichedEntry), stderr: '', exitCode: 0, }); mockParseSingleJson.mockReturnValueOnce(enrichedEntry); } const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds: new Set(), rateLimitDelay: 0, // no delay in tests }); expect(result).toHaveLength(3); // First entry — regular video with enriched publishedAt expect(result[0]).toEqual({ platformContentId: 'dQw4w9WgXcQ', title: 'Never Gonna Give You Up', url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', contentType: 'video', duration: 212, thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', publishedAt: '2024-03-15T00:00:00Z', }); // Second entry — uses thumbnails array expect(result[1]?.thumbnailUrl).toBe( 'https://i.ytimg.com/vi/abc123xyz/hd.jpg' ); // Third entry — live stream expect(result[2]?.contentType).toBe('livestream'); // Verify Phase 1 call uses --flat-playlist expect(mockExecYtDlp).toHaveBeenNthCalledWith( 1, ['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url], { timeout: 60_000 } ); // Verify Phase 2 calls use --dump-json --no-playlist per video expect(mockExecYtDlp).toHaveBeenNthCalledWith( 2, ['--dump-json', '--no-playlist', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'], { timeout: 15_000 } ); }); it('skips enrichment for items in existingIds', async () => { const channel = makeChannel(); // Phase 1: discovery returns 3 items mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES); // Provide 2 of 3 as existing — only 1 needs enrichment const existingIds = new Set(['dQw4w9WgXcQ', 'abc123xyz']); // Phase 2: only live001 needs enrichment const enrichedLive = { ...YOUTUBE_PLAYLIST_ENTRIES[2], upload_date: '20240701' }; mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enrichedLive), stderr: '', exitCode: 0 }); mockParseSingleJson.mockReturnValueOnce(enrichedLive); const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds, rateLimitDelay: 0, }); expect(result).toHaveLength(3); // Only 2 execYtDlp calls: 1 flat-playlist + 1 enrichment expect(mockExecYtDlp).toHaveBeenCalledTimes(2); }); it('skips all enrichment when all items are existing', async () => { const channel = makeChannel(); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES); const allIds = new Set(YOUTUBE_PLAYLIST_ENTRIES.map((e) => e.id)); const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds: allIds, rateLimitDelay: 0, }); expect(result).toHaveLength(3); // Only 1 execYtDlp call: flat-playlist only, no enrichment expect(mockExecYtDlp).toHaveBeenCalledTimes(1); }); it('tolerates individual enrichment failures', async () => { const channel = makeChannel(); // Phase 1: discovery mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES); // Phase 2: first enrichment succeeds const enriched1 = { ...YOUTUBE_PLAYLIST_ENTRIES[0] }; mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enriched1), stderr: '', exitCode: 0 }); mockParseSingleJson.mockReturnValueOnce(enriched1); // Second enrichment fails mockExecYtDlp.mockRejectedValueOnce(new Error('network timeout')); // Third enrichment succeeds const enriched3 = { ...YOUTUBE_PLAYLIST_ENTRIES[2] }; mockExecYtDlp.mockResolvedValueOnce({ stdout: JSON.stringify(enriched3), stderr: '', exitCode: 0 }); mockParseSingleJson.mockReturnValueOnce(enriched3); // Suppress console.warn for expected enrichment failure const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds: new Set(), rateLimitDelay: 0, }); warnSpy.mockRestore(); // All 3 items returned — failed one uses flat-playlist data expect(result).toHaveLength(3); // First item is enriched expect(result[0]?.publishedAt).toBe('2024-03-15T00:00:00Z'); // Second item falls back to flat data (no upload_date from flat-playlist → null) // The flat-playlist entry had upload_date:'20240620', so mapEntry gives it publishedAt // BUT the enrichment failed, so it uses the flat entry directly which DID have upload_date // Actually the flat entries include upload_date, so mapEntry will parse it expect(result[1]?.platformContentId).toBe('abc123xyz'); // Third item is enriched expect(result[2]?.publishedAt).toBe('2024-07-01T00:00:00Z'); }); it('uses correct yt-dlp args with custom limit', async () => { const channel = makeChannel(); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([]); await youtube.fetchRecentContent(channel, { limit: 10 }); expect(mockExecYtDlp).toHaveBeenCalledWith( ['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url], { timeout: 60_000 } ); }); it('uses defaults when no options provided', async () => { const channel = makeChannel(); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([]); await youtube.fetchRecentContent(channel); expect(mockExecYtDlp).toHaveBeenCalledWith( ['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url], { timeout: 60_000 } ); }); }); describe('publishedAt extraction', () => { it('extracts publishedAt from upload_date in YYYYMMDD format', async () => { const channel = makeChannel(); // Phase 1: flat discovery mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce(YOUTUBE_PLAYLIST_ENTRIES); // All items already exist — no enrichment needed const allIds = new Set(YOUTUBE_PLAYLIST_ENTRIES.map((e) => e.id)); const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds: allIds, rateLimitDelay: 0, }); // Flat entries include upload_date, so mapEntry parses them expect(result[0]?.publishedAt).toBe('2024-03-15T00:00:00Z'); expect(result[1]?.publishedAt).toBe('2024-06-20T00:00:00Z'); expect(result[2]?.publishedAt).toBe('2024-07-01T00:00:00Z'); }); it('returns null publishedAt when upload_date is missing', async () => { const channel = makeChannel(); const entryNoDate = [{ id: 'vid1', title: 'No Date', url: 'https://youtube.com/watch?v=vid1', duration: 100, live_status: null, }]; // Phase 1 mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce(entryNoDate); // Phase 2: enrich vid1 — return same data without upload_date mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseSingleJson.mockReturnValueOnce(entryNoDate[0]); const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds: new Set(), rateLimitDelay: 0, }); expect(result[0]?.publishedAt).toBeNull(); }); it('returns null publishedAt when upload_date is malformed', async () => { const channel = makeChannel(); const entryBadDate = [{ id: 'vid2', title: 'Bad Date', url: 'https://youtube.com/watch?v=vid2', duration: 200, live_status: null, upload_date: '2024', }]; // Phase 1 mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce(entryBadDate); // Phase 2: enrich vid2 — return same data with malformed upload_date mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseSingleJson.mockReturnValueOnce(entryBadDate[0]); const result = await youtube.fetchRecentContent(channel, { limit: 50, existingIds: new Set(), rateLimitDelay: 0, }); expect(result[0]?.publishedAt).toBeNull(); }); }); describe('fetchPlaylists', () => { it('returns playlist metadata with video mappings', async () => { const channel = makeChannel(); // First call: enumerate playlists from /playlists tab mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([ { id: 'PLabc123', title: 'Best Of 2024' }, { id: 'PLdef456', title: 'Tutorials' }, ]); // Second call: videos in first playlist mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([ { id: 'vid-a1' }, { id: 'vid-a2' }, { id: 'vid-a3' }, ]); // Third call: videos in second playlist mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([ { id: 'vid-b1' }, { id: 'vid-b2' }, ]); const result = await youtube.fetchPlaylists(channel); expect(result).toHaveLength(2); expect(result[0]).toEqual({ platformPlaylistId: 'PLabc123', title: 'Best Of 2024', videoIds: ['vid-a1', 'vid-a2', 'vid-a3'], }); expect(result[1]).toEqual({ platformPlaylistId: 'PLdef456', title: 'Tutorials', videoIds: ['vid-b1', 'vid-b2'], }); // Verify first call fetches playlists tab expect(mockExecYtDlp).toHaveBeenNthCalledWith( 1, ['--flat-playlist', '--dump-json', `${channel.url}/playlists`], { timeout: 60_000 } ); // Verify subsequent calls fetch individual playlist videos expect(mockExecYtDlp).toHaveBeenNthCalledWith( 2, ['--flat-playlist', '--dump-json', 'https://www.youtube.com/playlist?list=PLabc123'], { timeout: 60_000 } ); expect(mockExecYtDlp).toHaveBeenNthCalledWith( 3, ['--flat-playlist', '--dump-json', 'https://www.youtube.com/playlist?list=PLdef456'], { timeout: 60_000 } ); }); it('handles empty playlists tab', async () => { const channel = makeChannel(); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([]); const result = await youtube.fetchPlaylists(channel); expect(result).toEqual([]); expect(mockExecYtDlp).toHaveBeenCalledTimes(1); }); it('skips entries without playlist ID', async () => { const channel = makeChannel(); // Playlists tab returns one valid and one without id mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([ { id: 'PLvalid', title: 'Valid Playlist' }, { title: 'No ID Playlist' }, { id: '', title: 'Empty ID Playlist' }, ]); // Only the valid playlist triggers a video fetch mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([{ id: 'vid-1' }]); const result = await youtube.fetchPlaylists(channel); expect(result).toHaveLength(1); expect(result[0]).toEqual({ platformPlaylistId: 'PLvalid', title: 'Valid Playlist', videoIds: ['vid-1'], }); // 1 call for playlists tab + 1 call for the valid playlist's videos expect(mockExecYtDlp).toHaveBeenCalledTimes(2); }); it('uses "Untitled Playlist" when title is missing', async () => { const channel = makeChannel(); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([ { id: 'PLnoTitle' }, ]); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); mockParseJsonLines.mockReturnValueOnce([]); const result = await youtube.fetchPlaylists(channel); expect(result[0]?.title).toBe('Untitled Playlist'); }); }); }); describe('SoundCloudSource', () => { const soundcloud = new SoundCloudSource(); beforeEach(() => { vi.clearAllMocks(); }); describe('resolveChannel', () => { it('resolves artist metadata from a SoundCloud URL', async () => { mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, }); mockParseSingleJson.mockReturnValueOnce(SOUNDCLOUD_ARTIST_JSON); const result = await soundcloud.resolveChannel( 'https://soundcloud.com/deadmau5' ); expect(result).toEqual({ name: 'Deadmau5', platformId: 'deadmau5', imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg', url: 'https://soundcloud.com/deadmau5', platform: 'soundcloud', }); }); }); describe('fetchRecentContent', () => { it('fetches tracks with audio content type', async () => { const channel = makeChannel({ platform: Platform.SoundCloud, url: 'https://soundcloud.com/deadmau5', }); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, }); mockParseJsonLines.mockReturnValueOnce(SOUNDCLOUD_TRACK_ENTRIES); const result = await soundcloud.fetchRecentContent(channel, { limit: 20 }); expect(result).toHaveLength(3); // All SoundCloud entries should be audio type result.forEach((item) => { expect(item.contentType).toBe('audio'); }); expect(result[0]).toEqual({ platformContentId: 'sc-track-001', title: 'Strobe (Club Edit)', url: 'https://soundcloud.com/deadmau5/strobe-club-edit', contentType: 'audio', duration: 421, thumbnailUrl: 'https://i1.sndcdn.com/artworks-track1.jpg', publishedAt: null, }); // Entry with thumbnails array expect(result[1]?.thumbnailUrl).toBe( 'https://i1.sndcdn.com/artworks-track2-lg.jpg' ); // Entry with null thumbnail expect(result[2]?.thumbnailUrl).toBeNull(); }); it('passes --sleep-requests 2 for rate limit mitigation', async () => { const channel = makeChannel({ platform: Platform.SoundCloud, url: 'https://soundcloud.com/deadmau5', }); mockExecYtDlp.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, }); mockParseJsonLines.mockReturnValueOnce([]); await soundcloud.fetchRecentContent(channel, { limit: 50 }); expect(mockExecYtDlp).toHaveBeenCalledWith( expect.arrayContaining(['--sleep-requests', '2']), expect.any(Object) ); }); }); }); describe('PlatformRegistry', () => { let registry: PlatformRegistry; const youtube = new YouTubeSource(); const soundcloud = new SoundCloudSource(); beforeEach(() => { registry = new PlatformRegistry(); registry.register(Platform.YouTube, youtube); registry.register(Platform.SoundCloud, soundcloud); }); describe('get', () => { it('returns registered source for a platform', () => { expect(registry.get(Platform.YouTube)).toBe(youtube); expect(registry.get(Platform.SoundCloud)).toBe(soundcloud); }); it('returns undefined for unregistered platform', () => { const empty = new PlatformRegistry(); expect(empty.get(Platform.YouTube)).toBeUndefined(); }); }); describe('getForUrl', () => { it('resolves YouTube @handle URLs', () => { const result = registry.getForUrl( 'https://www.youtube.com/@LinusTechTips' ); expect(result?.platform).toBe('youtube'); expect(result?.source).toBe(youtube); }); it('resolves YouTube /channel/ URLs', () => { const result = registry.getForUrl( 'https://www.youtube.com/channel/UC123' ); expect(result?.platform).toBe('youtube'); }); it('resolves YouTube /c/ URLs', () => { const result = registry.getForUrl( 'https://www.youtube.com/c/LinusTechTips' ); expect(result?.platform).toBe('youtube'); }); it('resolves YouTube /user/ URLs', () => { const result = registry.getForUrl( 'https://www.youtube.com/user/LinusTechTips' ); expect(result?.platform).toBe('youtube'); }); it('resolves youtu.be short URLs', () => { const result = registry.getForUrl('https://youtu.be/dQw4w9WgXcQ'); expect(result?.platform).toBe('youtube'); }); it('resolves SoundCloud artist URLs', () => { const result = registry.getForUrl('https://soundcloud.com/deadmau5'); expect(result?.platform).toBe('soundcloud'); expect(result?.source).toBe(soundcloud); }); it('rejects SoundCloud track URLs', () => { const result = registry.getForUrl( 'https://soundcloud.com/deadmau5/tracks/strobe' ); expect(result).toBeNull(); }); it('rejects SoundCloud set URLs', () => { const result = registry.getForUrl( 'https://soundcloud.com/deadmau5/sets/album' ); expect(result).toBeNull(); }); it('returns null for unknown URLs', () => { expect(registry.getForUrl('https://example.com')).toBeNull(); expect(registry.getForUrl('https://spotify.com/artist/123')).toBeNull(); }); it('returns null when platform has no registered source', () => { const emptyRegistry = new PlatformRegistry(); const result = emptyRegistry.getForUrl( 'https://www.youtube.com/@Test' ); expect(result).toBeNull(); }); }); }); describe('Error propagation', () => { const youtube = new YouTubeSource(); const soundcloud = new SoundCloudSource(); beforeEach(() => { vi.clearAllMocks(); }); it('YouTube resolveChannel propagates yt-dlp errors', async () => { const error = new Error('yt-dlp failed'); mockExecYtDlp.mockRejectedValueOnce(error); await expect( youtube.resolveChannel('https://www.youtube.com/@Bad') ).rejects.toThrow('yt-dlp failed'); }); it('SoundCloud resolveChannel propagates yt-dlp errors', async () => { const error = new Error('yt-dlp failed'); mockExecYtDlp.mockRejectedValueOnce(error); await expect( soundcloud.resolveChannel('https://soundcloud.com/bad') ).rejects.toThrow('yt-dlp failed'); }); it('YouTube fetchRecentContent propagates yt-dlp errors', async () => { const channel = makeChannel(); const error = new Error('network error'); mockExecYtDlp.mockRejectedValueOnce(error); await expect(youtube.fetchRecentContent(channel)).rejects.toThrow( 'network error' ); }); it('SoundCloud fetchRecentContent propagates yt-dlp errors', async () => { const channel = makeChannel({ platform: Platform.SoundCloud, url: 'https://soundcloud.com/artist', }); const error = new Error('timeout'); mockExecYtDlp.mockRejectedValueOnce(error); await expect(soundcloud.fetchRecentContent(channel)).rejects.toThrow( 'timeout' ); }); });