added tests for batch import functionality
This commit is contained in:
parent
0f4ce609da
commit
0aeb470793
6 changed files with 466 additions and 3 deletions
|
|
@ -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
2
ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
190
ui/src/app/batch-import-core.spec.ts
Normal file
190
ui/src/app/batch-import-core.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
ui/src/app/batch-import.spec.ts
Normal file
228
ui/src/app/batch-import.spec.ts
Normal 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
45
ui/src/app/test-utils.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue