import { TestBed } from '@angular/core/testing'; import { HttpClient } from '@angular/common/http'; import { Subject, of } from 'rxjs'; import { App } from './app'; import { DownloadsService } from './services/downloads.service'; import { SubscriptionsService } from './services/subscriptions.service'; import { ToastService } from './services/toast.service'; import { CookieService } from 'ngx-cookie-service'; class DownloadsServiceStub { loading = false; queue = new Map(); done = new Map(); configuration: Record = { CUSTOM_DIRS: true, CREATE_CUSTOM_DIRS: true, ALLOW_YTDL_OPTIONS_OVERRIDES: false }; customDirs = { download_dir: [], audio_download_dir: [] }; queueChanged = new Subject(); doneChanged = new Subject(); configurationChanged = new Subject>(); customDirsChanged = new Subject>(); ytdlOptionsChanged = new Subject>(); updated = new Subject(); getCookieStatus() { return of({ status: 'ok', has_cookies: false }); } getPresets() { return of({ presets: ['Preset A'] }); } add() { return of({ status: 'ok' as const }); } cancelAdd() { return of({ status: 'ok' as const }); } startById() { return of({}); } delById() { return of({}); } delByFilter() { return of({}); } startByFilter() { return of({}); } uploadCookies() { return of({ status: 'ok' }); } deleteCookies() { return of({ status: 'ok' }); } } class SubscriptionsServiceStub { subscriptions = new Map(); subscriptionsChanged = new Subject(); subscribeCalls: unknown[] = []; subscribe(payload: unknown) { this.subscribeCalls.push(payload); return of({ status: 'ok' as const }); } delete() { return of({}); } update() { return of({ status: 'ok' as const }); } refreshList() { return of([]); } } class CookieServiceStub { private cookies = new Map(); get(name: string) { return this.cookies.get(name) ?? ''; } set(name: string, value: string) { this.cookies.set(name, value); } check(name: string) { return this.cookies.has(name); } } describe('App', () => { let downloads: DownloadsServiceStub; beforeEach(async () => { Object.defineProperty(window, 'matchMedia', { writable: true, enumerable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); downloads = new DownloadsServiceStub(); await TestBed.configureTestingModule({ imports: [App], providers: [ { provide: DownloadsService, useValue: downloads }, { provide: SubscriptionsService, useClass: SubscriptionsServiceStub }, { provide: CookieService, useClass: CookieServiceStub }, { provide: HttpClient, useValue: { get: vi.fn().mockReturnValue(of({ 'yt-dlp': 'test', version: 'test' })), }, }, ], }).compileComponents(); }); it('should create the app', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; expect(app).toBeTruthy(); }); it('hides manual override input when disabled', () => { const fixture = TestBed.createComponent(App); fixture.componentInstance.isAdvancedOpen = true; fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).toBeNull(); const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12'); expect(presetWrapper?.classList.contains('col-md-6')).toBe(false); const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row'); expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull(); }); it('shows manual override input when enabled', () => { downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] = true; const fixture = TestBed.createComponent(App); fixture.componentInstance.isAdvancedOpen = true; fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull(); const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12'); expect(presetWrapper?.classList.contains('col-md-6')).toBe(true); const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row'); expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull(); expect(presetRow?.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull(); }); it('does not submit manual overrides when disabled', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; app.ytdlOptionsOverrides = '{"exec":"echo hi"}'; const payload = app['buildAddPayload'](); expect(payload.ytdlOptionsOverrides).toBe(''); }); it('shows waiting badge for scheduled live stream', () => { downloads.queue.set('https://example.com/live', { id: 'live1', title: 'Upcoming Stream', url: 'https://example.com/live', download_type: 'video', quality: 'best', format: 'any', folder: '', custom_name_prefix: '', playlist_item_limit: 0, status: 'scheduled', live_status: 'is_upcoming', live_release_timestamp: Date.now() / 1000 + 3600, msg: '', percent: 0, speed: 0, eta: 0, filename: '', checked: false, }); downloads.queueChanged.next(); const fixture = TestBed.createComponent(App); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; expect(root.textContent).toContain('Waiting for stream'); expect(root.textContent).toContain('starts in'); }); it('includes titleRegex in subscribe payload', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub; app.addUrl = 'https://example.com/channel'; app.titleRegex = 'EPISODE'; app.addSubscription(); expect(subs.subscribeCalls.length).toBe(1); const payload = subs.subscribeCalls[0] as { titleRegex: string; skipSubscriberOnly: boolean }; expect(payload.titleRegex).toBe('EPISODE'); expect(payload.skipSubscriberOnly).toBe(false); }); it('includes skipSubscriberOnly true when checked', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub; app.addUrl = 'https://example.com/channel'; app.skipSubscriberOnly = true; app.addSubscription(); expect(subs.subscribeCalls.length).toBe(1); const payload = subs.subscribeCalls[0] as { skipSubscriberOnly: boolean }; expect(payload.skipSubscriberOnly).toBe(true); }); it('omits clip fields from subscribe payload', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub; app.addUrl = 'https://example.com/channel'; app.clipStart = '1:00'; app.clipEnd = '2:00'; app.addSubscription(); expect(subs.subscribeCalls.length).toBe(1); const payload = subs.subscribeCalls[0] as Record; expect('clipStart' in payload).toBe(false); expect('clipEnd' in payload).toBe(false); }); it('buildAddPayload includes clip times', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; app.clipStart = '0:10'; app.clipEnd = '1:20'; const payload = app['buildAddPayload'](); expect(payload.clipStart).toBe('0:10'); expect(payload.clipEnd).toBe('1:20'); }); it('blocks subscribe with invalid title regex', () => { const toasts = TestBed.inject(ToastService); const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined); const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub; app.addUrl = 'https://example.com/channel'; app.titleRegex = '['; app.addSubscription(); expect(subs.subscribeCalls.length).toBe(0); expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)'); errorSpy.mockRestore(); }); });