test: improve test case

This commit is contained in:
Peifan Li
2025-12-15 16:45:03 -05:00
parent f32d8fc641
commit 5a047b702e
13 changed files with 2096 additions and 28 deletions

View File

@@ -29,7 +29,7 @@ describe('CollectionController', () => {
getCollections(req as Request, res as Response);
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollections });
expect(json).toHaveBeenCalledWith(mockCollections);
});
it('should handle errors', async () => {
@@ -58,9 +58,7 @@ describe('CollectionController', () => {
// The controller creates a new object, so we check partial match or just that it was called
expect(storageService.saveCollection).toHaveBeenCalled();
expect(json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
data: expect.objectContaining({ title: 'New Col' }),
message: "Collection created"
title: 'New Col'
}));
});
@@ -97,7 +95,7 @@ describe('CollectionController', () => {
updateCollection(req as Request, res as Response);
expect(storageService.atomicUpdateCollection).toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollection });
expect(json).toHaveBeenCalledWith(mockCollection);
});
it('should add video', () => {
@@ -109,7 +107,7 @@ describe('CollectionController', () => {
updateCollection(req as Request, res as Response);
expect(storageService.addVideoToCollection).toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollection });
expect(json).toHaveBeenCalledWith(mockCollection);
});
it('should remove video', () => {
@@ -121,7 +119,7 @@ describe('CollectionController', () => {
updateCollection(req as Request, res as Response);
expect(storageService.removeVideoFromCollection).toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({ success: true, data: mockCollection });
expect(json).toHaveBeenCalledWith(mockCollection);
});
it('should throw NotFoundError if collection not found', async () => {

View File

@@ -0,0 +1,123 @@
import { Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
cancelDownload,
clearDownloadHistory,
clearQueue,
getDownloadHistory,
removeDownloadHistory,
removeFromQueue,
} from '../../controllers/downloadController';
import downloadManager from '../../services/downloadManager';
import * as storageService from '../../services/storageService';
vi.mock('../../services/downloadManager');
vi.mock('../../services/storageService');
describe('DownloadController', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let json: any;
let status: any;
beforeEach(() => {
vi.clearAllMocks();
json = vi.fn();
status = vi.fn().mockReturnValue({ json });
req = {
params: {},
};
res = {
json,
status,
};
});
describe('cancelDownload', () => {
it('should cancel a download', async () => {
req.params = { id: 'download-123' };
(downloadManager.cancelDownload as any).mockReturnValue(undefined);
await cancelDownload(req as Request, res as Response);
expect(downloadManager.cancelDownload).toHaveBeenCalledWith('download-123');
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, message: 'Download cancelled' });
});
});
describe('removeFromQueue', () => {
it('should remove download from queue', async () => {
req.params = { id: 'download-123' };
(downloadManager.removeFromQueue as any).mockReturnValue(undefined);
await removeFromQueue(req as Request, res as Response);
expect(downloadManager.removeFromQueue).toHaveBeenCalledWith('download-123');
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, message: 'Removed from queue' });
});
});
describe('clearQueue', () => {
it('should clear the download queue', async () => {
(downloadManager.clearQueue as any).mockReturnValue(undefined);
await clearQueue(req as Request, res as Response);
expect(downloadManager.clearQueue).toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, message: 'Queue cleared' });
});
});
describe('getDownloadHistory', () => {
it('should return download history', async () => {
const mockHistory = [
{ id: '1', url: 'https://example.com', status: 'completed' },
{ id: '2', url: 'https://example2.com', status: 'failed' },
];
(storageService.getDownloadHistory as any).mockReturnValue(mockHistory);
await getDownloadHistory(req as Request, res as Response);
expect(storageService.getDownloadHistory).toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith(mockHistory);
});
it('should return empty array when no history', async () => {
(storageService.getDownloadHistory as any).mockReturnValue([]);
await getDownloadHistory(req as Request, res as Response);
expect(json).toHaveBeenCalledWith([]);
});
});
describe('removeDownloadHistory', () => {
it('should remove item from download history', async () => {
req.params = { id: 'history-123' };
(storageService.removeDownloadHistoryItem as any).mockReturnValue(undefined);
await removeDownloadHistory(req as Request, res as Response);
expect(storageService.removeDownloadHistoryItem).toHaveBeenCalledWith('history-123');
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, message: 'Removed from history' });
});
});
describe('clearDownloadHistory', () => {
it('should clear download history', async () => {
(storageService.clearDownloadHistory as any).mockReturnValue(undefined);
await clearDownloadHistory(req as Request, res as Response);
expect(storageService.clearDownloadHistory).toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, message: 'History cleared' });
});
});
});

View File

@@ -42,8 +42,7 @@ describe('ScanController', () => {
expect(storageService.saveVideo).toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
data: expect.objectContaining({ addedCount: 1 })
addedCount: 1
}));
});

View File

@@ -37,7 +37,7 @@ describe('SettingsController', () => {
await getSettings(req as Request, res as Response);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ theme: 'dark' }) }));
expect(json).toHaveBeenCalledWith(expect.objectContaining({ theme: 'dark' }));
});
it('should save defaults if empty', async () => {
@@ -95,7 +95,7 @@ describe('SettingsController', () => {
await verifyPassword(req as Request, res as Response);
expect(json).toHaveBeenCalledWith({ success: true, data: { verified: true } });
expect(json).toHaveBeenCalledWith({ success: true });
});
it('should reject incorrect password', async () => {
@@ -120,7 +120,7 @@ describe('SettingsController', () => {
await migrateData(req as Request, res as Response);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
expect(json).toHaveBeenCalledWith(expect.objectContaining({ results: { success: true } }));
});
it('should handle errors', async () => {
@@ -146,7 +146,7 @@ describe('SettingsController', () => {
await deleteLegacyData(req as Request, res as Response);
expect(fs.unlinkSync).toHaveBeenCalledTimes(4);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
expect(json).toHaveBeenCalledWith(expect.objectContaining({ results: expect.anything() }));
});
it('should handle errors during deletion', async () => {
@@ -157,7 +157,7 @@ describe('SettingsController', () => {
await deleteLegacyData(req as Request, res as Response);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
expect(json).toHaveBeenCalledWith(expect.objectContaining({ results: expect.anything() }));
// It returns success but with failed list
});
});

View File

@@ -0,0 +1,136 @@
import { Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createSubscription,
deleteSubscription,
getSubscriptions,
} from '../../controllers/subscriptionController';
import { ValidationError } from '../../errors/DownloadErrors';
import { subscriptionService } from '../../services/subscriptionService';
import { logger } from '../../utils/logger';
vi.mock('../../services/subscriptionService');
vi.mock('../../utils/logger', () => ({
logger: {
info: vi.fn(),
},
}));
describe('SubscriptionController', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let json: any;
let status: any;
beforeEach(() => {
vi.clearAllMocks();
json = vi.fn();
status = vi.fn().mockReturnValue({ json });
req = {
body: {},
params: {},
};
res = {
json,
status,
};
});
describe('createSubscription', () => {
it('should create a subscription', async () => {
req.body = { url: 'https://www.youtube.com/@testuser', interval: 60 };
const mockSubscription = {
id: 'sub-123',
url: 'https://www.youtube.com/@testuser',
interval: 60,
author: '@testuser',
platform: 'YouTube',
};
(subscriptionService.subscribe as any).mockResolvedValue(mockSubscription);
await createSubscription(req as Request, res as Response);
expect(logger.info).toHaveBeenCalledWith('Creating subscription:', {
url: 'https://www.youtube.com/@testuser',
interval: 60,
});
expect(subscriptionService.subscribe).toHaveBeenCalledWith(
'https://www.youtube.com/@testuser',
60
);
expect(status).toHaveBeenCalledWith(201);
expect(json).toHaveBeenCalledWith(mockSubscription);
});
it('should throw ValidationError when URL is missing', async () => {
req.body = { interval: 60 };
await expect(
createSubscription(req as Request, res as Response)
).rejects.toThrow(ValidationError);
expect(subscriptionService.subscribe).not.toHaveBeenCalled();
});
it('should throw ValidationError when interval is missing', async () => {
req.body = { url: 'https://www.youtube.com/@testuser' };
await expect(
createSubscription(req as Request, res as Response)
).rejects.toThrow(ValidationError);
expect(subscriptionService.subscribe).not.toHaveBeenCalled();
});
it('should throw ValidationError when both URL and interval are missing', async () => {
req.body = {};
await expect(
createSubscription(req as Request, res as Response)
).rejects.toThrow(ValidationError);
});
});
describe('getSubscriptions', () => {
it('should return all subscriptions', async () => {
const mockSubscriptions = [
{ id: 'sub-1', url: 'https://www.youtube.com/@test1', interval: 60 },
{ id: 'sub-2', url: 'https://space.bilibili.com/123', interval: 120 },
];
(subscriptionService.listSubscriptions as any).mockResolvedValue(
mockSubscriptions
);
await getSubscriptions(req as Request, res as Response);
expect(subscriptionService.listSubscriptions).toHaveBeenCalled();
expect(json).toHaveBeenCalledWith(mockSubscriptions);
expect(status).not.toHaveBeenCalled(); // Default status is 200
});
it('should return empty array when no subscriptions', async () => {
(subscriptionService.listSubscriptions as any).mockResolvedValue([]);
await getSubscriptions(req as Request, res as Response);
expect(json).toHaveBeenCalledWith([]);
});
});
describe('deleteSubscription', () => {
it('should delete a subscription', async () => {
req.params = { id: 'sub-123' };
(subscriptionService.unsubscribe as any).mockResolvedValue(undefined);
await deleteSubscription(req as Request, res as Response);
expect(subscriptionService.unsubscribe).toHaveBeenCalledWith('sub-123');
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({
success: true,
message: 'Subscription deleted',
});
});
});
});

View File

@@ -2,15 +2,15 @@ import { Request, Response } from 'express';
import fs from 'fs-extra';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkBilibiliCollection,
checkBilibiliParts,
deleteVideo,
downloadVideo,
getVideoById,
getVideos,
rateVideo,
searchVideos,
updateVideoDetails,
checkBilibiliCollection,
checkBilibiliParts,
deleteVideo,
downloadVideo,
getVideoById,
getVideos,
rateVideo,
searchVideos,
updateVideoDetails,
} from '../../controllers/videoController';
import downloadManager from '../../services/downloadManager';
import * as downloadService from '../../services/downloadService';
@@ -70,7 +70,7 @@ describe('VideoController', () => {
expect(downloadService.searchYouTube).toHaveBeenCalledWith('test', 8, 1);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, data: { results: mockResults } });
expect(json).toHaveBeenCalledWith({ results: mockResults });
});
it('should return 400 if query is missing', async () => {
@@ -221,7 +221,7 @@ describe('VideoController', () => {
expect(storageService.getVideos).toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, data: mockVideos });
expect(json).toHaveBeenCalledWith(mockVideos);
});
});
@@ -235,7 +235,7 @@ describe('VideoController', () => {
expect(storageService.getVideoById).toHaveBeenCalledWith('1');
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, data: mockVideo });
expect(json).toHaveBeenCalledWith(mockVideo);
});
it('should throw NotFoundError if not found', async () => {
@@ -286,7 +286,7 @@ describe('VideoController', () => {
expect(storageService.updateVideo).toHaveBeenCalledWith('1', { rating: 5 });
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, message: 'Video rated successfully', data: { video: mockVideo } });
expect(json).toHaveBeenCalledWith({ success: true, video: mockVideo });
});
it('should throw ValidationError for invalid rating', async () => {
@@ -430,7 +430,7 @@ describe('VideoController', () => {
await import('../../controllers/videoController').then(m => m.getVideoComments(req as Request, res as Response));
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ success: true, data: [] });
expect(json).toHaveBeenCalledWith([]);
});
});

View File

@@ -0,0 +1,208 @@
import { NextFunction, Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
DownloadError,
ServiceError,
ValidationError,
NotFoundError,
DuplicateError,
} from '../../errors/DownloadErrors';
import { errorHandler, asyncHandler } from '../../middleware/errorHandler';
import { logger } from '../../utils/logger';
vi.mock('../../utils/logger', () => ({
logger: {
warn: vi.fn(),
error: vi.fn(),
},
}));
describe('ErrorHandler Middleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
let json: any;
let status: any;
beforeEach(() => {
vi.clearAllMocks();
json = vi.fn();
status = vi.fn().mockReturnValue({ json });
req = {};
res = {
json,
status,
};
next = vi.fn();
});
describe('errorHandler', () => {
it('should handle DownloadError with 400 status', () => {
const error = new DownloadError('network', 'Network error', true);
errorHandler(error, req as Request, res as Response, next);
expect(logger.warn).toHaveBeenCalledWith(
'[DownloadError] network: Network error'
);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({
error: 'Network error',
type: 'network',
recoverable: true,
});
});
it('should handle ServiceError with 400 status by default', () => {
const error = new ServiceError('validation', 'Invalid input', false);
errorHandler(error, req as Request, res as Response, next);
expect(logger.warn).toHaveBeenCalledWith(
'[ServiceError] validation: Invalid input'
);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({
error: 'Invalid input',
type: 'validation',
recoverable: false,
});
});
it('should handle NotFoundError with 404 status', () => {
const error = new NotFoundError('Video', 'video-123');
errorHandler(error, req as Request, res as Response, next);
expect(logger.warn).toHaveBeenCalledWith(
'[ServiceError] not_found: Video not found: video-123'
);
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({
error: 'Video not found: video-123',
type: 'not_found',
recoverable: false,
});
});
it('should handle DuplicateError with 409 status', () => {
const error = new DuplicateError('Subscription', 'Already exists');
errorHandler(error, req as Request, res as Response, next);
expect(logger.warn).toHaveBeenCalledWith(
'[ServiceError] duplicate: Already exists'
);
expect(status).toHaveBeenCalledWith(409);
expect(json).toHaveBeenCalledWith({
error: 'Already exists',
type: 'duplicate',
recoverable: false,
});
});
it('should handle ServiceError with execution type and 500 status', () => {
const error = new ServiceError('execution', 'Execution failed', false);
errorHandler(error, req as Request, res as Response, next);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
error: 'Execution failed',
type: 'execution',
recoverable: false,
});
});
it('should handle ServiceError with database type and 500 status', () => {
const error = new ServiceError('database', 'Database error', false);
errorHandler(error, req as Request, res as Response, next);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
error: 'Database error',
type: 'database',
recoverable: false,
});
});
it('should handle ServiceError with migration type and 500 status', () => {
const error = new ServiceError('migration', 'Migration failed', false);
errorHandler(error, req as Request, res as Response, next);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
error: 'Migration failed',
type: 'migration',
recoverable: false,
});
});
it('should handle unknown errors with 500 status', () => {
const error = new Error('Unexpected error');
errorHandler(error, req as Request, res as Response, next);
expect(logger.error).toHaveBeenCalledWith('Unhandled error', error);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
error: 'Internal server error',
message: undefined,
});
});
it('should include error message in development mode', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const error = new Error('Unexpected error');
errorHandler(error, req as Request, res as Response, next);
expect(json).toHaveBeenCalledWith({
error: 'Internal server error',
message: 'Unexpected error',
});
process.env.NODE_ENV = originalEnv;
});
});
describe('asyncHandler', () => {
it('should wrap async function and catch errors', async () => {
const asyncFn = vi.fn().mockRejectedValue(new Error('Test error'));
const wrapped = asyncHandler(asyncFn);
const next = vi.fn();
await wrapped(req as Request, res as Response, next);
expect(asyncFn).toHaveBeenCalledWith(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
});
it('should pass through successful async function', async () => {
const asyncFn = vi.fn().mockResolvedValue(undefined);
const wrapped = asyncHandler(asyncFn);
const next = vi.fn();
await wrapped(req as Request, res as Response, next);
expect(asyncFn).toHaveBeenCalledWith(req, res, next);
expect(next).not.toHaveBeenCalled();
});
it('should handle promise rejections from async functions', async () => {
const asyncFn = vi.fn().mockRejectedValue(new Error('Async error'));
const wrapped = asyncHandler(asyncFn);
const next = vi.fn();
await wrapped(req as Request, res as Response, next);
expect(asyncFn).toHaveBeenCalledWith(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect((next.mock.calls[0][0] as Error).message).toBe('Async error');
});
});
});

View File

@@ -0,0 +1,383 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileError, NetworkError } from '../../errors/DownloadErrors';
import * as storageService from '../../services/storageService';
import { CloudStorageService } from '../../services/CloudStorageService';
vi.mock('axios');
vi.mock('fs-extra');
vi.mock('../../services/storageService');
describe('CloudStorageService', () => {
beforeEach(() => {
vi.clearAllMocks();
console.log = vi.fn();
console.error = vi.fn();
});
describe('uploadVideo', () => {
it('should return early if cloud drive is not enabled', async () => {
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: false,
});
await CloudStorageService.uploadVideo({ title: 'Test Video' });
expect(axios.put).not.toHaveBeenCalled();
});
it('should return early if apiUrl is missing', async () => {
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: '',
openListToken: 'token',
});
await CloudStorageService.uploadVideo({ title: 'Test Video' });
expect(axios.put).not.toHaveBeenCalled();
});
it('should return early if token is missing', async () => {
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: '',
});
await CloudStorageService.uploadVideo({ title: 'Test Video' });
expect(axios.put).not.toHaveBeenCalled();
});
it('should upload video file when path exists', async () => {
const mockVideoData = {
title: 'Test Video',
videoPath: '/videos/test.mp4',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.createReadStream as any).mockReturnValue({});
(axios.put as any).mockResolvedValue({ status: 200 });
// Mock resolveAbsolutePath by making fs.existsSync return true for data dir
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('test.mp4') || p.includes('videos')) {
return true;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(axios.put).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith(
'[CloudStorage] Starting upload for video: Test Video'
);
});
it('should upload thumbnail when path exists', async () => {
const mockVideoData = {
title: 'Test Video',
thumbnailPath: '/images/thumb.jpg',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 512 });
(fs.createReadStream as any).mockReturnValue({});
(axios.put as any).mockResolvedValue({ status: 200 });
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('thumb.jpg') || p.includes('images')) {
return true;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(axios.put).toHaveBeenCalled();
});
it('should upload metadata JSON file', async () => {
const mockVideoData = {
title: 'Test Video',
description: 'Test description',
author: 'Test Author',
sourceUrl: 'https://example.com',
tags: ['tag1', 'tag2'],
createdAt: '2024-01-01',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.ensureDirSync as any).mockReturnValue(undefined);
(fs.writeFileSync as any).mockReturnValue(undefined);
(fs.statSync as any).mockReturnValue({ size: 256 });
(fs.createReadStream as any).mockReturnValue({});
(fs.unlinkSync as any).mockReturnValue(undefined);
(axios.put as any).mockResolvedValue({ status: 200 });
await CloudStorageService.uploadVideo(mockVideoData);
expect(fs.ensureDirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalled();
expect(axios.put).toHaveBeenCalled();
expect(fs.unlinkSync).toHaveBeenCalled();
});
it('should handle missing video file gracefully', async () => {
const mockVideoData = {
title: 'Test Video',
videoPath: '/videos/missing.mp4',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
// Mock existsSync to return false for video file, but true for data dir and temp_metadata
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('temp_metadata')) {
return true;
}
if (p.includes('missing.mp4') || p.includes('videos')) {
return false;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalledWith(
'[CloudStorage] Video file not found: /videos/missing.mp4'
);
// Metadata will still be uploaded even if video is missing
// So we check that video upload was not attempted
const putCalls = (axios.put as any).mock.calls;
const videoUploadCalls = putCalls.filter((call: any[]) =>
call[0] && call[0].includes('missing.mp4')
);
expect(videoUploadCalls.length).toBe(0);
});
it('should handle upload errors gracefully', async () => {
const mockVideoData = {
title: 'Test Video',
videoPath: '/videos/test.mp4',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.createReadStream as any).mockReturnValue({});
(axios.put as any).mockRejectedValue(new Error('Upload failed'));
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('test.mp4')) {
return true;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalledWith(
'[CloudStorage] Upload failed for Test Video:',
expect.any(Error)
);
});
it('should sanitize filename for metadata', async () => {
const mockVideoData = {
title: 'Test Video (2024)',
description: 'Test',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.ensureDirSync as any).mockReturnValue(undefined);
(fs.writeFileSync as any).mockReturnValue(undefined);
(fs.statSync as any).mockReturnValue({ size: 256 });
(fs.createReadStream as any).mockReturnValue({});
(fs.unlinkSync as any).mockReturnValue(undefined);
(axios.put as any).mockResolvedValue({ status: 200 });
await CloudStorageService.uploadVideo(mockVideoData);
expect(fs.writeFileSync).toHaveBeenCalled();
const metadataPath = (fs.writeFileSync as any).mock.calls[0][0];
// The sanitize function replaces non-alphanumeric with underscore, so ( becomes _
expect(metadataPath).toContain('test_video__2024_.json');
});
});
describe('uploadFile error handling', () => {
it('should throw NetworkError on HTTP error response', async () => {
const mockVideoData = {
title: 'Test Video',
videoPath: '/videos/test.mp4',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.createReadStream as any).mockReturnValue({});
const axiosError = {
response: {
status: 500,
},
message: 'Internal Server Error',
};
(axios.put as any).mockRejectedValue(axiosError);
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('test.mp4')) {
return true;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalled();
});
it('should handle network timeout errors', async () => {
const mockVideoData = {
title: 'Test Video',
videoPath: '/videos/test.mp4',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.createReadStream as any).mockReturnValue({});
const axiosError = {
request: {},
message: 'Timeout',
};
(axios.put as any).mockRejectedValue(axiosError);
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('test.mp4')) {
return true;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalled();
});
it('should handle file not found errors', async () => {
const mockVideoData = {
title: 'Test Video',
videoPath: '/videos/test.mp4',
};
(storageService.getSettings as any).mockReturnValue({
cloudDriveEnabled: true,
openListApiUrl: 'https://api.example.com',
openListToken: 'test-token',
cloudDrivePath: '/uploads',
});
(fs.existsSync as any).mockReturnValue(true);
(fs.statSync as any).mockReturnValue({ size: 1024 });
(fs.createReadStream as any).mockReturnValue({});
const axiosError = {
code: 'ENOENT',
message: 'File not found',
};
(axios.put as any).mockRejectedValue(axiosError);
(fs.existsSync as any).mockImplementation((p: string) => {
if (p.includes('data') && !p.includes('videos') && !p.includes('images')) {
return true;
}
if (p.includes('test.mp4')) {
return true;
}
return false;
});
await CloudStorageService.uploadVideo(mockVideoData);
expect(console.error).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../db';
import { DuplicateError, ValidationError } from '../../errors/DownloadErrors';
import { BilibiliDownloader } from '../../services/downloaders/BilibiliDownloader';
import { YtDlpDownloader } from '../../services/downloaders/YtDlpDownloader';
import * as downloadService from '../../services/downloadService';
import * as storageService from '../../services/storageService';
import { subscriptionService } from '../../services/subscriptionService';
// Test setup
vi.mock('../../db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
}
}));
// Mock schema to avoid actual DB dependency issues in table definitions if any
vi.mock('../../db/schema', () => ({
subscriptions: {
id: 'id',
authorUrl: 'authorUrl',
// add other fields if needed for referencing columns
}
}));
vi.mock('../../services/downloadService');
vi.mock('../../services/storageService');
vi.mock('../../services/downloaders/BilibiliDownloader');
vi.mock('../../services/downloaders/YtDlpDownloader');
vi.mock('node-cron', () => ({
default: {
schedule: vi.fn().mockReturnValue({ stop: vi.fn() }),
}
}));
// Mock UUID to predict IDs
vi.mock('uuid', () => ({
v4: () => 'test-uuid'
}));
describe('SubscriptionService', () => {
// Setup chainable db mocks
const createMockQueryBuilder = (result: any) => {
const builder: any = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
then: (resolve: any) => Promise.resolve(result).then(resolve)
};
// Circular references for chaining
builder.from.mockReturnValue(builder);
builder.where.mockReturnValue(builder);
builder.limit.mockReturnValue(builder);
builder.values.mockReturnValue(builder);
builder.set.mockReturnValue(builder);
return builder;
};
let mockBuilder: any;
beforeEach(() => {
vi.clearAllMocks();
mockBuilder = createMockQueryBuilder([]);
(db.select as any).mockReturnValue(mockBuilder);
(db.insert as any).mockReturnValue(mockBuilder);
(db.delete as any).mockReturnValue(mockBuilder);
(db.update as any).mockReturnValue(mockBuilder);
});
describe('subscribe', () => {
it('should subscribe to a YouTube channel', async () => {
const url = 'https://www.youtube.com/@testuser';
// Mock empty result for "where" check (no existing sub)
// Since we use the same builder for everything, we just rely on it returning empty array by default
// But insert needs to return something? Typically insert returns result object.
// But the code doesn't use the insert result, just awaits it.
const result = await subscriptionService.subscribe(url, 60);
expect(result).toMatchObject({
id: 'test-uuid',
author: '@testuser',
platform: 'YouTube',
interval: 60
});
expect(db.insert).toHaveBeenCalled();
expect(mockBuilder.values).toHaveBeenCalled();
});
it('should subscribe to a Bilibili space', async () => {
const url = 'https://space.bilibili.com/123456';
// Default mock builder returns empty array which satisfies "not existing"
(BilibiliDownloader.getAuthorInfo as any).mockResolvedValue({ name: 'BilibiliUser' });
const result = await subscriptionService.subscribe(url, 30);
expect(result).toMatchObject({
author: 'BilibiliUser',
platform: 'Bilibili'
});
expect(db.insert).toHaveBeenCalled();
});
it('should throw DuplicateError if already subscribed', async () => {
const url = 'https://www.youtube.com/@testuser';
// Mock existing subscription
mockBuilder.then = (cb: any) => Promise.resolve([{ id: 'existing' }]).then(cb);
await expect(subscriptionService.subscribe(url, 60))
.rejects.toThrow(DuplicateError);
});
it('should throw ValidationError for unsupported URL', async () => {
const url = 'https://example.com/user';
await expect(subscriptionService.subscribe(url, 60))
.rejects.toThrow(ValidationError);
});
});
describe('unsubscribe', () => {
it('should unsubscribe successfully', async () => {
const subId = 'sub-1';
// First call (check existence): return [sub]
// Second call (delete): return whatever
// Third call (verify): return []
let callCount = 0;
mockBuilder.then = (cb: any) => {
callCount++;
if (callCount === 1) return Promise.resolve([{ id: subId, author: 'User', platform: 'YouTube' }]).then(cb);
if (callCount === 2) return Promise.resolve(undefined).then(cb); // Delete result
if (callCount === 3) return Promise.resolve([]).then(cb); // Verify result
return Promise.resolve([]).then(cb);
};
await subscriptionService.unsubscribe(subId);
expect(db.delete).toHaveBeenCalled();
});
it('should handle non-existent subscription gracefully', async () => {
const subId = 'non-existent';
// First call returns empty
mockBuilder.then = (cb: any) => Promise.resolve([]).then(cb);
await subscriptionService.unsubscribe(subId);
expect(db.delete).not.toHaveBeenCalled();
});
});
describe('checkSubscriptions', () => {
it('should check subscriptions and download new video', async () => {
const sub = {
id: 'sub-1',
author: 'User',
platform: 'YouTube',
authorUrl: 'url',
lastCheck: 0,
interval: 10,
lastVideoLink: 'old-link'
};
// We need to handle multiple queries here.
// 1. listSubscriptions
// Then loop:
// 2. verify existence
// 3. update (in case of success/failure)
let callCount = 0;
mockBuilder.then = (cb: any) => {
callCount++;
if (callCount === 1) return Promise.resolve([sub]).then(cb); // listSubscriptions
if (callCount === 2) return Promise.resolve([sub]).then(cb); // verify existence
return Promise.resolve(undefined).then(cb); // subsequent updates
};
// Mock getting latest video
(YtDlpDownloader.getLatestVideoUrl as any).mockResolvedValue('new-link');
// Mock download
(downloadService.downloadYouTubeVideo as any).mockResolvedValue({
videoData: { id: 'vid-1', title: 'New Video' }
});
await subscriptionService.checkSubscriptions();
expect(downloadService.downloadYouTubeVideo).toHaveBeenCalledWith('new-link');
expect(storageService.addDownloadHistoryItem).toHaveBeenCalledWith(expect.objectContaining({
status: 'success'
}));
expect(db.update).toHaveBeenCalled();
});
it('should skip if no new video', async () => {
const sub = {
id: 'sub-1',
author: 'User',
platform: 'YouTube',
authorUrl: 'url',
lastCheck: 0,
interval: 10,
lastVideoLink: 'same-link'
};
let callCount = 0;
mockBuilder.then = (cb: any) => {
callCount++;
if (callCount === 1) return Promise.resolve([sub]).then(cb); // listSubscriptions
if (callCount === 2) return Promise.resolve([sub]).then(cb); // verify existence
return Promise.resolve(undefined).then(cb); // updates
};
(YtDlpDownloader.getLatestVideoUrl as any).mockResolvedValue('same-link');
await subscriptionService.checkSubscriptions();
expect(downloadService.downloadYouTubeVideo).not.toHaveBeenCalled();
// Should still update lastCheck
expect(db.update).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,327 @@
import fs from 'fs-extra';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileError } from '../../errors/DownloadErrors';
import { SUBTITLES_DIR, VIDEOS_DIR } from '../../config/paths';
import * as storageService from '../../services/storageService';
import { moveAllSubtitles } from '../../services/subtitleService';
vi.mock('../../db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
sqlite: {
prepare: vi.fn(),
},
}));
vi.mock('fs-extra');
vi.mock('../../services/storageService');
vi.mock('../../config/paths', () => ({
SUBTITLES_DIR: '/test/subtitles',
VIDEOS_DIR: '/test/videos',
DATA_DIR: '/test/data',
}));
describe('SubtitleService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('moveAllSubtitles', () => {
it('should move subtitles to video folders', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/subtitles/sub1.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllSubtitles(true);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(SUBTITLES_DIR, 'sub1.vtt'),
path.join(VIDEOS_DIR, 'sub1.vtt'),
{ overwrite: true }
);
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
subtitles: [
{
filename: 'sub1.vtt',
path: '/videos/sub1.vtt',
language: 'en',
},
],
});
expect(result.movedCount).toBe(1);
expect(result.errorCount).toBe(0);
});
it('should move subtitles to central subtitles folder', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/videos/sub1.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.ensureDirSync as any).mockReturnValue(undefined);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllSubtitles(false);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(VIDEOS_DIR, 'sub1.vtt'),
path.join(SUBTITLES_DIR, 'sub1.vtt'),
{ overwrite: true }
);
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
subtitles: [
{
filename: 'sub1.vtt',
path: '/subtitles/sub1.vtt',
language: 'en',
},
],
});
expect(result.movedCount).toBe(1);
expect(result.errorCount).toBe(0);
});
it('should handle videos in collection folders', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/MyCollection/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/subtitles/sub1.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllSubtitles(true);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(SUBTITLES_DIR, 'sub1.vtt'),
path.join(VIDEOS_DIR, 'MyCollection', 'sub1.vtt'),
{ overwrite: true }
);
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
subtitles: [
{
filename: 'sub1.vtt',
path: '/videos/MyCollection/sub1.vtt',
language: 'en',
},
],
});
});
it('should skip videos without subtitles', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
subtitles: [],
},
{
id: 'video-2',
videoFilename: 'video2.mp4',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
const result = await moveAllSubtitles(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(storageService.updateVideo).not.toHaveBeenCalled();
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(0);
});
it('should handle missing subtitle files gracefully', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'missing.vtt',
path: '/subtitles/missing.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(false);
const result = await moveAllSubtitles(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(storageService.updateVideo).not.toHaveBeenCalled();
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(0);
});
it('should handle FileError during move', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/subtitles/sub1.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockImplementation(() => {
throw new FileError('Move failed', '/test/path');
});
const result = await moveAllSubtitles(true);
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(1);
});
it('should handle generic errors during move', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/subtitles/sub1.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockImplementation(() => {
throw new Error('Generic error');
});
const result = await moveAllSubtitles(true);
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(1);
});
it('should not move if already in correct location', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/videos/sub1.vtt',
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
const result = await moveAllSubtitles(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(0);
});
it('should update path even if file already in correct location but path is wrong', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
subtitles: [
{
filename: 'sub1.vtt',
path: '/subtitles/sub1.vtt', // Wrong path
language: 'en',
},
],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
// File is actually at /videos/sub1.vtt
(fs.existsSync as any).mockImplementation((p: string) => {
return p === path.join(VIDEOS_DIR, 'sub1.vtt');
});
const result = await moveAllSubtitles(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
subtitles: [
{
filename: 'sub1.vtt',
path: '/videos/sub1.vtt',
language: 'en',
},
],
});
});
});
});

View File

@@ -0,0 +1,272 @@
import fs from 'fs-extra';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IMAGES_DIR, VIDEOS_DIR } from '../../config/paths';
import * as storageService from '../../services/storageService';
import { moveAllThumbnails } from '../../services/thumbnailService';
vi.mock('../../db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
sqlite: {
prepare: vi.fn(),
},
}));
vi.mock('fs-extra');
vi.mock('../../services/storageService');
vi.mock('../../config/paths', () => ({
IMAGES_DIR: '/test/images',
VIDEOS_DIR: '/test/videos',
DATA_DIR: '/test/data',
}));
describe('ThumbnailService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('moveAllThumbnails', () => {
it('should move thumbnails to video folders', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/images/thumb1.jpg',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllThumbnails(true);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(IMAGES_DIR, 'thumb1.jpg'),
path.join(VIDEOS_DIR, 'thumb1.jpg'),
{ overwrite: true }
);
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
thumbnailPath: '/videos/thumb1.jpg',
});
expect(result.movedCount).toBe(1);
expect(result.errorCount).toBe(0);
});
it('should move thumbnails to central images folder', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/videos/thumb1.jpg',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.ensureDirSync as any).mockReturnValue(undefined);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllThumbnails(false);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(VIDEOS_DIR, 'thumb1.jpg'),
path.join(IMAGES_DIR, 'thumb1.jpg'),
{ overwrite: true }
);
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
thumbnailPath: '/images/thumb1.jpg',
});
expect(result.movedCount).toBe(1);
expect(result.errorCount).toBe(0);
});
it('should handle videos in collection folders', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/MyCollection/video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/images/thumb1.jpg',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllThumbnails(true);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(IMAGES_DIR, 'thumb1.jpg'),
path.join(VIDEOS_DIR, 'MyCollection', 'thumb1.jpg'),
{ overwrite: true }
);
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
thumbnailPath: '/videos/MyCollection/thumb1.jpg',
});
});
it('should skip videos without thumbnails', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
},
{
id: 'video-2',
videoFilename: 'video2.mp4',
thumbnailFilename: null,
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
const result = await moveAllThumbnails(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(storageService.updateVideo).not.toHaveBeenCalled();
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(0);
});
it('should handle missing thumbnail files gracefully', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
thumbnailFilename: 'missing.jpg',
thumbnailPath: '/images/missing.jpg',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(false);
const result = await moveAllThumbnails(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(storageService.updateVideo).not.toHaveBeenCalled();
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(0);
});
it('should handle errors during move', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/images/thumb1.jpg',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockImplementation(() => {
throw new Error('Move failed');
});
const result = await moveAllThumbnails(true);
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(1);
});
it('should not move if already in correct location', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/videos/thumb1.jpg',
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
const result = await moveAllThumbnails(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(result.movedCount).toBe(0);
expect(result.errorCount).toBe(0);
});
it('should update path even if file already in correct location but path is wrong', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
videoPath: '/videos/video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/images/thumb1.jpg', // Wrong path
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(fs.existsSync as any).mockReturnValue(true);
// File is actually at /videos/thumb1.jpg
(fs.existsSync as any).mockImplementation((p: string) => {
return p === path.join(VIDEOS_DIR, 'thumb1.jpg');
});
const result = await moveAllThumbnails(true);
expect(fs.moveSync).not.toHaveBeenCalled();
expect(storageService.updateVideo).toHaveBeenCalledWith('video-1', {
thumbnailPath: '/videos/thumb1.jpg',
});
});
it('should handle videos with collection fallback', async () => {
const mockVideos = [
{
id: 'video-1',
videoFilename: 'video1.mp4',
thumbnailFilename: 'thumb1.jpg',
thumbnailPath: '/images/thumb1.jpg',
},
];
const mockCollections = [
{
id: 'col-1',
name: 'MyCollection',
videos: ['video-1'],
},
];
(storageService.getVideos as any).mockReturnValue(mockVideos);
(storageService.getCollections as any).mockReturnValue(mockCollections);
(fs.existsSync as any).mockReturnValue(true);
(fs.moveSync as any).mockReturnValue(undefined);
(storageService.updateVideo as any).mockReturnValue(undefined);
const result = await moveAllThumbnails(true);
expect(fs.moveSync).toHaveBeenCalledWith(
path.join(IMAGES_DIR, 'thumb1.jpg'),
path.join(VIDEOS_DIR, 'MyCollection', 'thumb1.jpg'),
{ overwrite: true }
);
expect(result.movedCount).toBe(1);
});
});
});

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest';
import { bccToVtt } from '../../utils/bccToVtt';
describe('bccToVtt', () => {
it('should convert BCC object to VTT format', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: [
{
from: 0,
to: 2.5,
location: 2,
content: 'Hello world',
},
{
from: 2.5,
to: 5.0,
location: 2,
content: 'This is a test',
},
],
};
const result = bccToVtt(bcc);
expect(result).toContain('WEBVTT');
expect(result).toContain('00:00:00.000 --> 00:00:02.500');
expect(result).toContain('Hello world');
expect(result).toContain('00:00:02.500 --> 00:00:05.000');
expect(result).toContain('This is a test');
});
it('should convert BCC string to VTT format', () => {
const bccString = JSON.stringify({
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: [
{
from: 10.5,
to: 15.75,
location: 2,
content: 'Subtitle text',
},
],
});
const result = bccToVtt(bccString);
expect(result).toContain('WEBVTT');
expect(result).toContain('00:00:10.500 --> 00:00:15.750');
expect(result).toContain('Subtitle text');
});
it('should handle milliseconds correctly', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: [
{
from: 1.234,
to: 3.456,
location: 2,
content: 'Test',
},
],
};
const result = bccToVtt(bcc);
expect(result).toContain('00:00:01.234 --> 00:00:03.456');
});
it('should handle hours correctly', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: [
{
from: 3661.5,
to: 3665.0,
location: 2,
content: 'Hour test',
},
],
};
const result = bccToVtt(bcc);
expect(result).toContain('01:01:01.500 --> 01:01:05.000');
});
it('should return empty string for invalid JSON string', () => {
const invalidJson = 'not valid json';
const result = bccToVtt(invalidJson);
expect(result).toBe('');
});
it('should return empty string when body is missing', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
};
const result = bccToVtt(bcc as any);
expect(result).toBe('');
});
it('should return empty string when body is not an array', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: 'not an array',
};
const result = bccToVtt(bcc as any);
expect(result).toBe('');
});
it('should handle empty body array', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: [],
};
const result = bccToVtt(bcc);
expect(result).toBe('WEBVTT\n\n');
});
it('should handle multiple subtitles correctly', () => {
const bcc = {
font_size: 0.4,
font_color: '#FFFFFF',
background_alpha: 0.5,
background_color: '#000000',
Stroke: 'none',
type: 'subtitles',
lang: 'en',
version: '1.0',
body: [
{
from: 0,
to: 1,
location: 2,
content: 'First',
},
{
from: 1,
to: 2,
location: 2,
content: 'Second',
},
{
from: 2,
to: 3,
location: 2,
content: 'Third',
},
],
};
const result = bccToVtt(bcc);
const lines = result.split('\n');
expect(lines[0]).toBe('WEBVTT');
expect(lines[2]).toBe('00:00:00.000 --> 00:00:01.000');
expect(lines[3]).toBe('First');
expect(lines[5]).toBe('00:00:01.000 --> 00:00:02.000');
expect(lines[6]).toBe('Second');
expect(lines[8]).toBe('00:00:02.000 --> 00:00:03.000');
expect(lines[9]).toBe('Third');
});
});

View File

@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as storageService from '../../services/storageService';
import { ProgressTracker } from '../../utils/progressTracker';
vi.mock('../../services/storageService');
describe('ProgressTracker', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('parseYtDlpOutput', () => {
it('should parse percentage-based progress', () => {
const tracker = new ProgressTracker();
const output = '[download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.percentage).toBe(23.5);
expect(result?.totalSize).toBe('10.00MiB');
expect(result?.speed).toBe('2.00MiB/s');
});
it('should parse progress with tilde prefix', () => {
const tracker = new ProgressTracker();
const output = '[download] 50.0% of ~10.00MiB at 2.00MiB/s';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.percentage).toBe(50.0);
expect(result?.totalSize).toBe('~10.00MiB');
});
it('should parse size-based progress', () => {
const tracker = new ProgressTracker();
const output = '[download] 55.8MiB of 123.45MiB at 5.67MiB/s ETA 00:12';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.downloadedSize).toBe('55.8MiB');
expect(result?.totalSize).toBe('123.45MiB');
expect(result?.speed).toBe('5.67MiB/s');
expect(result?.percentage).toBeCloseTo(45.2, 1);
});
it('should parse segment-based progress', () => {
const tracker = new ProgressTracker();
const output = '[download] Downloading segment 5 of 10';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.percentage).toBe(50);
expect(result?.downloadedSize).toBe('5/10 segments');
expect(result?.totalSize).toBe('10 segments');
expect(result?.speed).toBe('0 B/s');
});
it('should return null for non-matching output', () => {
const tracker = new ProgressTracker();
const output = 'Some random text';
const result = tracker.parseYtDlpOutput(output);
expect(result).toBeNull();
});
it('should handle progress without ETA', () => {
const tracker = new ProgressTracker();
const output = '[download] 75.0% of 100.00MiB at 10.00MiB/s';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.percentage).toBe(75.0);
});
it('should calculate percentage from sizes correctly', () => {
const tracker = new ProgressTracker();
const output = '[download] 25.0MiB of 100.0MiB at 5.0MiB/s';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.percentage).toBe(25);
});
it('should handle zero total size gracefully', () => {
const tracker = new ProgressTracker();
const output = '[download] 0.0MiB of 0.0MiB at 0.0MiB/s';
const result = tracker.parseYtDlpOutput(output);
expect(result).not.toBeNull();
expect(result?.percentage).toBe(0);
});
});
describe('update', () => {
it('should update download progress when downloadId is set', () => {
const tracker = new ProgressTracker('download-123');
const progress = {
percentage: 50,
downloadedSize: '50MiB',
totalSize: '100MiB',
speed: '5MiB/s',
};
tracker.update(progress);
expect(storageService.updateActiveDownload).toHaveBeenCalledWith(
'download-123',
{
progress: 50,
totalSize: '100MiB',
downloadedSize: '50MiB',
speed: '5MiB/s',
}
);
});
it('should not update when downloadId is not set', () => {
const tracker = new ProgressTracker();
const progress = {
percentage: 50,
downloadedSize: '50MiB',
totalSize: '100MiB',
speed: '5MiB/s',
};
tracker.update(progress);
expect(storageService.updateActiveDownload).not.toHaveBeenCalled();
});
});
describe('parseAndUpdate', () => {
it('should parse and update when valid progress is found', () => {
const tracker = new ProgressTracker('download-123');
const output = '[download] 50.0% of 100.00MiB at 5.00MiB/s';
tracker.parseAndUpdate(output);
expect(storageService.updateActiveDownload).toHaveBeenCalled();
});
it('should not update when no valid progress is found', () => {
const tracker = new ProgressTracker('download-123');
const output = 'Some random text';
tracker.parseAndUpdate(output);
expect(storageService.updateActiveDownload).not.toHaveBeenCalled();
});
it('should not update when downloadId is not set', () => {
const tracker = new ProgressTracker();
const output = '[download] 50.0% of 100.00MiB at 5.00MiB/s';
tracker.parseAndUpdate(output);
expect(storageService.updateActiveDownload).not.toHaveBeenCalled();
});
});
});