From a6eec4bfde8e562ea9aa68678e427dc58398caf1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:25:19 -0800 Subject: [PATCH] test: increase coverage for index, webmanifest, and services proxy --- .../pages/api/services/proxy.test.js | 234 +++++++++- src/__tests__/pages/index.test.js | 102 ----- src/__tests__/pages/index.test.jsx | 400 ++++++++++++++++++ src/__tests__/pages/site.webmanifest.test.js | 37 ++ 4 files changed, 668 insertions(+), 105 deletions(-) delete mode 100644 src/__tests__/pages/index.test.js create mode 100644 src/__tests__/pages/index.test.jsx diff --git a/src/__tests__/pages/api/services/proxy.test.js b/src/__tests__/pages/api/services/proxy.test.js index ec60c0a4e..372962b73 100644 --- a/src/__tests__/pages/api/services/proxy.test.js +++ b/src/__tests__/pages/api/services/proxy.test.js @@ -1,6 +1,12 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() })); +const { state, getServiceWidget, calendarProxy } = vi.hoisted(() => ({ + state: { + genericResult: { ok: true }, + }, + getServiceWidget: vi.fn(), + calendarProxy: vi.fn(), +})); vi.mock("utils/logger", () => ({ default: () => ({ debug: vi.fn(), error: vi.fn() }), @@ -12,7 +18,7 @@ const handlerFn = vi.hoisted(() => ({ handler: vi.fn() })); vi.mock("utils/proxy/handlers/generic", () => ({ default: handlerFn.handler })); // Calendar proxy is only used for an exception; keep it stubbed. -vi.mock("widgets/calendar/proxy", () => ({ default: vi.fn() })); +vi.mock("widgets/calendar/proxy", () => ({ default: calendarProxy })); // Provide a minimal widget registry for mapping tests. vi.mock("widgets/widgets", () => ({ @@ -23,6 +29,36 @@ vi.mock("widgets/widgets", () => ({ collections: { endpoint: "collections" }, }, }, + segments: { + api: "{url}/{endpoint}", + mappings: { + item: { endpoint: "items/{id}", segments: ["id"] }, + }, + }, + queryparams: { + api: "{url}/{endpoint}", + mappings: { + list: { endpoint: "list", params: ["limit"], optionalParams: ["q"] }, + }, + }, + endpointproxy: { + api: "{url}/{endpoint}", + mappings: { + list: { endpoint: "list", proxyHandler: handlerFn.handler, headers: { "X-Test": "1" } }, + }, + }, + regex: { + api: "{url}/{endpoint}", + allowedEndpoints: /^ok\//, + }, + ical: { + api: "{url}/{endpoint}", + proxyHandler: calendarProxy, + }, + unifi_console: { + api: "{url}/{endpoint}", + proxyHandler: handlerFn.handler, + }, }, })); @@ -51,6 +87,10 @@ function createMockRes() { } describe("pages/api/services/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("maps opaque endpoints using widget.mappings and calls the handler", async () => { getServiceWidget.mockResolvedValue({ type: "linkwarden" }); handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); @@ -76,4 +116,192 @@ describe("pages/api/services/proxy", () => { expect(res.statusCode).toBe(403); expect(res.body).toEqual({ error: "Unsupported service endpoint" }); }); + + it("returns 403 for unknown widget types", async () => { + getServiceWidget.mockResolvedValue({ type: "does_not_exist" }); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unknown proxy service type" }); + }); + + it("quick-returns the proxy handler when no endpoint is provided", async () => { + getServiceWidget.mockResolvedValue({ type: "linkwarden" }); + handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json(state.genericResult)); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(handlerFn.handler).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); + + it("applies the calendar exception and always delegates to calendarProxyHandler", async () => { + getServiceWidget.mockResolvedValue({ type: "calendar" }); + calendarProxy.mockImplementation(async (_req, res) => res.status(200).json({ ok: "calendar" })); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "events" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(calendarProxy).toHaveBeenCalledTimes(1); + expect(handlerFn.handler).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: "calendar" }); + }); + + it("applies the unifi_console exception when service and group are unifi_console", async () => { + getServiceWidget.mockResolvedValue({ type: "something_else" }); + handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: "unifi" })); + + const req = { + method: "GET", + query: { group: "unifi_console", service: "unifi_console", index: "0" }, + }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: "unifi" }); + }); + + it("rejects unsupported mapping methods", async () => { + getServiceWidget.mockResolvedValue({ type: "linkwarden" }); + + // Inject a mapping with a method requirement through the mocked registry. + const widgets = (await import("widgets/widgets")).default; + const originalMethod = widgets.linkwarden.mappings.collections.method; + widgets.linkwarden.mappings.collections.method = "POST"; + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unsupported method" }); + + widgets.linkwarden.mappings.collections.method = originalMethod; + }); + + it("replaces endpoint segments and rejects unsupported segment keys/values", async () => { + getServiceWidget.mockResolvedValue({ type: "segments" }); + handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); + + const res1 = createMockRes(); + await servicesProxy( + { + method: "GET", + query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "123" }) }, + }, + res1, + ); + expect(res1.statusCode).toBe(200); + expect(res1.body).toEqual({ endpoint: "items/123" }); + + const res2 = createMockRes(); + await servicesProxy( + { + method: "GET", + query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ nope: "123" }) }, + }, + res2, + ); + expect(res2.statusCode).toBe(403); + expect(res2.body).toEqual({ error: "Unsupported segment" }); + + const res3 = createMockRes(); + await servicesProxy( + { + method: "GET", + query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "../123" }) }, + }, + res3, + ); + expect(res3.statusCode).toBe(403); + expect(res3.body).toEqual({ error: "Unsupported segment" }); + }); + + it("adds query params based on mapping params + optionalParams", async () => { + getServiceWidget.mockResolvedValue({ type: "queryparams" }); + handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); + + const req = { + method: "GET", + query: { + group: "g", + service: "s", + index: "0", + endpoint: "list", + query: JSON.stringify({ limit: 10, q: "test" }), + }, + }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.endpoint).toBe("list?limit=10&q=test"); + }); + + it("passes mapping headers via req.extraHeaders and uses mapping.proxyHandler when provided", async () => { + getServiceWidget.mockResolvedValue({ type: "endpointproxy" }); + handlerFn.handler.mockImplementation(async (req, res) => + res.status(200).json({ headers: req.extraHeaders ?? null }), + ); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "list" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(handlerFn.handler).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(res.body.headers).toEqual({ "X-Test": "1" }); + }); + + it("allows regex endpoints when widget.allowedEndpoints matches", async () => { + getServiceWidget.mockResolvedValue({ type: "regex" }); + handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: true })); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "ok/test" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(200); + }); + + it("rejects unmapped proxy requests when no mapping and regex does not match", async () => { + getServiceWidget.mockResolvedValue({ type: "regex" }); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unmapped proxy request." }); + }); + + it("returns 500 on unexpected errors", async () => { + getServiceWidget.mockRejectedValueOnce(new Error("boom")); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "Unexpected error" }); + }); }); diff --git a/src/__tests__/pages/index.test.js b/src/__tests__/pages/index.test.js deleted file mode 100644 index 5b675242c..000000000 --- a/src/__tests__/pages/index.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { state, getSettings, servicesResponse, bookmarksResponse, widgetsResponse, serverSideTranslations, logger } = - vi.hoisted(() => { - const state = { - throwIn: null, - }; - - const getSettings = vi.fn(() => ({ - providers: {}, - language: "en", - title: "Homepage", - })); - - const servicesResponse = vi.fn(async () => { - if (state.throwIn === "services") throw new Error("services failed"); - return [{ name: "svc" }]; - }); - const bookmarksResponse = vi.fn(async () => { - if (state.throwIn === "bookmarks") throw new Error("bookmarks failed"); - return [{ name: "bm" }]; - }); - const widgetsResponse = vi.fn(async () => { - if (state.throwIn === "widgets") throw new Error("widgets failed"); - return [{ type: "search" }]; - }); - - const serverSideTranslations = vi.fn(async (language) => ({ _translations: language })); - const logger = { error: vi.fn() }; - - return { state, getSettings, servicesResponse, bookmarksResponse, widgetsResponse, serverSideTranslations, logger }; - }); - -vi.mock("next/dynamic", () => ({ - default: () => () => null, -})); -vi.mock("next/head", () => ({ default: ({ children }) => children })); -vi.mock("next/script", () => ({ default: () => null })); -vi.mock("next/router", () => ({ useRouter: () => ({ asPath: "/" }) })); - -vi.mock("next-i18next/serverSideTranslations", () => ({ - serverSideTranslations, -})); - -vi.mock("utils/logger", () => ({ - default: () => logger, -})); - -vi.mock("utils/config/config", () => ({ - getSettings, -})); - -vi.mock("utils/config/api-response", () => ({ - servicesResponse, - bookmarksResponse, - widgetsResponse, -})); - -describe("pages/index getStaticProps", () => { - beforeEach(() => { - vi.clearAllMocks(); - state.throwIn = null; - }); - - it("returns initial settings and api fallbacks for swr", async () => { - getSettings.mockReturnValueOnce({ providers: { x: 1 }, language: "en", title: "Homepage" }); - - const { getStaticProps } = await import("pages/index.jsx"); - const result = await getStaticProps(); - - expect(result.props.initialSettings).toEqual({ language: "en", title: "Homepage" }); - expect(result.props.fallback["/api/services"]).toEqual([{ name: "svc" }]); - expect(result.props.fallback["/api/bookmarks"]).toEqual([{ name: "bm" }]); - expect(result.props.fallback["/api/widgets"]).toEqual([{ type: "search" }]); - expect(result.props.fallback["/api/hash"]).toBe(false); - expect(serverSideTranslations).toHaveBeenCalledWith("en"); - }); - - it("normalizes legacy language codes before requesting translations", async () => { - getSettings.mockReturnValueOnce({ providers: {}, language: "zh-CN" }); - - const { getStaticProps } = await import("pages/index.jsx"); - await getStaticProps(); - - expect(serverSideTranslations).toHaveBeenCalledWith("zh-Hans"); - }); - - it("falls back to empty settings and en translations on errors", async () => { - getSettings.mockReturnValueOnce({ providers: {}, language: "de" }); - state.throwIn = "services"; - - const { getStaticProps } = await import("pages/index.jsx"); - const result = await getStaticProps(); - - expect(result.props.initialSettings).toEqual({}); - expect(result.props.fallback["/api/services"]).toEqual([]); - expect(result.props.fallback["/api/bookmarks"]).toEqual([]); - expect(result.props.fallback["/api/widgets"]).toEqual([]); - expect(serverSideTranslations).toHaveBeenCalledWith("en"); - expect(logger.error).toHaveBeenCalled(); - }); -}); diff --git a/src/__tests__/pages/index.test.jsx b/src/__tests__/pages/index.test.jsx new file mode 100644 index 000000000..b0a96156c --- /dev/null +++ b/src/__tests__/pages/index.test.jsx @@ -0,0 +1,400 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ColorContext } from "utils/contexts/color"; +import { SettingsContext } from "utils/contexts/settings"; +import { TabContext } from "utils/contexts/tab"; +import { ThemeContext } from "utils/contexts/theme"; + +const { + state, + router, + i18n, + getSettings, + servicesResponse, + bookmarksResponse, + widgetsResponse, + serverSideTranslations, + logger, + useSWR, + useWindowFocus, +} = vi.hoisted(() => { + const state = { + throwIn: null, + validateData: [], + hashData: null, + mutateHash: vi.fn(), + servicesData: [], + bookmarksData: [], + widgetsData: [], + quickLaunchProps: null, + widgetCalls: [], + windowFocused: false, + }; + + const router = { asPath: "/" }; + const i18n = { language: "en", changeLanguage: vi.fn() }; + + const getSettings = vi.fn(() => ({ + providers: {}, + language: "en", + title: "Homepage", + })); + + const servicesResponse = vi.fn(async () => { + if (state.throwIn === "services") throw new Error("services failed"); + return [{ name: "svc" }]; + }); + const bookmarksResponse = vi.fn(async () => { + if (state.throwIn === "bookmarks") throw new Error("bookmarks failed"); + return [{ name: "bm" }]; + }); + const widgetsResponse = vi.fn(async () => { + if (state.throwIn === "widgets") throw new Error("widgets failed"); + return [{ type: "search" }]; + }); + + const serverSideTranslations = vi.fn(async (language) => ({ _translations: language })); + const logger = { error: vi.fn() }; + + const useSWR = vi.fn((key) => { + if (key === "/api/validate") return { data: state.validateData }; + if (key === "/api/hash") return { data: state.hashData, mutate: state.mutateHash }; + if (key === "/api/services") return { data: state.servicesData }; + if (key === "/api/bookmarks") return { data: state.bookmarksData }; + if (key === "/api/widgets") return { data: state.widgetsData }; + return { data: undefined }; + }); + + const useWindowFocus = vi.fn(() => state.windowFocused); + + return { + state, + router, + i18n, + getSettings, + servicesResponse, + bookmarksResponse, + widgetsResponse, + serverSideTranslations, + logger, + useSWR, + useWindowFocus, + }; +}); + +vi.mock("next/dynamic", () => ({ + default: () => () => null, +})); +vi.mock("next/head", () => ({ default: ({ children }) => children })); +vi.mock("next/script", () => ({ default: () => null })); +vi.mock("next/router", () => ({ useRouter: () => router })); + +vi.mock("next-i18next", () => ({ + useTranslation: () => ({ + i18n, + t: (k) => k, + }), +})); + +vi.mock("next-i18next/serverSideTranslations", () => ({ + serverSideTranslations, +})); + +vi.mock("swr", () => ({ + default: useSWR, + SWRConfig: ({ children }) => children, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/config", () => ({ + getSettings, +})); + +vi.mock("utils/config/api-response", () => ({ + servicesResponse, + bookmarksResponse, + widgetsResponse, +})); + +vi.mock("utils/hooks/window-focus", () => ({ + default: useWindowFocus, +})); + +vi.mock("components/bookmarks/group", () => ({ + default: ({ bookmarks }) =>