555 lines
18 KiB
TypeScript
555 lines
18 KiB
TypeScript
import { Request, Response } from "express";
|
|
import fs from "fs-extra";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
deleteVideo,
|
|
getVideoById,
|
|
getVideos,
|
|
updateVideoDetails,
|
|
} from "../../controllers/videoController";
|
|
import {
|
|
checkBilibiliCollection,
|
|
checkBilibiliParts,
|
|
downloadVideo,
|
|
getDownloadStatus,
|
|
searchVideos,
|
|
} from "../../controllers/videoDownloadController";
|
|
import { rateVideo } from "../../controllers/videoMetadataController";
|
|
import downloadManager from "../../services/downloadManager";
|
|
import * as downloadService from "../../services/downloadService";
|
|
import * as storageService from "../../services/storageService";
|
|
|
|
vi.mock("../../db", () => ({
|
|
db: {
|
|
insert: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
select: vi.fn(),
|
|
transaction: vi.fn(),
|
|
},
|
|
sqlite: {
|
|
prepare: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../services/downloadService");
|
|
vi.mock("../../services/storageService");
|
|
vi.mock("../../services/downloadManager");
|
|
vi.mock("../../services/metadataService");
|
|
vi.mock("../../utils/security");
|
|
vi.mock("fs-extra");
|
|
vi.mock("child_process");
|
|
vi.mock("multer", () => {
|
|
const multer = vi.fn(() => ({
|
|
single: vi.fn(),
|
|
array: vi.fn(),
|
|
}));
|
|
(multer as any).diskStorage = vi.fn(() => ({}));
|
|
return { default: multer };
|
|
});
|
|
|
|
describe("VideoController", () => {
|
|
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 = {};
|
|
res = {
|
|
json,
|
|
status,
|
|
};
|
|
(storageService.handleVideoDownloadCheck as any) = vi.fn().mockReturnValue({
|
|
shouldSkip: false,
|
|
shouldForce: false,
|
|
});
|
|
(storageService.checkVideoDownloadBySourceId as any) = vi.fn().mockReturnValue({
|
|
found: false,
|
|
});
|
|
});
|
|
|
|
describe("searchVideos", () => {
|
|
it("should return search results", async () => {
|
|
req.query = { query: "test" };
|
|
const mockResults = [{ id: "1", title: "Test" }];
|
|
(downloadService.searchYouTube as any).mockResolvedValue(mockResults);
|
|
|
|
await searchVideos(req as Request, res as Response);
|
|
|
|
expect(downloadService.searchYouTube).toHaveBeenCalledWith("test", 8, 1);
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith({ results: mockResults });
|
|
});
|
|
|
|
it("should return 400 if query is missing", async () => {
|
|
req.query = {};
|
|
|
|
req.query = {};
|
|
|
|
// Validation errors might return 400 or 500 depending on middleware config, but usually 400 is expected for validation
|
|
// But since we are catching validation error in test via try/catch in middleware in real app, here we are testing controller directly.
|
|
// Wait, searchVideos does not throw ValidationError for empty query, it explicitly returns 400?
|
|
// Let's check controller. It throws ValidationError. Middleware catches it.
|
|
// But in this unit test we are mocking req/res. We are NOT using middleware.
|
|
// So calling searchVideos will THROW.
|
|
try {
|
|
await searchVideos(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("ValidationError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("downloadVideo", () => {
|
|
it("should queue download for valid URL", async () => {
|
|
req.body = { youtubeUrl: "https://youtube.com/watch?v=123" };
|
|
(downloadManager.addDownload as any).mockResolvedValue("success");
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
expect(downloadManager.addDownload).toHaveBeenCalled();
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ success: true, message: "Download queued" })
|
|
);
|
|
});
|
|
|
|
it("should return 400 for invalid URL", async () => {
|
|
req.body = { youtubeUrl: "not-a-url" };
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: "Not a valid URL" })
|
|
);
|
|
});
|
|
|
|
it("should return 400 if url is missing", async () => {
|
|
req.body = {};
|
|
await downloadVideo(req as Request, res as Response);
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
});
|
|
|
|
it("should handle Bilibili collection download", async () => {
|
|
req.body = {
|
|
youtubeUrl: "https://www.bilibili.com/video/BV1xx",
|
|
downloadCollection: true,
|
|
collectionName: "Col",
|
|
collectionInfo: {},
|
|
};
|
|
(downloadService.downloadBilibiliCollection as any).mockResolvedValue({
|
|
success: true,
|
|
collectionId: "1",
|
|
});
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
// The actual download task runs async, we just check it queued successfully
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ success: true, message: "Download queued" })
|
|
);
|
|
});
|
|
|
|
it("should handle Bilibili multi-part download", async () => {
|
|
req.body = {
|
|
youtubeUrl: "https://www.bilibili.com/video/BV1xx",
|
|
downloadAllParts: true,
|
|
collectionName: "Col",
|
|
};
|
|
(downloadService.checkBilibiliVideoParts as any).mockResolvedValue({
|
|
success: true,
|
|
videosNumber: 2,
|
|
title: "Title",
|
|
});
|
|
(downloadService.downloadSingleBilibiliPart as any).mockResolvedValue({
|
|
success: true,
|
|
videoData: { id: "v1" },
|
|
});
|
|
(
|
|
downloadService.downloadRemainingBilibiliParts as any
|
|
).mockImplementation(() => {});
|
|
(storageService.saveCollection as any).mockImplementation(() => {});
|
|
(storageService.atomicUpdateCollection as any).mockImplementation(
|
|
(_id: string, fn: Function) => fn({ videos: [] })
|
|
);
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
// The actual download task runs async, we just check it queued successfully
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ success: true, message: "Download queued" })
|
|
);
|
|
});
|
|
|
|
it("should handle MissAV download", async () => {
|
|
req.body = { youtubeUrl: "https://missav.com/v1" };
|
|
(downloadService.downloadMissAVVideo as any).mockResolvedValue({
|
|
id: "v1",
|
|
});
|
|
(storageService.checkVideoDownloadBySourceId as any).mockReturnValue({
|
|
found: false,
|
|
});
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
// The actual download task runs async, we just check it queued successfully
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ success: true, message: "Download queued" })
|
|
);
|
|
});
|
|
|
|
it("should handle Bilibili single part download when checkParts returns 1 video", async () => {
|
|
req.body = {
|
|
youtubeUrl: "https://www.bilibili.com/video/BV1xx",
|
|
downloadAllParts: true,
|
|
};
|
|
(downloadService.checkBilibiliVideoParts as any).mockResolvedValue({
|
|
success: true,
|
|
videosNumber: 1,
|
|
title: "Title",
|
|
});
|
|
(downloadService.downloadSingleBilibiliPart as any).mockResolvedValue({
|
|
success: true,
|
|
videoData: { id: "v1" },
|
|
});
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ success: true, message: "Download queued" })
|
|
);
|
|
});
|
|
|
|
it("should handle Bilibili single part download failure", async () => {
|
|
req.body = { youtubeUrl: "https://www.bilibili.com/video/BV1xx" };
|
|
(downloadService.downloadSingleBilibiliPart as any).mockResolvedValue({
|
|
success: false,
|
|
error: "Failed",
|
|
});
|
|
(storageService.checkVideoDownloadBySourceId as any).mockReturnValue({
|
|
found: false,
|
|
});
|
|
(downloadManager.addDownload as any).mockReturnValue(Promise.resolve());
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
// Should still queue successfully even if the task itself might fail
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it("should handle download task errors", async () => {
|
|
req.body = { youtubeUrl: "https://youtube.com/watch?v=123" };
|
|
(downloadManager.addDownload as any).mockImplementation(() => {
|
|
throw new Error("Queue error");
|
|
});
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
expect(status).toHaveBeenCalledWith(500);
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: "Failed to queue download" })
|
|
);
|
|
});
|
|
|
|
it("should handle YouTube download", async () => {
|
|
req.body = { youtubeUrl: "https://www.youtube.com/watch?v=abc123" };
|
|
(downloadService.downloadYouTubeVideo as any).mockResolvedValue({
|
|
id: "v1",
|
|
});
|
|
(downloadManager.addDownload as any).mockResolvedValue("success");
|
|
|
|
await downloadVideo(req as Request, res as Response);
|
|
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ success: true, message: "Download queued" })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getVideos", () => {
|
|
it("should return all videos", () => {
|
|
const mockVideos = [{ id: "1" }];
|
|
(storageService.getVideos as any).mockReturnValue(mockVideos);
|
|
|
|
getVideos(req as Request, res as Response);
|
|
|
|
expect(storageService.getVideos).toHaveBeenCalled();
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith(mockVideos);
|
|
});
|
|
});
|
|
|
|
describe("getVideoById", () => {
|
|
it("should return video if found", () => {
|
|
req.params = { id: "1" };
|
|
const mockVideo = { id: "1" };
|
|
(storageService.getVideoById as any).mockReturnValue(mockVideo);
|
|
|
|
getVideoById(req as Request, res as Response);
|
|
|
|
expect(storageService.getVideoById).toHaveBeenCalledWith("1");
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith(mockVideo);
|
|
});
|
|
|
|
it("should throw NotFoundError if not found", async () => {
|
|
req.params = { id: "1" };
|
|
(storageService.getVideoById as any).mockReturnValue(undefined);
|
|
|
|
try {
|
|
await getVideoById(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("NotFoundError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("deleteVideo", () => {
|
|
it("should delete video", () => {
|
|
req.params = { id: "1" };
|
|
(storageService.deleteVideo as any).mockReturnValue(true);
|
|
|
|
deleteVideo(req as Request, res as Response);
|
|
|
|
expect(storageService.deleteVideo).toHaveBeenCalledWith("1");
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it("should throw NotFoundError if delete fails", async () => {
|
|
req.params = { id: "1" };
|
|
(storageService.deleteVideo as any).mockReturnValue(false);
|
|
|
|
try {
|
|
await deleteVideo(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("NotFoundError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("rateVideo", () => {
|
|
it("should rate video", () => {
|
|
req.params = { id: "1" };
|
|
req.body = { rating: 5 };
|
|
const mockVideo = { id: "1", rating: 5 };
|
|
(storageService.updateVideo as any).mockReturnValue(mockVideo);
|
|
|
|
rateVideo(req as Request, res as Response);
|
|
|
|
expect(storageService.updateVideo).toHaveBeenCalledWith("1", {
|
|
rating: 5,
|
|
});
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith({ success: true, video: mockVideo });
|
|
});
|
|
|
|
it("should throw ValidationError for invalid rating", async () => {
|
|
req.params = { id: "1" };
|
|
req.body = { rating: 6 };
|
|
|
|
try {
|
|
await rateVideo(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("ValidationError");
|
|
}
|
|
});
|
|
|
|
it("should throw NotFoundError if video not found", async () => {
|
|
req.params = { id: "1" };
|
|
req.body = { rating: 5 };
|
|
(storageService.updateVideo as any).mockReturnValue(null);
|
|
|
|
try {
|
|
await rateVideo(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("NotFoundError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("updateVideoDetails", () => {
|
|
it("should update video details", () => {
|
|
req.params = { id: "1" };
|
|
req.body = { title: "New Title" };
|
|
const mockVideo = { id: "1", title: "New Title" };
|
|
(storageService.updateVideo as any).mockReturnValue(mockVideo);
|
|
|
|
updateVideoDetails(req as Request, res as Response);
|
|
|
|
expect(storageService.updateVideo).toHaveBeenCalledWith("1", {
|
|
title: "New Title",
|
|
});
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it("should update tags field", () => {
|
|
req.params = { id: "1" };
|
|
req.body = { tags: ["tag1", "tag2"] };
|
|
const mockVideo = { id: "1", tags: ["tag1", "tag2"] };
|
|
(storageService.updateVideo as any).mockReturnValue(mockVideo);
|
|
|
|
updateVideoDetails(req as Request, res as Response);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it("should throw NotFoundError if video not found", async () => {
|
|
req.params = { id: "1" };
|
|
req.body = { title: "New Title" };
|
|
(storageService.updateVideo as any).mockReturnValue(null);
|
|
|
|
try {
|
|
await updateVideoDetails(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("NotFoundError");
|
|
}
|
|
});
|
|
|
|
it("should throw ValidationError if no valid updates", async () => {
|
|
req.params = { id: "1" };
|
|
req.body = { invalid: "field" };
|
|
|
|
try {
|
|
await updateVideoDetails(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("ValidationError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("checkBilibiliParts", () => {
|
|
it("should check bilibili parts", async () => {
|
|
req.query = { url: "https://www.bilibili.com/video/BV1xx" };
|
|
(downloadService.checkBilibiliVideoParts as any).mockResolvedValue({
|
|
success: true,
|
|
});
|
|
|
|
await checkBilibiliParts(req as Request, res as Response);
|
|
|
|
expect(downloadService.checkBilibiliVideoParts).toHaveBeenCalled();
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it("should throw ValidationError if url is missing", async () => {
|
|
req.query = {};
|
|
try {
|
|
await checkBilibiliParts(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("ValidationError");
|
|
}
|
|
});
|
|
|
|
it("should throw ValidationError if url is invalid", async () => {
|
|
req.query = { url: "invalid" };
|
|
try {
|
|
await checkBilibiliParts(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("ValidationError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("checkBilibiliCollection", () => {
|
|
it("should check bilibili collection", async () => {
|
|
req.query = { url: "https://www.bilibili.com/video/BV1xx" };
|
|
(
|
|
downloadService.checkBilibiliCollectionOrSeries as any
|
|
).mockResolvedValue({ success: true });
|
|
|
|
await checkBilibiliCollection(req as Request, res as Response);
|
|
|
|
expect(
|
|
downloadService.checkBilibiliCollectionOrSeries
|
|
).toHaveBeenCalled();
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it("should throw ValidationError if url is missing", async () => {
|
|
req.query = {};
|
|
try {
|
|
await checkBilibiliCollection(req as Request, res as Response);
|
|
expect.fail("Should have thrown");
|
|
} catch (error: any) {
|
|
expect(error.name).toBe("ValidationError");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getVideoComments", () => {
|
|
it("should get video comments", async () => {
|
|
req.params = { id: "1" };
|
|
// Mock commentService dynamically since it's imported dynamically in controller
|
|
vi.mock("../../services/commentService", () => ({
|
|
getComments: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
await import("../../controllers/videoController").then((m) =>
|
|
m.getVideoComments(req as Request, res as Response)
|
|
);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith([]);
|
|
});
|
|
});
|
|
|
|
describe("uploadVideo", () => {
|
|
it("should upload video", async () => {
|
|
req.file = { filename: "vid.mp4", originalname: "vid.mp4" } as any;
|
|
req.body = { title: "Title" };
|
|
(fs.existsSync as any).mockReturnValue(true);
|
|
(fs.statSync as any).mockReturnValue({ size: 1024 });
|
|
(fs.ensureDirSync as any).mockImplementation(() => {});
|
|
|
|
// Set up mocks before importing the controller
|
|
const securityUtils = await import("../../utils/security");
|
|
vi.mocked(securityUtils.execFileSafe).mockResolvedValue({
|
|
stdout: "",
|
|
stderr: "",
|
|
});
|
|
vi.mocked(securityUtils.validateVideoPath).mockImplementation(
|
|
(path: string) => path
|
|
);
|
|
vi.mocked(securityUtils.validateImagePath).mockImplementation(
|
|
(path: string) => path
|
|
);
|
|
|
|
const metadataService = await import("../../services/metadataService");
|
|
vi.mocked(metadataService.getVideoDuration).mockResolvedValue(120);
|
|
|
|
await import("../../controllers/videoController").then((m) =>
|
|
m.uploadVideo(req as Request, res as Response)
|
|
);
|
|
|
|
expect(storageService.saveVideo).toHaveBeenCalled();
|
|
expect(status).toHaveBeenCalledWith(201);
|
|
});
|
|
});
|
|
|
|
describe("getDownloadStatus", () => {
|
|
it("should return download status", async () => {
|
|
(storageService.getDownloadStatus as any).mockReturnValue({
|
|
activeDownloads: [],
|
|
queuedDownloads: [],
|
|
});
|
|
|
|
await getDownloadStatus(req as Request, res as Response);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
});
|
|
});
|
|
});
|