import { describe, it, expect } from 'vitest' import { classifySource, buildMediaItems, mergeItemsPreservingState, buildFeedUrl, isYouTubeFeedUrl, parseRssVideoIds, entryUrl, fmtDuration, stripTabSuffix, stableSourceId, type RawEntry } from '../src/main/indexerCore' import type { MediaItem } from '@shared/ipc' describe('classifySource', () => { it('classifies channel handle / id / c / user forms and strips the tab', () => { expect(classifySource('https://www.youtube.com/@LinusTechTips')).toEqual({ kind: 'channel', base: 'https://www.youtube.com/@LinusTechTips' }) expect(classifySource('https://youtube.com/@Foo/videos')).toEqual({ kind: 'channel', base: 'https://www.youtube.com/@Foo' }) expect(classifySource('https://www.youtube.com/channel/UC123/playlists')?.base).toBe( 'https://www.youtube.com/channel/UC123' ) expect(classifySource('https://www.youtube.com/c/SomeName')?.kind).toBe('channel') expect(classifySource('https://www.youtube.com/user/Legacy')?.kind).toBe('channel') }) it('classifies a playlist by its list= param and canonicalises it', () => { expect(classifySource('https://www.youtube.com/playlist?list=PL_abc')).toEqual({ kind: 'playlist', base: 'https://www.youtube.com/playlist?list=PL_abc' }) // a watch URL that also carries list= is treated as the playlist expect(classifySource('https://www.youtube.com/watch?v=xyz&list=PLfoo')?.kind).toBe('playlist') }) it('returns null for a lone video, a bad URL, or a non-YouTube host', () => { expect(classifySource('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull() expect(classifySource('not a url')).toBeNull() expect(classifySource('ftp://example.com/@chan')).toBeNull() expect(classifySource('https://vimeo.com/channels/staffpicks')).toBeNull() }) }) describe('entryUrl', () => { it('prefers an explicit http(s) url, else builds a watch url from the id', () => { expect(entryUrl({ url: 'https://youtu.be/abc' })).toBe('https://youtu.be/abc') expect(entryUrl({ webpage_url: 'https://x/y' })).toBe('https://x/y') expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123') }) it('rejects a non-http url and returns null when nothing usable is present', () => { expect(entryUrl({ url: 'javascript:alert(1)' })).toBeNull() expect(entryUrl({})).toBeNull() }) it('percent-encodes an untrusted id rather than splicing it raw (audit T2)', () => { expect(entryUrl({ id: 'ab cd&x=1' })).toBe('https://www.youtube.com/watch?v=ab%20cd%26x%3D1') expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123') // unchanged }) }) describe('fmtDuration', () => { it('formats seconds as M:SS or H:MM:SS', () => { expect(fmtDuration(0)).toBe('0:00') expect(fmtDuration(75)).toBe('1:15') expect(fmtDuration(3661)).toBe('1:01:01') expect(fmtDuration(undefined)).toBeUndefined() expect(fmtDuration(NaN)).toBeUndefined() }) }) describe('stripTabSuffix', () => { it('strips a trailing " - " suffix only', () => { expect(stripTabSuffix('Creator - Videos')).toBe('Creator') expect(stripTabSuffix('Creator - Playlists')).toBe('Creator') expect(stripTabSuffix('Just A Name')).toBe('Just A Name') expect(stripTabSuffix('Tech - Tips')).toBe('Tech - Tips') // 'Tips' isn't a tab expect(stripTabSuffix(undefined)).toBeUndefined() }) }) describe('stableSourceId', () => { it('is deterministic and 8 hex chars', () => { const a = stableSourceId('https://www.youtube.com/@Foo') expect(a).toMatch(/^[0-9a-f]{8}$/) expect(stableSourceId('https://www.youtube.com/@Foo')).toBe(a) expect(stableSourceId('https://www.youtube.com/@Bar')).not.toBe(a) }) }) describe('buildMediaItems', () => { const vids = (...ids: string[]): RawEntry[] => ids.map((id) => ({ id, title: `T-${id}` })) it('files videos under their playlist with 1-based per-playlist index', () => { const items = buildMediaItems('src1', [{ title: 'Series A', entries: vids('a', 'b') }], []) expect(items).toHaveLength(2) expect(items[0]).toMatchObject({ id: 'src1:a', sourceId: 'src1', videoId: 'a', playlistTitle: 'Series A', playlistIndex: 1, downloaded: false }) expect(items[1]).toMatchObject({ videoId: 'b', playlistIndex: 2 }) }) it('dedups across playlists — the first playlist a video appears in wins', () => { const items = buildMediaItems( 'src1', [ { title: 'First', entries: vids('x', 'y') }, { title: 'Second', entries: vids('y', 'z') } ], [] ) expect(items.map((i) => i.videoId)).toEqual(['x', 'y', 'z']) expect(items.find((i) => i.videoId === 'y')?.playlistTitle).toBe('First') }) it('falls back to the synthetic Uploads folder for videos in no playlist', () => { const items = buildMediaItems('src1', [{ title: 'P', entries: vids('a') }], vids('a', 'b')) // 'a' already filed under P; only 'b' becomes an Uploads item expect(items).toHaveLength(2) expect(items.find((i) => i.videoId === 'a')?.playlistTitle).toBe('P') const b = items.find((i) => i.videoId === 'b') expect(b).toMatchObject({ playlistTitle: 'Uploads', playlistIndex: 2 }) }) it('skips entries with no id or no resolvable URL', () => { const items = buildMediaItems('src1', [{ title: 'P', entries: [{ title: 'no id' }] }], []) expect(items).toHaveLength(0) }) }) describe('mergeItemsPreservingState', () => { const item = (videoId: string, over: Partial = {}): MediaItem => ({ id: `s:${videoId}`, sourceId: 's', videoId, title: videoId, url: `https://youtube.com/watch?v=${videoId}`, playlistTitle: 'P', playlistIndex: 1, downloaded: false, ...over }) it('carries downloaded state forward for videos already on disk', () => { const existing = [item('a', { downloaded: true, downloadedAt: 111, filePath: 'C:/a.mp4' })] const fresh = [item('a'), item('b')] const { items, newCount } = mergeItemsPreservingState(existing, fresh) expect(newCount).toBe(1) // only 'b' is new expect(items.find((i) => i.videoId === 'a')).toMatchObject({ downloaded: true, downloadedAt: 111, filePath: 'C:/a.mp4' }) expect(items.find((i) => i.videoId === 'b')?.downloaded).toBe(false) }) it('adopts the fresh membership/order and drops vanished videos', () => { const existing = [item('a', { downloaded: true }), item('gone', { downloaded: true })] const fresh = [item('a', { playlistTitle: 'Moved', playlistIndex: 5 })] const { items, newCount } = mergeItemsPreservingState(existing, fresh) expect(items).toHaveLength(1) // 'gone' dropped expect(newCount).toBe(0) // fresh playlist assignment wins, but downloaded state is preserved expect(items[0]).toMatchObject({ playlistTitle: 'Moved', playlistIndex: 5, downloaded: true }) }) it('counts every video as new on a first index (empty existing)', () => { const { newCount } = mergeItemsPreservingState([], [item('a'), item('b'), item('c')]) expect(newCount).toBe(3) }) }) describe('buildFeedUrl', () => { it('builds a channel feed by channel_id and a playlist feed by playlist_id', () => { expect(buildFeedUrl('channel', 'UCabc')).toBe( 'https://www.youtube.com/feeds/videos.xml?channel_id=UCabc' ) expect(buildFeedUrl('playlist', 'PLxyz')).toBe( 'https://www.youtube.com/feeds/videos.xml?playlist_id=PLxyz' ) }) it('returns undefined when the id is missing', () => { expect(buildFeedUrl('channel', undefined)).toBeUndefined() }) }) describe('isYouTubeFeedUrl (audit T7 — SSRF guard)', () => { it('accepts a youtube feeds URL (www optional) and what buildFeedUrl produces', () => { expect(isYouTubeFeedUrl('https://www.youtube.com/feeds/videos.xml?channel_id=UCabc')).toBe(true) expect(isYouTubeFeedUrl('https://youtube.com/feeds/videos.xml?playlist_id=PLxyz')).toBe(true) expect(isYouTubeFeedUrl(buildFeedUrl('channel', 'UCabc')!)).toBe(true) expect(isYouTubeFeedUrl(buildFeedUrl('playlist', 'PLxyz')!)).toBe(true) }) it('rejects internal/arbitrary hosts, wrong scheme, and wrong path (SSRF vectors)', () => { expect(isYouTubeFeedUrl('http://169.254.169.254/latest/meta-data/')).toBe(false) // cloud metadata expect(isYouTubeFeedUrl('http://localhost:8080/admin')).toBe(false) expect(isYouTubeFeedUrl('https://evil.com/feeds/videos.xml')).toBe(false) expect(isYouTubeFeedUrl('https://notyoutube.com.evil.com/feeds/videos.xml')).toBe(false) expect(isYouTubeFeedUrl('http://www.youtube.com/feeds/videos.xml')).toBe(false) // not https expect(isYouTubeFeedUrl('https://www.youtube.com/watch?v=x')).toBe(false) // wrong path expect(isYouTubeFeedUrl('file:///etc/passwd')).toBe(false) expect(isYouTubeFeedUrl('not a url')).toBe(false) }) }) describe('parseRssVideoIds', () => { it('extracts every from a feed body, in order', () => { const xml = ` aaa111 bbb222 ` expect(parseRssVideoIds(xml)).toEqual(['aaa111', 'bbb222']) }) it('returns an empty array for a feed with no entries', () => { expect(parseRssVideoIds('')).toEqual([]) }) })