mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-07 16:30:52 +08:00
test: increase coverage for index, webmanifest, and services proxy
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
400
src/__tests__/pages/index.test.jsx
Normal file
400
src/__tests__/pages/index.test.jsx
Normal file
@@ -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 }) => <div data-testid="bookmarks-group">{bookmarks?.name}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("components/services/group", () => ({
|
||||
default: ({ group }) => <div data-testid="services-group">{group?.name}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("components/tab", () => ({
|
||||
default: ({ tab }) => <li data-testid="tab">{tab}</li>,
|
||||
slugifyAndEncode: (tabName) =>
|
||||
tabName !== undefined ? encodeURIComponent(tabName.toString().replace(/\\s+/g, "-").toLowerCase()) : "",
|
||||
}));
|
||||
|
||||
vi.mock("components/quicklaunch", () => ({
|
||||
default: (props) => {
|
||||
state.quickLaunchProps = props;
|
||||
return (
|
||||
<div data-testid="quicklaunch">
|
||||
{props.isOpen ? "open" : "closed"}:{props.servicesAndBookmarks?.length ?? 0}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/widgets/widget", () => ({
|
||||
default: ({ widget, style }) => {
|
||||
state.widgetCalls.push({ widget, style });
|
||||
return <div data-testid="widget">{widget?.type}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/toggles/revalidate", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe("pages/index getStaticProps", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.throwIn = null;
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
state.quickLaunchProps = null;
|
||||
state.widgetCalls = [];
|
||||
state.windowFocused = false;
|
||||
router.asPath = "/";
|
||||
i18n.changeLanguage.mockClear();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
async function renderIndex({
|
||||
initialSettings = { title: "Homepage", layout: {} },
|
||||
fallback = {},
|
||||
theme = "dark",
|
||||
color = "slate",
|
||||
activeTab = "",
|
||||
settings = initialSettings,
|
||||
} = {}) {
|
||||
const { default: Wrapper } = await import("pages/index.jsx");
|
||||
|
||||
const setTheme = vi.fn();
|
||||
const setColor = vi.fn();
|
||||
const setSettings = vi.fn();
|
||||
const setActiveTab = vi.fn();
|
||||
|
||||
return render(
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<ColorContext.Provider value={{ color, setColor }}>
|
||||
<SettingsContext.Provider value={{ settings, setSettings }}>
|
||||
<TabContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<Wrapper initialSettings={initialSettings} fallback={fallback} />
|
||||
</TabContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ColorContext.Provider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("pages/index Wrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
state.widgetCalls = [];
|
||||
document.documentElement.className = "dark theme-slate";
|
||||
});
|
||||
|
||||
it("applies theme/color classes and renders a background overlay when configured", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: {
|
||||
title: "Homepage",
|
||||
color: "slate",
|
||||
background: { image: "https://example.com/bg.jpg", opacity: 10, blur: true, saturate: 150, brightness: 125 },
|
||||
layout: {},
|
||||
},
|
||||
theme: "dark",
|
||||
color: "emerald",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("theme-emerald")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("theme-slate")).toBe(false);
|
||||
|
||||
expect(document.querySelector("#background")).toBeTruthy();
|
||||
expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-blur");
|
||||
expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-saturate-150");
|
||||
expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-brightness-125");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages/index Index routing + SWR branches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.hashData = null;
|
||||
state.mutateHash.mockClear();
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
});
|
||||
|
||||
it("renders the validation error screen when /api/validate returns an error", async () => {
|
||||
state.validateData = { error: "bad config" };
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
expect(screen.getByText("bad config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders config errors when /api/validate returns a list of errors", async () => {
|
||||
state.validateData = [{ config: "services.yaml", reason: "broken", mark: { snippet: "x: y" } }];
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
expect(screen.getByText("services.yaml")).toBeInTheDocument();
|
||||
expect(screen.getByText("broken")).toBeInTheDocument();
|
||||
expect(screen.getByText("x: y")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the UI stale when the hash changes and triggers a revalidate reload", async () => {
|
||||
state.validateData = [];
|
||||
state.hashData = { hash: "new-hash" };
|
||||
localStorage.setItem("hash", "old-hash");
|
||||
|
||||
const fetchSpy = vi.fn(async () => ({ ok: true }));
|
||||
// eslint-disable-next-line no-global-assign
|
||||
fetch = fetchSpy;
|
||||
|
||||
let reloadSpy;
|
||||
try {
|
||||
reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {});
|
||||
} catch {
|
||||
// jsdom can make window.location non-configurable in some contexts.
|
||||
Object.defineProperty(window, "location", { value: { reload: vi.fn() }, writable: true });
|
||||
reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {});
|
||||
}
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(document.querySelector(".animate-spin")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages/index Home behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.servicesData = [
|
||||
{
|
||||
name: "Services",
|
||||
services: [{ name: "s1", href: "http://svc/1" }, { name: "s2" }],
|
||||
groups: [{ name: "Nested", services: [{ name: "s3", href: "http://svc/3" }], groups: [] }],
|
||||
},
|
||||
];
|
||||
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [{ name: "b1", href: "http://bm/1" }, { name: "b2" }] }];
|
||||
state.widgetsData = [{ type: "glances" }, { type: "search" }];
|
||||
state.quickLaunchProps = null;
|
||||
state.widgetCalls = [];
|
||||
});
|
||||
|
||||
it("passes href-bearing services and bookmarks to QuickLaunch and toggles search on keydown", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: { title: "Homepage", layout: {}, language: "en" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.quickLaunchProps).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(state.quickLaunchProps.servicesAndBookmarks.map((i) => i.name)).toEqual(["b1", "s1", "s3"]);
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3");
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "a" });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3");
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3");
|
||||
});
|
||||
|
||||
it("marks information widgets as right-aligned for known widget types", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: { title: "Homepage", layout: {}, language: "en" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.widgetCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const rightAligned = state.widgetCalls.filter((c) => c.style?.isRightAligned).map((c) => c.widget.type);
|
||||
expect(rightAligned).toEqual(["search"]);
|
||||
});
|
||||
});
|
||||
@@ -52,4 +52,41 @@ describe("pages/site.webmanifest", () => {
|
||||
expect(manifest.theme_color).toBe(themes.slate.dark);
|
||||
expect(manifest.background_color).toBe(themes.slate.dark);
|
||||
});
|
||||
|
||||
it("uses sensible defaults when no settings are provided", async () => {
|
||||
getSettings.mockReturnValueOnce({});
|
||||
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
const manifest = JSON.parse(res.write.mock.calls[0][0]);
|
||||
expect(manifest.name).toBe("Homepage");
|
||||
expect(manifest.short_name).toBe("Homepage");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
expect(manifest.display).toBe("standalone");
|
||||
expect(manifest.theme_color).toBe(themes.slate.dark);
|
||||
expect(manifest.background_color).toBe(themes.slate.dark);
|
||||
|
||||
// Default icon set is used when pwa.icons is not set.
|
||||
expect(manifest.icons).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ src: expect.stringContaining("android-chrome-192x192") }),
|
||||
expect.objectContaining({ src: expect.stringContaining("android-chrome-512x512") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects provided pwa.icons even when it is an empty array", async () => {
|
||||
getSettings.mockReturnValueOnce({
|
||||
pwa: { icons: [] },
|
||||
});
|
||||
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
const manifest = JSON.parse(res.write.mock.calls[0][0]);
|
||||
expect(manifest.icons).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user