added tests for batch import functionality

This commit is contained in:
n g 2025-08-26 01:04:15 -07:00
parent 0f4ce609da
commit 0aeb470793
6 changed files with 466 additions and 3 deletions

View file

@ -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

2
ui/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<string>();
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']);
});
});
});

View file

@ -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<AppComponent>;
let downloadsServiceSpy: jasmine.SpyObj<DownloadsService>;
let cookieServiceSpy: jasmine.SpyObj<CookieService>;
let socketSpy: jasmine.SpyObj<MeTubeSocket>;
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<DownloadsService>;
cookieServiceSpy = TestBed.inject(CookieService) as jasmine.SpyObj<CookieService>;
socketSpy = TestBed.inject(MeTubeSocket) as jasmine.SpyObj<MeTubeSocket>;
// 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]);
});
});
});

45
ui/src/app/test-utils.ts Normal file
View file

@ -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: '<input type="checkbox" [id]="id">'
})
export class MockMasterCheckboxComponent {
@Input() id: string = '';
@Input() list: any;
}
@Component({
selector: 'app-slave-checkbox',
template: '<input type="checkbox" [id]="id">'
})
export class MockSlaveCheckboxComponent {
@Input() id: string = '';
@Input() master: any;
@Input() checkable: any;
}