diff --git a/config.js b/config.js index 66f6910..70ea412 100644 --- a/config.js +++ b/config.js @@ -1,5 +1,10 @@ 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 path = require("path"); @@ -7,7 +12,7 @@ const path = require("path"); * Indicates whether the application is running in development mode. * 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. @@ -64,8 +69,8 @@ const featuresPath = resolveConfigPath(); */ function getDefaultConfigPath() { return devMode - ? path.join(__dirname, "config", "config.default.json") - : path.join(process.resourcesPath, "config", "config.default.json"); + ? path.join(__dirname, "config", "config.default.json") + : path.join(process.resourcesPath, "config", "config.default.json"); } /** @@ -85,10 +90,10 @@ function mergeMissingFeatures(defaults, current) { 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)); - return { features, changed }; + return {features, changed}; } /** @@ -100,7 +105,7 @@ function mergeMissingFeatures(defaults, current) { function loadFeatures() { const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "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) { fs.writeFileSync(featuresPath, JSON.stringify(features, null, 2), "utf-8"); diff --git a/test/unit/buildArgs.test.js b/test/unit/buildArgs.test.js index 920de22..b812ab1 100644 --- a/test/unit/buildArgs.test.js +++ b/test/unit/buildArgs.test.js @@ -1,8 +1,8 @@ const path = require("path"); -jest.mock("../../server/helpers/getBrowser.helpers.js", () => - jest.fn(() => "firefox") -); +jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({ + getUserBrowser: jest.fn(() => "firefox"), +})); jest.mock("../../server/helpers/path.helpers.js", () => ({ ffmpegPath: "/mock/ffmpeg", diff --git a/test/unit/config.test.js b/test/unit/config.test.js new file mode 100644 index 0000000..0ef754d --- /dev/null +++ b/test/unit/config.test.js @@ -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" + ); + }); +}); diff --git a/test/unit/download.controller.test.js b/test/unit/download.controller.test.js new file mode 100644 index 0000000..75e358a --- /dev/null +++ b/test/unit/download.controller.test.js @@ -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", + }) + ); + }); + }); +}); diff --git a/test/unit/info.controller.test.js b/test/unit/info.controller.test.js new file mode 100644 index 0000000..ebaccf8 --- /dev/null +++ b/test/unit/info.controller.test.js @@ -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(); + }); +}); diff --git a/test/unit/info.services.test.js b/test/unit/info.services.test.js new file mode 100644 index 0000000..e7eae3e --- /dev/null +++ b/test/unit/info.services.test.js @@ -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"); + }); + }); +}); diff --git a/test/unit/notify.helpers.test.js b/test/unit/notify.helpers.test.js index 21d460e..b873ae3 100644 --- a/test/unit/notify.helpers.test.js +++ b/test/unit/notify.helpers.test.js @@ -1,6 +1,6 @@ const mockShow = jest.fn(); const mockOn = jest.fn(); -const mockOpenPath = jest.fn(); +const mockOpenPath = jest.fn().mockResolvedValue(""); const mockOpenExternal = jest.fn(); jest.mock("electron", () => ({ @@ -71,13 +71,16 @@ describe("notify.helpers", () => { expect(mockShow).toHaveBeenCalled(); }); - test("opens folder when notification is clicked", () => { + test("opens folder when notification is clicked", (done) => { notifyDownloadFinished("/downloads"); const callback = mockOn.mock.calls[0][1]; callback(); - expect(shell.openPath).toHaveBeenCalledWith("/downloads"); + setTimeout(() => { + expect(shell.openPath).toHaveBeenCalledWith("/downloads"); + done(); + }, 200); }); }); @@ -101,7 +104,7 @@ describe("notify.helpers", () => { callback(); 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(); expect(shell.openExternal).toHaveBeenCalledWith( - "https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1" + "https://www.firefox.com/en-US/download/" ); }); }); diff --git a/test/unit/parseInfo.helpers.test.js b/test/unit/parseInfo.helpers.test.js new file mode 100644 index 0000000..e944567 --- /dev/null +++ b/test/unit/parseInfo.helpers.test.js @@ -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([]); + }); + }); +}); diff --git a/test/unit/validation.helpers.test.js b/test/unit/validation.helpers.test.js new file mode 100644 index 0000000..6eadbb9 --- /dev/null +++ b/test/unit/validation.helpers.test.js @@ -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); + }); + }); +});