diff --git a/Dockerfile b/Dockerfile index 10ad50d..c1dc648 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:lts-alpine as builder WORKDIR /metube COPY ui ./ -RUN npm ci && \ +RUN npm install && \ node_modules/.bin/ng build --configuration production diff --git a/ui/package-lock.json b/ui/package-lock.json index 49313c3..c70873f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,7 +24,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.0", "@fortawesome/free-solid-svg-icons": "^6.7.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0", - "@ng-select/ng-select": "^14.0.0", + "@ng-select/ng-select": "^14.9.0", "bootstrap": "^5.3.6", "ngx-cookie-service": "^19.0.0", "ngx-socket-io": "~4.8.0", diff --git a/ui/package.json b/ui/package.json index 6247a07..3cad8ff 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,7 +27,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.0", "@fortawesome/free-solid-svg-icons": "^6.7.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0", - "@ng-select/ng-select": "^14.0.0", + "@ng-select/ng-select": "^14.9.0", "bootstrap": "^5.3.6", "ngx-cookie-service": "^19.0.0", "ngx-socket-io": "~4.8.0", diff --git a/ui/src/app/batch-import-core.spec.ts b/ui/src/app/batch-import-core.spec.ts new file mode 100644 index 0000000..e432f83 --- /dev/null +++ b/ui/src/app/batch-import-core.spec.ts @@ -0,0 +1,190 @@ +// Core batch import logic tests - isolated from Angular dependencies +describe('Batch Import Core Logic', () => { + + // URL validation function (extracted from our component logic) + function validateUrls(urlText: string) { + const urls = urlText + .split(/\r?\n/) + .map(url => url.trim()) + .filter(url => url.length > 0); + + const parsedUrlCount = urls.length; + + if (urls.length === 0) { + return { valid: [], invalid: [], duplicates: [], parsedUrlCount }; + } + + // Basic URL validation regex + const urlRegex = /^https?:\/\/.+/i; + const valid: string[] = []; + const invalid: string[] = []; + const seen = new Set(); + const duplicates: string[] = []; + + urls.forEach(url => { + if (seen.has(url)) { + if (!duplicates.includes(url)) { + duplicates.push(url); + } + return; + } + seen.add(url); + + if (urlRegex.test(url)) { + valid.push(url); + } else { + invalid.push(url); + } + }); + + return { valid, invalid, duplicates, parsedUrlCount }; + } + + describe('URL Validation Logic', () => { + it('should handle empty input', () => { + const result = validateUrls(''); + expect(result.parsedUrlCount).toBe(0); + expect(result.valid).toEqual([]); + expect(result.invalid).toEqual([]); + expect(result.duplicates).toEqual([]); + }); + + it('should validate single valid URL', () => { + const result = validateUrls('https://youtube.com/watch?v=test'); + expect(result.parsedUrlCount).toBe(1); + expect(result.valid).toEqual(['https://youtube.com/watch?v=test']); + expect(result.invalid).toEqual([]); + expect(result.duplicates).toEqual([]); + }); + + it('should detect invalid URLs', () => { + const result = validateUrls('invalid-url\nnot-a-url'); + expect(result.parsedUrlCount).toBe(2); + expect(result.valid).toEqual([]); + expect(result.invalid).toEqual(['invalid-url', 'not-a-url']); + expect(result.duplicates).toEqual([]); + }); + + it('should detect duplicate URLs', () => { + const urls = [ + 'https://youtube.com/watch?v=test', + 'https://youtube.com/watch?v=test', + 'https://youtube.com/watch?v=unique' + ].join('\n'); + + const result = validateUrls(urls); + expect(result.parsedUrlCount).toBe(3); + expect(result.valid).toEqual([ + 'https://youtube.com/watch?v=test', + 'https://youtube.com/watch?v=unique' + ]); + expect(result.invalid).toEqual([]); + expect(result.duplicates).toEqual(['https://youtube.com/watch?v=test']); + }); + + it('should handle mixed valid, invalid, and duplicate URLs', () => { + const urls = [ + 'https://youtube.com/watch?v=test1', + 'https://youtube.com/watch?v=test1', // duplicate + 'invalid-url', + 'https://youtu.be/test2', + 'not-a-url' + ].join('\n'); + + const result = validateUrls(urls); + expect(result.parsedUrlCount).toBe(5); + expect(result.valid).toEqual([ + 'https://youtube.com/watch?v=test1', + 'https://youtu.be/test2' + ]); + expect(result.invalid).toEqual(['invalid-url', 'not-a-url']); + expect(result.duplicates).toEqual(['https://youtube.com/watch?v=test1']); + }); + + it('should handle whitespace and empty lines', () => { + const urls = ' https://youtube.com/test \n\n \nhttps://youtu.be/test2\n\n'; + const result = validateUrls(urls); + + expect(result.parsedUrlCount).toBe(2); + expect(result.valid).toEqual([ + 'https://youtube.com/test', + 'https://youtu.be/test2' + ]); + }); + + it('should accept various valid URL formats', () => { + const validUrls = [ + 'https://youtube.com/watch?v=test', + 'http://youtube.com/watch?v=test', + 'https://youtu.be/test', + 'https://www.youtube.com/playlist?list=test', + 'https://github.com/user/repo', + 'https://example.com/path/to/resource' + ]; + + validUrls.forEach(url => { + const result = validateUrls(url); + expect(result.valid).toContain(url, `${url} should be valid`); + expect(result.invalid).not.toContain(url, `${url} should not be invalid`); + }); + }); + + it('should reject invalid URL formats', () => { + const invalidUrls = [ + 'youtube.com/watch?v=test', // missing protocol + 'ftp://youtube.com/test', // wrong protocol + 'not-a-url', + 'just-text', + 'www.example.com' // missing protocol + ]; + + invalidUrls.forEach(url => { + const result = validateUrls(url); + expect(result.invalid).toContain(url, `${url} should be invalid`); + expect(result.valid).not.toContain(url, `${url} should not be valid`); + }); + }); + + it('should handle performance with large number of URLs', () => { + const urls = Array.from({ length: 1000 }, (_, i) => `https://youtube.com/watch?v=test${i}`); + const urlText = urls.join('\n'); + + const startTime = performance.now(); + const result = validateUrls(urlText); + const endTime = performance.now(); + + expect(endTime - startTime).toBeLessThan(100); // Should be fast + expect(result.valid.length).toBe(1000); + expect(result.parsedUrlCount).toBe(1000); + }); + }); + + describe('Real-world URL Testing', () => { + it('should validate actual YouTube URLs', () => { + const urls = [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtu.be/jNQXAC9IVRw', + 'https://youtube.com/playlist?list=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI' + ].join('\n'); + + const result = validateUrls(urls); + expect(result.valid.length).toBe(3); + expect(result.invalid.length).toBe(0); + }); + + it('should handle mixed real URLs with invalid ones', () => { + const urls = [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'invalid-youtube-url', + 'https://github.com/alexta69/metube', + 'just-plain-text', + 'https://youtu.be/jNQXAC9IVRw' + ].join('\n'); + + const result = validateUrls(urls); + expect(result.valid.length).toBe(3); + expect(result.invalid.length).toBe(2); + expect(result.invalid).toEqual(['invalid-youtube-url', 'just-plain-text']); + }); + }); +}); \ No newline at end of file diff --git a/ui/src/app/batch-import.spec.ts b/ui/src/app/batch-import.spec.ts new file mode 100644 index 0000000..a8c9a3e --- /dev/null +++ b/ui/src/app/batch-import.spec.ts @@ -0,0 +1,228 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { AppComponent } from './app.component'; +import { DownloadsService } from './downloads.service'; +import { CookieService } from 'ngx-cookie-service'; +import { MeTubeSocket } from './metube-socket'; +import { of } from 'rxjs'; +import { + MockSpeedPipe, + MockEtaPipe, + MockFileSizePipe, + MockMasterCheckboxComponent, + MockSlaveCheckboxComponent +} from './test-utils'; + +describe('Batch Import Functionality', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let downloadsServiceSpy: jasmine.SpyObj; + let cookieServiceSpy: jasmine.SpyObj; + let socketSpy: jasmine.SpyObj; + + beforeEach(async () => { + const downloadsSpy = jasmine.createSpyObj('DownloadsService', ['add'], { + loading: false, + queue: new Map(), + done: new Map(), + queueChanged: of(null), + doneChanged: of(null), + updated: of(null), + configurationChanged: of({}), + customDirsChanged: of({ download_dir: [], audio_download_dir: [] }), + ytdlOptionsChanged: of({}), + configuration: {} + }); + const cookieSpy = jasmine.createSpyObj('CookieService', ['get', 'set', 'check']); + const metubeSocketSpy = jasmine.createSpyObj('MeTubeSocket', ['fromEvent']); + + await TestBed.configureTestingModule({ + declarations: [ + AppComponent, + MockSpeedPipe, + MockEtaPipe, + MockFileSizePipe, + MockMasterCheckboxComponent, + MockSlaveCheckboxComponent + ], + imports: [ + FormsModule, + HttpClientTestingModule, + NgbModule, + FontAwesomeModule, + NgSelectModule + ], + providers: [ + { provide: DownloadsService, useValue: downloadsSpy }, + { provide: CookieService, useValue: cookieSpy }, + { provide: MeTubeSocket, useValue: metubeSocketSpy } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + downloadsServiceSpy = TestBed.inject(DownloadsService) as jasmine.SpyObj; + cookieServiceSpy = TestBed.inject(CookieService) as jasmine.SpyObj; + socketSpy = TestBed.inject(MeTubeSocket) as jasmine.SpyObj; + + // Set up socket mock returns + socketSpy.fromEvent.and.returnValue(of({})); + }); + + describe('Core Batch Import Logic', () => { + it('should create component successfully', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize batch import properties', () => { + expect(component.batchImportModalOpen).toBe(false); + expect(component.batchImportText).toBe(''); + expect(component.batchImportStatus).toBe(''); + expect(component.importInProgress).toBe(false); + expect(component.cancelImportFlag).toBe(false); + expect(component.urlValidationResults).toEqual({ valid: [], invalid: [], duplicates: [] }); + expect(component.parsedUrlCount).toBe(0); + }); + + it('should open batch import modal correctly', () => { + component.openBatchImportModal(); + + expect(component.batchImportModalOpen).toBe(true); + expect(component.batchImportText).toBe(''); + expect(component.batchImportStatus).toBe(''); + expect(component.importInProgress).toBe(false); + expect(component.cancelImportFlag).toBe(false); + expect(component.urlValidationResults).toEqual({ valid: [], invalid: [], duplicates: [] }); + expect(component.parsedUrlCount).toBe(0); + }); + + it('should validate URLs correctly', () => { + const testUrls = [ + 'https://youtube.com/watch?v=test1', + 'https://youtube.com/watch?v=test1', // duplicate + 'invalid-url', + 'https://youtu.be/test2' + ].join('\n'); + + component.batchImportText = testUrls; + component.validateBatchUrls(); + + expect(component.parsedUrlCount).toBe(4); + expect(component.urlValidationResults.valid).toEqual([ + 'https://youtube.com/watch?v=test1', + 'https://youtu.be/test2' + ]); + expect(component.urlValidationResults.invalid).toEqual(['invalid-url']); + expect(component.urlValidationResults.duplicates).toEqual(['https://youtube.com/watch?v=test1']); + }); + + it('should handle empty URL input', () => { + component.batchImportText = ''; + component.validateBatchUrls(); + + expect(component.parsedUrlCount).toBe(0); + expect(component.urlValidationResults).toEqual({ valid: [], invalid: [], duplicates: [] }); + }); + + it('should handle whitespace and empty lines', () => { + component.batchImportText = ' https://youtube.com/test \n\n \nhttps://youtu.be/test2\n\n'; + component.validateBatchUrls(); + + expect(component.parsedUrlCount).toBe(2); + expect(component.urlValidationResults.valid).toEqual([ + 'https://youtube.com/test', + 'https://youtu.be/test2' + ]); + }); + + it('should reject invalid URL formats', () => { + const invalidUrls = [ + 'youtube.com/watch?v=test', // missing protocol + 'ftp://youtube.com/test', // wrong protocol + 'not-a-url', + 'just-text' + ]; + + component.batchImportText = invalidUrls.join('\n'); + component.validateBatchUrls(); + + expect(component.urlValidationResults.valid).toEqual([]); + expect(component.urlValidationResults.invalid).toEqual(invalidUrls); + }); + }); + + describe('Import Process Logic', () => { + beforeEach(() => { + downloadsServiceSpy.add.and.returnValue(of({ status: 'ok' })); + }); + + it('should not start import with no valid URLs', () => { + component.batchImportText = 'invalid-url\nnot-a-url'; + spyOn(window, 'alert'); + + component.startBatchImport(); + + expect(window.alert).toHaveBeenCalledWith('No valid URLs found. Please check your URLs and try again.'); + expect(component.importInProgress).toBe(false); + }); + + it('should show confirmation dialog with mixed URLs', () => { + component.batchImportText = 'https://youtube.com/test\ninvalid-url'; + spyOn(window, 'confirm').and.returnValue(false); + + component.startBatchImport(); + + expect(window.confirm).toHaveBeenCalledWith('Found 1 invalid URLs that will be skipped. Continue with 1 valid URLs?'); + expect(component.importInProgress).toBe(false); + }); + + it('should start import with valid URLs only', () => { + component.batchImportText = 'https://youtube.com/test1\nhttps://youtube.com/test2'; + + component.startBatchImport(); + + expect(component.importInProgress).toBe(true); + expect(component.batchImportStatus).toBe('Starting to import 2 URLs...'); + }); + + it('should handle cancellation correctly', () => { + component.importInProgress = true; + component.batchImportStatus = 'Importing...'; + + component.cancelBatchImport(); + + expect(component.cancelImportFlag).toBe(true); + expect(component.batchImportStatus).toBe('Importing... Cancelling...'); + }); + }); + + describe('URL Validation Edge Cases', () => { + it('should handle large number of URLs', () => { + const urls = Array.from({ length: 100 }, (_, i) => `https://youtube.com/watch?v=test${i}`); + component.batchImportText = urls.join('\n'); + + const startTime = performance.now(); + component.validateBatchUrls(); + const endTime = performance.now(); + + expect(endTime - startTime).toBeLessThan(50); // Should be fast + expect(component.urlValidationResults.valid.length).toBe(100); + expect(component.parsedUrlCount).toBe(100); + }); + + it('should detect multiple duplicates correctly', () => { + const duplicateUrl = 'https://youtube.com/watch?v=duplicate'; + const urls = [duplicateUrl, duplicateUrl, duplicateUrl, 'https://youtube.com/watch?v=unique']; + component.batchImportText = urls.join('\n'); + + component.validateBatchUrls(); + + expect(component.urlValidationResults.valid.length).toBe(2); // duplicate + unique + expect(component.urlValidationResults.duplicates).toEqual([duplicateUrl]); + }); + }); +}); \ No newline at end of file diff --git a/ui/src/app/test-utils.ts b/ui/src/app/test-utils.ts new file mode 100644 index 0000000..1910cbe --- /dev/null +++ b/ui/src/app/test-utils.ts @@ -0,0 +1,45 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +// Mock pipes for testing +@Pipe({ name: 'speed' }) +export class MockSpeedPipe implements PipeTransform { + transform(value: any): string { + return value ? `${value} MB/s` : ''; + } +} + +@Pipe({ name: 'eta' }) +export class MockEtaPipe implements PipeTransform { + transform(value: any): string { + return value ? `${value}s` : ''; + } +} + +@Pipe({ name: 'fileSize' }) +export class MockFileSizePipe implements PipeTransform { + transform(value: any): string { + return value ? `${value} MB` : ''; + } +} + +// Mock components for testing +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-master-checkbox', + template: '' +}) +export class MockMasterCheckboxComponent { + @Input() id: string = ''; + @Input() list: any; +} + +@Component({ + selector: 'app-slave-checkbox', + template: '' +}) +export class MockSlaveCheckboxComponent { + @Input() id: string = ''; + @Input() master: any; + @Input() checkable: any; +} \ No newline at end of file