test: improve test case
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
123
backend/src/__tests__/controllers/downloadController.test.ts
Normal file
123
backend/src/__tests__/controllers/downloadController.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
136
backend/src/__tests__/controllers/subscriptionController.test.ts
Normal file
136
backend/src/__tests__/controllers/subscriptionController.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
208
backend/src/__tests__/middleware/errorHandler.test.ts
Normal file
208
backend/src/__tests__/middleware/errorHandler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
383
backend/src/__tests__/services/CloudStorageService.test.ts
Normal file
383
backend/src/__tests__/services/CloudStorageService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
231
backend/src/__tests__/services/subscriptionService.test.ts
Normal file
231
backend/src/__tests__/services/subscriptionService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
327
backend/src/__tests__/services/subtitleService.test.ts
Normal file
327
backend/src/__tests__/services/subtitleService.test.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
272
backend/src/__tests__/services/thumbnailService.test.ts
Normal file
272
backend/src/__tests__/services/thumbnailService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
222
backend/src/__tests__/utils/bccToVtt.test.ts
Normal file
222
backend/src/__tests__/utils/bccToVtt.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
169
backend/src/__tests__/utils/progressTracker.test.ts
Normal file
169
backend/src/__tests__/utils/progressTracker.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user