test: add unit tests for configuration and download controllers and handle electron app initialization and improve devMode logic

This commit is contained in:
MasterAcnolo
2026-10-28 21:58:46 +01:00
parent f0f3018f80
commit f650770df7
9 changed files with 629 additions and 15 deletions

View File

@@ -1,5 +1,10 @@
const packageJson = require("./package.json"); const packageJson = require("./package.json");
const { app } = require("electron"); let app;
try {
app = require("electron").app;
} catch (e) {
app = null;
}
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -7,7 +12,7 @@ const path = require("path");
* Indicates whether the application is running in development mode. * Indicates whether the application is running in development mode.
* Determined by Electron's packaging state. * Determined by Electron's packaging state.
*/ */
const devMode = !app.isPackaged || process.env.NODE_ENV === "test"; const devMode = app && !app.isPackaged ? true : app ? !app.isPackaged || process.env.NODE_ENV === "test" : process.env.NODE_ENV !== "production";
/** /**
* Resolves the configuration file path depending on runtime environment. * Resolves the configuration file path depending on runtime environment.
@@ -85,10 +90,10 @@ function mergeMissingFeatures(defaults, current) {
throw new Error("User configuration must be a JSON object"); throw new Error("User configuration must be a JSON object");
} }
const features = { ...defaults, ...current }; const features = {...defaults, ...current};
const changed = Object.keys(defaults).some((key) => !Object.prototype.hasOwnProperty.call(current, key)); const changed = Object.keys(defaults).some((key) => !Object.prototype.hasOwnProperty.call(current, key));
return { features, changed }; return {features, changed};
} }
/** /**
@@ -100,7 +105,7 @@ function mergeMissingFeatures(defaults, current) {
function loadFeatures() { function loadFeatures() {
const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8")); const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8"));
const current = JSON.parse(fs.readFileSync(featuresPath, "utf-8")); const current = JSON.parse(fs.readFileSync(featuresPath, "utf-8"));
const { features, changed } = mergeMissingFeatures(defaults, current); const {features, changed} = mergeMissingFeatures(defaults, current);
if (changed) { if (changed) {
fs.writeFileSync(featuresPath, JSON.stringify(features, null, 2), "utf-8"); fs.writeFileSync(featuresPath, JSON.stringify(features, null, 2), "utf-8");

View File

@@ -1,8 +1,8 @@
const path = require("path"); const path = require("path");
jest.mock("../../server/helpers/getBrowser.helpers.js", () => jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({
jest.fn(() => "firefox") getUserBrowser: jest.fn(() => "firefox"),
); }));
jest.mock("../../server/helpers/path.helpers.js", () => ({ jest.mock("../../server/helpers/path.helpers.js", () => ({
ffmpegPath: "/mock/ffmpeg", ffmpegPath: "/mock/ffmpeg",

43
test/unit/config.test.js Normal file
View File

@@ -0,0 +1,43 @@
const {mergeMissingFeatures} = require("../../config");
describe("configuration migration", () => {
test("adds missing defaults and preserves user values", () => {
const result = mergeMissingFeatures(
{enabled: true, retries: 3},
{enabled: false}
);
expect(result).toEqual({
features: {enabled: false, retries: 3},
changed: true,
});
});
test("preserves unknown user-defined keys", () => {
const result = mergeMissingFeatures(
{enabled: true},
{enabled: false, customSetting: "keep me"}
);
expect(result).toEqual({
features: {enabled: false, customSetting: "keep me"},
changed: false,
});
});
test("reports no migration when all default keys exist", () => {
const result = mergeMissingFeatures(
{enabled: true, retries: 3},
{enabled: false, retries: 0}
);
expect(result.changed).toBe(false);
expect(result.features.retries).toBe(0);
});
test("rejects non-object configurations", () => {
expect(() => mergeMissingFeatures({}, [])).toThrow(
"User configuration must be a JSON object"
);
});
});

View File

@@ -0,0 +1,213 @@
jest.mock("../../server/services/download.services", () => ({
fetchDownload: jest.fn(),
cancelDownload: jest.fn(),
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock("../../server/helpers/validation.helpers", () => ({
isValidUrl: jest.fn(),
isSafePath: jest.fn(),
}));
jest.mock("../../server/helpers/notify.helpers", () => ({
notifyDownloadFinished: jest.fn(),
}));
jest.mock("../../config", () => ({
configFeatures: {
notifySystem: true,
},
}));
const {fetchDownload, cancelDownload} = require("../../server/services/download.services");
const {logger} = require("../../server/logger");
const {isValidUrl, isSafePath} = require("../../server/helpers/validation.helpers");
const {notifyDownloadFinished} = require("../../server/helpers/notify.helpers");
const {
downloadController,
cancelDownloadController,
progressController,
speedController,
stageController,
playlistInfoController,
} = require("../../server/controller/download.controller");
describe("download.controller", () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
req = {body: {}, on: jest.fn()};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
write: jest.fn().mockReturnThis(),
flushHeaders: jest.fn(),
end: jest.fn(),
};
});
describe("downloadController", () => {
test("returns 400 for invalid url", async () => {
isValidUrl.mockReturnValue(false);
req.body.url = "bad-url";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_URL", message: "Invalid URL"},
});
});
test("returns 400 for unsafe path", async () => {
isValidUrl.mockReturnValue(true);
isSafePath.mockReturnValue(false);
req.body.url = "https://youtube.com/watch?v=123";
req.body.savePath = "C:\\Windows\\System32";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_PATH", message: "Save path not allowed"},
});
expect(logger.warn).toHaveBeenCalled();
});
test("starts download and returns success", async () => {
isValidUrl.mockReturnValue(true);
isSafePath.mockReturnValue(true);
req.body = {
url: "https://youtube.com/watch?v=123",
audioOnly: "1",
quality: "720",
savePath: "/home/user/Downloads",
playlistTitle: "My Playlist",
};
fetchDownload.mockResolvedValue("/home/user/Downloads");
await downloadController(req, res);
expect(fetchDownload).toHaveBeenCalled();
const callArgs = fetchDownload.mock.calls[0][0];
expect(callArgs.url).toBe("https://youtube.com/watch?v=123");
expect(callArgs.audioOnly).toBe(true);
expect(callArgs.quality).toBe("720");
expect(callArgs.outputFolder).toBe("/home/user/Downloads");
expect(callArgs.playlistTitle).toBe("My Playlist");
expect(notifyDownloadFinished).toHaveBeenCalledWith(
"/home/user/Downloads",
true
);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: "Download Done !",
outputFolder: "/home/user/Downloads",
});
});
test("defaults quality to best for invalid quality", async () => {
isValidUrl.mockReturnValue(true);
fetchDownload.mockResolvedValue("/downloads");
req.body = {url: "https://youtube.com/watch?v=123", quality: "invalid"};
await downloadController(req, res);
const callArgs = fetchDownload.mock.calls[0][0];
expect(callArgs.quality).toBe("best");
});
test("returns 409 when download already in progress", async () => {
isValidUrl.mockReturnValue(true);
fetchDownload.mockRejectedValue(new Error("Download already in progress"));
req.body.url = "https://youtube.com/watch?v=123";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
error: {code: "DOWNLOAD_IN_PROGRESS", message: "A download is already in progress"},
});
});
test("returns 500 on generic error", async () => {
isValidUrl.mockReturnValue(true);
fetchDownload.mockRejectedValue(new Error("yt-dlp failed"));
req.body.url = "https://youtube.com/watch?v=123";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: {code: "DOWNLOAD_FAILED", message: "Server Error"},
});
expect(logger.error).toHaveBeenCalled();
});
});
describe("cancelDownloadController", () => {
test("returns success when download cancelled", () => {
cancelDownload.mockReturnValue(true);
cancelDownloadController(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: "Download stopped! Queue cleared.",
});
expect(logger.info).toHaveBeenCalled();
});
test("returns 400 when no download in progress", () => {
cancelDownload.mockReturnValue(false);
cancelDownloadController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "NO_DOWNLOAD_IN_PROGRESS", message: "No download in progress !"},
});
expect(logger.warn).toHaveBeenCalled();
});
});
describe("SSE controllers", () => {
test("progressController sets headers and writes data", () => {
req.on = jest.fn((event, cb) => {
if (event === "close") {
// don't call immediately
}
});
progressController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
})
);
expect(res.flushHeaders).toHaveBeenCalled();
});
test("speedController sets headers", () => {
req.on = jest.fn();
speedController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
})
);
});
test("stageController sets headers", () => {
req.on = jest.fn();
stageController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
})
);
});
test("playlistInfoController sets headers", () => {
req.on = jest.fn();
playlistInfoController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
})
);
});
});
});

View File

@@ -0,0 +1,83 @@
jest.mock("../../server/services/info.services", () => ({
fetchNormalizedInfo: jest.fn(),
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
jest.mock("../../server/helpers/validation.helpers", () => ({
isValidUrl: jest.fn(),
}));
const {fetchNormalizedInfo} = require("../../server/services/info.services");
const {logger} = require("../../server/logger");
const {isValidUrl} = require("../../server/helpers/validation.helpers");
const {infoController} = require("../../server/controller/info.controller");
describe("info.controller", () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
req = {query: {}, body: {}};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
});
test("returns 400 for missing url", async () => {
isValidUrl.mockReturnValue(false);
req.query.url = undefined;
await infoController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
});
});
test("returns 400 for invalid url", async () => {
isValidUrl.mockReturnValue(false);
req.query.url = "not-a-url";
await infoController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
});
});
test("accepts url from body", async () => {
isValidUrl.mockReturnValue(true);
req.body.url = "https://youtube.com/watch?v=123";
fetchNormalizedInfo.mockResolvedValue({type: "video", title: "Test"});
await infoController(req, res);
expect(fetchNormalizedInfo).toHaveBeenCalledWith("https://youtube.com/watch?v=123");
expect(res.json).toHaveBeenCalledWith({type: "video", title: "Test"});
});
test("returns normalized result for valid url", async () => {
isValidUrl.mockReturnValue(true);
req.query.url = "https://youtube.com/watch?v=123";
const mockResult = {type: "video", title: "My Video", duration: 120};
fetchNormalizedInfo.mockResolvedValue(mockResult);
await infoController(req, res);
expect(fetchNormalizedInfo).toHaveBeenCalledWith("https://youtube.com/watch?v=123");
expect(res.json).toHaveBeenCalledWith(mockResult);
expect(logger.info).toHaveBeenCalled();
});
test("returns 500 on service error", async () => {
isValidUrl.mockReturnValue(true);
req.query.url = "https://youtube.com/watch?v=123";
fetchNormalizedInfo.mockRejectedValue(new Error("fetch failed"));
await infoController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
});
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,161 @@
jest.mock("child_process");
jest.mock("../../server/helpers/path.helpers", () => ({
userYtDlp: "/mock/yt-dlp",
denoPath: "/mock/deno",
}));
jest.mock("../../server/helpers/getBrowser.helpers", () => ({
getUserBrowser: jest.fn(() => "firefox"),
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock("../../server/helpers/parseInfo.helpers", () => ({
parseVideo: jest.fn((data) => ({type: "video", ...data})),
parsePlaylist: jest.fn((data) => ({
type: "playlist",
title: data.title,
count: data.entries ? data.entries.length : 0,
videos: [],
})),
}));
jest.mock("../../server/helpers/playlist.helpers", () => ({
isUrlPlaylist: jest.fn(),
}));
const {execFile} = require("child_process");
const {fetchInfo, fetchNormalizedInfo} = require("../../server/services/info.services");
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
const {isUrlPlaylist} = require("../../server/helpers/playlist.helpers");
const {logger} = require("../../server/logger");
describe("info.services", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("fetchInfo", () => {
test("resolves parsed json on success", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, JSON.stringify({_type: "video", title: "Test"}), "");
});
const result = await fetchInfo("https://youtube.com/watch?v=123");
expect(result).toEqual({_type: "video", title: "Test"});
expect(execFile).toHaveBeenCalled();
});
test("rejects when cookies error in stderr", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, "", "sign in to confirm you're not a bot");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
/Unable to extract cookies/
);
});
test("rejects on exec error", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(new Error("spawn failed"), "", "");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
"Unable to fetch Info."
);
});
test("rejects when no stdout", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, "", "");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
"No Data Received."
);
});
test("rejects on invalid json", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, "not json", "");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
"JSON unreadable."
);
});
});
describe("fetchNormalizedInfo", () => {
test("normalizes video", async () => {
isUrlPlaylist.mockReturnValue(false);
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, JSON.stringify({_type: "video", title: "My Video"}), "");
});
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123");
expect(result.type).toBe("video");
expect(parseVideo).toHaveBeenCalled();
});
test("normalizes playlist", async () => {
isUrlPlaylist.mockReturnValue(true);
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(
null,
JSON.stringify({
_type: "playlist",
title: "Playlist",
entries: [{id: "1"}, {id: "2"}],
}),
""
);
});
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
expect(result.type).toBe("playlist");
expect(parsePlaylist).toHaveBeenCalled();
});
test("converts playlist with 1 entry to video", async () => {
isUrlPlaylist.mockReturnValue(true);
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(
null,
JSON.stringify({
_type: "playlist",
title: "Playlist",
entries: [{id: "1", title: "Single"}],
}),
""
);
});
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
expect(result.type).toBe("video");
expect(parseVideo).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Playlist with 1 item converted to video")
);
});
test("falls back to single video when playlist fetch fails", async () => {
isUrlPlaylist.mockReturnValue(true);
let callCount = 0;
execFile.mockImplementation((cmd, args, opts, cb) => {
callCount++;
if (callCount === 1) {
cb(new Error("fail"), "", "");
} else {
cb(null, JSON.stringify({_type: "video", title: "Fallback"}), "");
}
});
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123&list=ABC");
expect(result.type).toBe("video");
expect(callCount).toBe(2);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Retrying as single video")
);
});
test("throws when url is invalid type", async () => {
await expect(fetchNormalizedInfo(null)).rejects.toThrow("Invalid URL");
});
});
});

View File

@@ -1,6 +1,6 @@
const mockShow = jest.fn(); const mockShow = jest.fn();
const mockOn = jest.fn(); const mockOn = jest.fn();
const mockOpenPath = jest.fn(); const mockOpenPath = jest.fn().mockResolvedValue("");
const mockOpenExternal = jest.fn(); const mockOpenExternal = jest.fn();
jest.mock("electron", () => ({ jest.mock("electron", () => ({
@@ -71,13 +71,16 @@ describe("notify.helpers", () => {
expect(mockShow).toHaveBeenCalled(); expect(mockShow).toHaveBeenCalled();
}); });
test("opens folder when notification is clicked", () => { test("opens folder when notification is clicked", (done) => {
notifyDownloadFinished("/downloads"); notifyDownloadFinished("/downloads");
const callback = mockOn.mock.calls[0][1]; const callback = mockOn.mock.calls[0][1];
callback(); callback();
setTimeout(() => {
expect(shell.openPath).toHaveBeenCalledWith("/downloads"); expect(shell.openPath).toHaveBeenCalledWith("/downloads");
done();
}, 200);
}); });
}); });
@@ -101,7 +104,7 @@ describe("notify.helpers", () => {
callback(); callback();
expect(shell.openExternal).toHaveBeenCalledWith( expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1" "https://www.firefox.com/en-US/download/"
); );
}); });
}); });
@@ -126,7 +129,7 @@ describe("notify.helpers", () => {
callback(); callback();
expect(shell.openExternal).toHaveBeenCalledWith( expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1" "https://www.firefox.com/en-US/download/"
); );
}); });
}); });

View File

@@ -0,0 +1,66 @@
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
describe("parseInfo.helpers", () => {
describe("parseVideo", () => {
test("adds type video and spreads data", () => {
const data = {title: "Test Video", duration: 120, id: "abc123"};
const result = parseVideo(data);
expect(result.type).toBe("video");
expect(result.title).toBe("Test Video");
expect(result.duration).toBe(120);
expect(result.id).toBe("abc123");
});
});
describe("parsePlaylist", () => {
test("normalizes playlist with entries", () => {
const data = {
title: "My Playlist",
uploader: "Test Channel",
entries: [
{
id: "vid1",
title: "Video 1",
webpage_url: "https://example.com/watch?v=vid1",
duration: 60,
thumbnail: "thumb1.jpg",
uploader: "Uploader1",
},
{
id: "vid2",
title: "Video 2",
webpage_url: "https://example.com/watch?v=vid2",
duration: 90,
thumbnail: "thumb2.jpg",
uploader: "Uploader2",
},
],
};
const result = parsePlaylist(data);
expect(result.type).toBe("playlist");
expect(result.title).toBe("My Playlist");
expect(result.channel).toBe("Test Channel");
expect(result.count).toBe(2);
expect(result.videos).toHaveLength(2);
expect(result.videos[0]).toEqual({
id: "vid1",
title: "Video 1",
url: "https://example.com/watch?v=vid1",
duration: 60,
thumbnail: "thumb1.jpg",
uploader: "Uploader1",
});
});
test("falls back to id when title missing", () => {
const data = {
id: "PL123",
entries: [],
};
const result = parsePlaylist(data);
expect(result.title).toBe("PL123");
expect(result.count).toBe(0);
expect(result.videos).toEqual([]);
});
});
});

View File

@@ -0,0 +1,40 @@
const {isValidUrl, isSafePath} = require("../../server/helpers/validation.helpers");
describe("validation.helpers", () => {
describe("isValidUrl", () => {
test("returns true for valid http url", () => {
expect(isValidUrl("http://example.com")).toBe(true);
});
test("returns true for valid https url", () => {
expect(isValidUrl("https://youtube.com/watch?v=123")).toBe(true);
});
test("returns false for invalid url", () => {
expect(isValidUrl("not-a-url")).toBe(false);
});
test("returns false for empty string", () => {
expect(isValidUrl("")).toBe(false);
});
});
describe("isSafePath", () => {
test("returns false for empty or short path", () => {
expect(isSafePath("")).toBe(false);
expect(isSafePath("ab")).toBe(false);
});
test("returns false for Windows System32 path", () => {
expect(isSafePath("C:\\Windows\\System32\\test")).toBe(false);
});
test("returns false for Windows path containing Windows", () => {
expect(isSafePath("C:\\Windows\\SomeFolder")).toBe(false);
});
test("returns true for safe user path", () => {
expect(isSafePath("/home/user/Downloads")).toBe(true);
});
});
});