tubearr/src/__tests__/sources.test.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

880 lines
28 KiB
TypeScript

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<typeof vi.fn>;
const mockParseSingleJson = parseSingleJson as ReturnType<typeof vi.fn>;
const mockParseJsonLines = parseJsonLines as ReturnType<typeof vi.fn>;
// ── 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> = {}): 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'
);
});
});