From 5bf187816a43fdb389ec4745ea5b4c0e5ae0df36 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:42:42 -0800 Subject: [PATCH] test: add widget proxy tests (batch 2) --- src/widgets/booklore/proxy.test.js | 98 ++++++++++++++++++++++ src/widgets/calendar/proxy.test.js | 95 ++++++++++++++++++++++ src/widgets/deluge/proxy.test.js | 63 +++++++++++++++ src/widgets/dispatcharr/proxy.test.js | 108 +++++++++++++++++++++++++ src/widgets/filebrowser/proxy.test.js | 79 ++++++++++++++++++ src/widgets/kavita/proxy.test.js | 97 ++++++++++++++++++++++ src/widgets/komga/proxy.test.js | 76 +++++++++++++++++ src/widgets/qbittorrent/proxy.test.js | 67 +++++++++++++++ src/widgets/transmission/proxy.test.js | 79 ++++++++++++++++++ src/widgets/unraid/proxy.test.js | 85 +++++++++++++++++++ 10 files changed, 847 insertions(+) create mode 100644 src/widgets/booklore/proxy.test.js create mode 100644 src/widgets/calendar/proxy.test.js create mode 100644 src/widgets/deluge/proxy.test.js create mode 100644 src/widgets/dispatcharr/proxy.test.js create mode 100644 src/widgets/filebrowser/proxy.test.js create mode 100644 src/widgets/kavita/proxy.test.js create mode 100644 src/widgets/komga/proxy.test.js create mode 100644 src/widgets/qbittorrent/proxy.test.js create mode 100644 src/widgets/transmission/proxy.test.js create mode 100644 src/widgets/unraid/proxy.test.js diff --git a/src/widgets/booklore/proxy.test.js b/src/widgets/booklore/proxy.test.js new file mode 100644 index 000000000..6cfc26a13 --- /dev/null +++ b/src/widgets/booklore/proxy.test.js @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => { + const store = new Map(); + + return { + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + cache: { + get: vi.fn((k) => store.get(k)), + put: vi.fn((k, v) => store.set(k, v)), + del: vi.fn((k) => store.delete(k)), + _reset: () => store.clear(), + }, + logger: { + debug: vi.fn(), + error: vi.fn(), + }, + }; +}); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("memory-cache", () => ({ + default: cache, + ...cache, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + booklore: { + api: "{url}/{endpoint}", + }, + }, +})); + +import bookloreProxyHandler from "./proxy"; + +describe("widgets/booklore/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("returns 400 when Booklore credentials are missing", async () => { + getServiceWidget.mockResolvedValue({ type: "booklore", url: "http://booklore" }); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await bookloreProxyHandler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Missing Booklore credentials" }); + }); + + it("logs in and summarizes libraries and book statuses", async () => { + getServiceWidget.mockResolvedValue({ + type: "booklore", + url: "http://booklore", + username: "u", + password: "p", + }); + + const books = [{ readStatus: "reading" }, { readStatus: "read" }, { readStatus: "READ" }, { readStatus: "other" }]; + + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ accessToken: "tok" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([{ id: 1 }, { id: 2 }]))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify(books))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await bookloreProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + libraries: 2, + books: 4, + reading: 1, + finished: 2, + }); + }); +}); diff --git a/src/widgets/calendar/proxy.test.js b/src/widgets/calendar/proxy.test.js new file mode 100644 index 000000000..c9f6dd66d --- /dev/null +++ b/src/widgets/calendar/proxy.test.js @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + debug: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +import calendarProxyHandler from "./proxy"; + +describe("widgets/calendar/proxy", () => { + const envVersion = process.env.NEXT_PUBLIC_VERSION; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_VERSION = envVersion; + }); + + it("returns 400 when integration is missing", async () => { + getServiceWidget.mockResolvedValue({ integrations: [] }); + + const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } }; + const res = createMockRes(); + + await calendarProxyHandler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Invalid integration" }); + }); + + it("returns 403 when integration has no URL", async () => { + getServiceWidget.mockResolvedValue({ integrations: [{ name: "foo", url: "" }] }); + + const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } }; + const res = createMockRes(); + + await calendarProxyHandler(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "No integration URL specified" }); + }); + + it("adds a User-Agent for Outlook integrations and returns string data", async () => { + process.env.NEXT_PUBLIC_VERSION = "1.2.3"; + getServiceWidget.mockResolvedValue({ + integrations: [{ name: "outlook", url: "https://example.com/outlook.ics" }], + }); + + httpProxy.mockResolvedValueOnce([200, "text/calendar", Buffer.from("CAL")]); + + const req = { query: { group: "g", service: "svc", endpoint: "outlook", index: "0" } }; + const res = createMockRes(); + + await calendarProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledWith("https://example.com/outlook.ics", { + headers: { "User-Agent": "gethomepage/1.2.3" }, + }); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/calendar"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ data: "CAL" }); + }); + + it("passes through non-200 status codes from integrations", async () => { + getServiceWidget.mockResolvedValue({ + integrations: [{ name: "foo", url: "https://example.com/foo.ics" }], + }); + + httpProxy.mockResolvedValueOnce([503, "text/plain", Buffer.from("nope")]); + + const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } }; + const res = createMockRes(); + + await calendarProxyHandler(req, res); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual(Buffer.from("nope")); + }); +}); diff --git a/src/widgets/deluge/proxy.test.js b/src/widgets/deluge/proxy.test.js new file mode 100644 index 000000000..58b01890a --- /dev/null +++ b/src/widgets/deluge/proxy.test.js @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { sendJsonRpcRequest, getServiceWidget, logger } = vi.hoisted(() => ({ + sendJsonRpcRequest: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + debug: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/handlers/jsonrpc", () => ({ + sendJsonRpcRequest, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + deluge: { + api: "{url}", + }, + }, +})); + +import delugeProxyHandler from "./proxy"; + +describe("widgets/deluge/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs in and retries the update call after an auth error", async () => { + getServiceWidget.mockResolvedValue({ type: "deluge", url: "http://deluge", password: "pw" }); + + sendJsonRpcRequest + // update_ui -> error code 1 => 403 + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ error: { code: 1 } }))]) + // auth.login -> ok + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ result: true }))]) + // update_ui retry -> ok + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ result: { torrents: {} } }))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await delugeProxyHandler(req, res); + + expect(sendJsonRpcRequest).toHaveBeenCalledTimes(3); + expect(sendJsonRpcRequest.mock.calls[0][1]).toBe("web.update_ui"); + expect(sendJsonRpcRequest.mock.calls[1][1]).toBe("auth.login"); + expect(sendJsonRpcRequest.mock.calls[2][1]).toBe("web.update_ui"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from(JSON.stringify({ result: { torrents: {} } }))); + }); +}); diff --git a/src/widgets/dispatcharr/proxy.test.js b/src/widgets/dispatcharr/proxy.test.js new file mode 100644 index 000000000..96bf1d7c1 --- /dev/null +++ b/src/widgets/dispatcharr/proxy.test.js @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => { + const store = new Map(); + + return { + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + cache: { + get: vi.fn((k) => store.get(k)), + put: vi.fn((k, v) => store.set(k, v)), + del: vi.fn((k) => store.delete(k)), + _reset: () => store.clear(), + }, + logger: { + debug: vi.fn(), + error: vi.fn(), + }, + }; +}); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("memory-cache", () => ({ + default: cache, + ...cache, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + dispatcharr: { + api: "{url}/{endpoint}", + mappings: { + token: { endpoint: "auth/token" }, + }, + }, + }, +})); + +import dispatcharrProxyHandler from "./proxy"; + +describe("widgets/dispatcharr/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("logs in when token is missing and uses Bearer token for requests", async () => { + getServiceWidget.mockResolvedValue({ + type: "dispatcharr", + url: "http://dispatcharr", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ access: "t1" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); + + const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } }; + const res = createMockRes(); + + await dispatcharrProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(httpProxy.mock.calls[0][0].toString()).toBe("http://dispatcharr/auth/token"); + expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("Bearer t1"); + expect(res.body).toEqual(Buffer.from("data")); + }); + + it("retries after a bad response by clearing cache and logging in again", async () => { + cache.put("dispatcharrProxyHandler__token.svc", "old"); + + getServiceWidget.mockResolvedValue({ + type: "dispatcharr", + url: "http://dispatcharr", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([400, "application/json", Buffer.from(JSON.stringify({ items: [] }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ access: "new" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]); + + const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } }; + const res = createMockRes(); + + await dispatcharrProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[1][0].toString()).toBe("http://dispatcharr/auth/token"); + expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new"); + expect(res.body).toEqual(Buffer.from("ok")); + }); +}); diff --git a/src/widgets/filebrowser/proxy.test.js b/src/widgets/filebrowser/proxy.test.js new file mode 100644 index 000000000..c4caf4979 --- /dev/null +++ b/src/widgets/filebrowser/proxy.test.js @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + filebrowser: { + api: "{url}/{endpoint}", + }, + }, +})); + +import filebrowserProxyHandler from "./proxy"; + +describe("widgets/filebrowser/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs in and uses X-AUTH token for subsequent requests", async () => { + getServiceWidget.mockResolvedValue({ + type: "filebrowser", + url: "http://fb", + username: "u", + password: "p", + authHeader: "X-User", + }); + + httpProxy + .mockResolvedValueOnce([200, "text/plain", "token123"]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); + + const req = { query: { group: "g", service: "svc", endpoint: "api/raw", index: "0" } }; + const res = createMockRes(); + + await filebrowserProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(httpProxy.mock.calls[0][0]).toBe("http://fb/login"); + expect(httpProxy.mock.calls[0][1].headers).toEqual({ "X-User": "u" }); + expect(httpProxy.mock.calls[1][1].headers["X-AUTH"]).toBe("token123"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("data")); + }); + + it("returns 500 when login fails", async () => { + getServiceWidget.mockResolvedValue({ type: "filebrowser", url: "http://fb", username: "u", password: "p" }); + httpProxy.mockResolvedValueOnce([401, "text/plain", "nope"]); + + const req = { query: { group: "g", service: "svc", endpoint: "api/raw", index: "0" } }; + const res = createMockRes(); + + await filebrowserProxyHandler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "Failed to authenticate with Filebrowser" }); + }); +}); diff --git a/src/widgets/kavita/proxy.test.js b/src/widgets/kavita/proxy.test.js new file mode 100644 index 000000000..9d65c1afb --- /dev/null +++ b/src/widgets/kavita/proxy.test.js @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => { + const store = new Map(); + + return { + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + cache: { + get: vi.fn((k) => store.get(k)), + put: vi.fn((k, v) => store.set(k, v)), + del: vi.fn((k) => store.delete(k)), + _reset: () => store.clear(), + }, + logger: { + debug: vi.fn(), + error: vi.fn(), + }, + }; +}); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("memory-cache", () => ({ + default: cache, + ...cache, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + kavita: { + api: "{url}/{endpoint}", + }, + }, +})); + +import kavitaProxyHandler from "./proxy"; + +describe("widgets/kavita/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("logs in and returns server stats", async () => { + getServiceWidget.mockResolvedValue({ type: "kavita", url: "http://kv", username: "u", password: "p" }); + + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "tok" }))]) + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from(JSON.stringify({ seriesCount: 5, totalFiles: 100 })), + ]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await kavitaProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ seriesCount: 5, totalFiles: 100 }); + }); + + it("retries after a 401 by obtaining a new session token", async () => { + cache.put("kavitaProxyHandler__sessionToken.svc", "old"); + + getServiceWidget.mockResolvedValue({ type: "kavita", url: "http://kv", username: "u", password: "p" }); + + httpProxy + .mockResolvedValueOnce([401, "application/json", Buffer.from("{}")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "newtok" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ seriesCount: 1, totalFiles: 2 }))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await kavitaProxyHandler(req, res); + + const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("Account/login")); + expect(loginCalls).toHaveLength(1); + expect(res.body).toEqual({ seriesCount: 1, totalFiles: 2 }); + }); +}); diff --git a/src/widgets/komga/proxy.test.js b/src/widgets/komga/proxy.test.js new file mode 100644 index 000000000..03531db47 --- /dev/null +++ b/src/widgets/komga/proxy.test.js @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + error: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + komga: { + api: "{url}/{endpoint}", + mappings: { + series: { endpoint: "series" }, + books: { endpoint: "books" }, + seriesv2: { endpoint: "series/v2" }, + booksv2: { endpoint: "books/v2" }, + }, + }, + }, +})); + +import komgaProxyHandler from "./proxy"; + +describe("widgets/komga/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches libraries, series, and books and returns aggregated data", async () => { + getServiceWidget.mockResolvedValue({ type: "komga", url: "http://kg", key: "k" }); + + httpProxy + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from( + JSON.stringify([ + { id: 1, unavailable: false }, + { id: 2, unavailable: true }, + ]), + ), + ]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([{ id: "s1" }]))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([{ id: "b1" }]))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await komgaProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[0][1].headers["X-API-Key"]).toBe("k"); + expect(res.body).toEqual({ + libraries: [{ id: 1, unavailable: false }], + series: [{ id: "s1" }], + books: [{ id: "b1" }], + }); + }); +}); diff --git a/src/widgets/qbittorrent/proxy.test.js b/src/widgets/qbittorrent/proxy.test.js new file mode 100644 index 000000000..8c0754e75 --- /dev/null +++ b/src/widgets/qbittorrent/proxy.test.js @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +import qbittorrentProxyHandler from "./proxy"; + +describe("widgets/qbittorrent/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs in and retries after a 403 response", async () => { + getServiceWidget.mockResolvedValue({ url: "http://qb", username: "u", password: "p" }); + + httpProxy + .mockResolvedValueOnce([403, "application/json", Buffer.from("nope")]) + .mockResolvedValueOnce([200, "text/plain", Buffer.from("Ok.")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); + + const req = { query: { group: "g", service: "svc", endpoint: "torrents/info", index: "0" } }; + const res = createMockRes(); + + await qbittorrentProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[1][0]).toBe("http://qb/api/v2/auth/login"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("data")); + }); + + it("returns 401 when login succeeds but response body is not Ok.", async () => { + getServiceWidget.mockResolvedValue({ url: "http://qb", username: "u", password: "p" }); + + httpProxy + .mockResolvedValueOnce([403, "application/json", Buffer.from("nope")]) + .mockResolvedValueOnce([200, "text/plain", Buffer.from("Denied")]); + + const req = { query: { group: "g", service: "svc", endpoint: "torrents/info", index: "0" } }; + const res = createMockRes(); + + await qbittorrentProxyHandler(req, res); + + expect(res.statusCode).toBe(401); + expect(res.body).toEqual(Buffer.from("Denied")); + }); +}); diff --git a/src/widgets/transmission/proxy.test.js b/src/widgets/transmission/proxy.test.js new file mode 100644 index 000000000..80b4dc0e3 --- /dev/null +++ b/src/widgets/transmission/proxy.test.js @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => { + const store = new Map(); + + return { + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + cache: { + get: vi.fn((k) => store.get(k)), + put: vi.fn((k, v) => store.set(k, v)), + del: vi.fn((k) => store.delete(k)), + _reset: () => store.clear(), + }, + logger: { + debug: vi.fn(), + error: vi.fn(), + }, + }; +}); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("memory-cache", () => ({ + default: cache, + ...cache, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + transmission: { + rpcUrl: "/transmission/", + }, + }, +})); + +import transmissionProxyHandler from "./proxy"; + +describe("widgets/transmission/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("retries after a 409 response by caching the CSRF header", async () => { + getServiceWidget.mockResolvedValue({ + type: "transmission", + url: "http://tr", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([409, "application/json", Buffer.from("nope"), { "x-transmission-session-id": "csrf" }]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("ok"), {}]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await transmissionProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(httpProxy.mock.calls[1][1].headers["x-transmission-session-id"]).toBe("csrf"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("ok")); + }); +}); diff --git a/src/widgets/unraid/proxy.test.js b/src/widgets/unraid/proxy.test.js new file mode 100644 index 000000000..901a73831 --- /dev/null +++ b/src/widgets/unraid/proxy.test.js @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +import unraidProxyHandler from "./proxy"; + +describe("widgets/unraid/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls the Unraid GraphQL endpoint and returns a flattened response", async () => { + getServiceWidget.mockResolvedValue({ url: "http://unraid", key: "k" }); + + httpProxy.mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from( + JSON.stringify({ + data: { + metrics: { memory: { active: 10, available: 90, percentTotal: 10 }, cpu: { percentTotal: 5 } }, + notifications: { overview: { unread: { total: 2 } } }, + array: { + state: "STARTED", + capacity: { kilobytes: { free: 10, used: 20, total: 40 } }, + caches: [{ name: "cache", fsType: "btrfs", fsSize: 100, fsFree: 25, fsUsed: 75 }], + }, + }, + }), + ), + ]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await unraidProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(1); + expect(httpProxy.mock.calls[0][0].toString()).toBe("http://unraid/graphql"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + memoryUsedPercent: 10, + cpuPercent: 5, + unreadNotifications: 2, + arrayState: "STARTED", + }), + ); + expect(res.body.caches.cache.fsUsedPercent).toBe(75); + }); + + it("returns 500 when the response cannot be processed", async () => { + getServiceWidget.mockResolvedValue({ url: "http://unraid", key: "k" }); + httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("not-json")]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await unraidProxyHandler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual(expect.objectContaining({ error: expect.any(String) })); + }); +});