Chore: homepage tests (#6278)

This commit is contained in:
shamoon
2026-02-04 19:58:39 -08:00
committed by GitHub
parent 7d019185a3
commit 872a3600aa
558 changed files with 32606 additions and 84 deletions

View File

@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Next's Head implementation relies on internal Next contexts; stub it for unit tests.
vi.mock("next/head", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/color", () => ({
ColorProvider: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/theme", () => ({
ThemeProvider: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/settings", () => ({
SettingsProvider: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/tab", () => ({
TabProvider: ({ children }) => <>{children}</>,
}));
import App from "pages/_app.jsx";
describe("pages/_app", () => {
it("renders the active page component with pageProps", () => {
function Page({ message }) {
return <div>msg:{message}</div>;
}
render(<App Component={Page} pageProps={{ message: "hello" }} />);
expect(screen.getByText("msg:hello")).toBeInTheDocument();
expect(document.querySelector('meta[name="viewport"]')).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/document", () => ({
Html: ({ children }) => <div data-testid="html">{children}</div>,
Head: ({ children }) => <div data-testid="head">{children}</div>,
Main: () => <main data-testid="main" />,
NextScript: () => <script data-testid="nextscript" />,
}));
import Document from "pages/_document.jsx";
describe("pages/_document", () => {
it("renders the PWA meta + custom css links", () => {
const html = renderToStaticMarkup(<Document />);
expect(html).toContain('meta name="mobile-web-app-capable" content="yes"');
expect(html).toContain('link rel="manifest" href="/site.webmanifest?v=4"');
expect(html).toContain('link rel="preload" href="/api/config/custom.css" as="style"');
expect(html).toContain('link rel="stylesheet" href="/api/config/custom.css"');
expect(html).toContain('data-testid="main"');
expect(html).toContain('data-testid="nextscript"');
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { bookmarksResponse } = vi.hoisted(() => ({
bookmarksResponse: vi.fn(),
}));
vi.mock("utils/config/api-response", () => ({
bookmarksResponse,
}));
import handler from "pages/api/bookmarks";
describe("pages/api/bookmarks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns bookmarksResponse()", async () => {
bookmarksResponse.mockResolvedValueOnce({ ok: true });
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual({ ok: true });
});
});

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { fs, config, logger } = vi.hoisted(() => ({
fs: {
existsSync: vi.fn(),
readFileSync: vi.fn(),
},
config: {
CONF_DIR: "/conf",
},
logger: {
error: vi.fn(),
},
}));
vi.mock("fs", () => ({
default: fs,
...fs,
}));
vi.mock("utils/config/config", () => config);
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/config/[path]";
describe("pages/api/config/[path]", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 422 for unsupported files", async () => {
const req = { query: { path: "not-supported.txt" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(422);
});
it("returns empty content when the file doesn't exist", async () => {
fs.existsSync.mockReturnValueOnce(false);
const req = { query: { path: "custom.css" } };
const res = createMockRes();
await handler(req, res);
expect(res.headers["Content-Type"]).toBe("text/css");
expect(res.statusCode).toBe(200);
expect(res.body).toBe("");
});
it("returns file content when the file exists", async () => {
fs.existsSync.mockReturnValueOnce(true);
fs.readFileSync.mockReturnValueOnce("body{}");
const req = { query: { path: "custom.js" } };
const res = createMockRes();
await handler(req, res);
expect(res.headers["Content-Type"]).toBe("text/javascript");
expect(res.statusCode).toBe(200);
expect(res.body).toBe("body{}");
});
it("logs and returns 500 when reading the file throws", async () => {
fs.existsSync.mockReturnValueOnce(true);
fs.readFileSync.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { path: "custom.css" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toBe("Internal Server Error");
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {
const state = {
docker: null,
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
};
function DockerCtor() {
return state.docker;
}
return {
state,
DockerCtor,
getDockerArguments: vi.fn(() => state.dockerArgs),
logger: { error: vi.fn() },
};
});
vi.mock("dockerode", () => ({
default: DockerCtor,
}));
vi.mock("utils/config/docker", () => ({
default: getDockerArguments,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/docker/stats/[...service]";
describe("pages/api/docker/stats/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
state.docker = {
listContainers: vi.fn(),
getContainer: vi.fn(),
listTasks: vi.fn(),
};
});
it("returns 400 when container name/server params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "docker query parameters are required" });
});
it("returns 500 when docker returns a non-array containers payload", async () => {
state.docker.listContainers.mockResolvedValue(Buffer.from("bad"));
const req = { query: { service: ["c", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "query failed" });
});
it("returns stats for an existing container", async () => {
state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]);
const containerStats = { cpu_stats: { cpu_usage: { total_usage: 1 } } };
state.docker.getContainer.mockReturnValue({
stats: vi.fn().mockResolvedValue(containerStats),
});
const req = { query: { service: ["myapp", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ stats: containerStats });
});
it("uses swarm tasks to locate a container and reports a friendly error when stats cannot be retrieved", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.listTasks.mockResolvedValue([
{ Status: { ContainerStatus: { ContainerID: "local1" } } },
{ Status: { ContainerStatus: { ContainerID: "remote1" } } },
]);
state.docker.getContainer.mockReturnValue({
stats: vi.fn().mockRejectedValue(new Error("nope")),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ error: "Unable to retrieve stats" });
});
it("returns stats for a swarm task container when present locally", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.listTasks.mockResolvedValue([{ Status: { ContainerStatus: { ContainerID: "local1" } } }]);
const containerStats = { cpu_stats: { cpu_usage: { total_usage: 2 } } };
state.docker.getContainer.mockReturnValue({
stats: vi.fn().mockResolvedValue(containerStats),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ stats: containerStats });
});
it("returns 404 when no container or swarm task is found", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.listTasks.mockResolvedValue([]);
const req = { query: { service: ["missing", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "not found" });
});
it("logs and returns 500 when the docker query throws", async () => {
getDockerArguments.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["myapp", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "boom" } });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {
const state = {
docker: null,
dockerCtorArgs: [],
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
};
function DockerCtor(conn) {
state.dockerCtorArgs.push(conn);
return state.docker;
}
return {
state,
DockerCtor,
getDockerArguments: vi.fn(() => state.dockerArgs),
logger: { error: vi.fn() },
};
});
vi.mock("dockerode", () => ({
default: DockerCtor,
}));
vi.mock("utils/config/docker", () => ({
default: getDockerArguments,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/docker/status/[...service]";
describe("pages/api/docker/status/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
state.dockerCtorArgs.length = 0;
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
state.docker = {
listContainers: vi.fn(),
getContainer: vi.fn(),
getService: vi.fn(),
listTasks: vi.fn(),
};
});
it("returns 400 when container name/server params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "docker query parameters are required" });
});
it("returns 500 when docker returns a non-array containers payload", async () => {
state.docker.listContainers.mockResolvedValue(Buffer.from("bad"));
const req = { query: { service: ["c", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "query failed" });
});
it("inspects an existing container and returns status + health", async () => {
state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]);
state.docker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "healthy" } } }),
});
const req = { query: { service: ["myapp", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(getDockerArguments).toHaveBeenCalledWith("local");
expect(state.dockerCtorArgs).toHaveLength(1);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running", health: "healthy" });
});
it("returns 404 when container does not exist and swarm is disabled", async () => {
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
const req = { query: { service: ["missing", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ status: "not found" });
});
it("reports replicated swarm service status based on desired replicas", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "2" } } } }),
});
state.docker.listTasks.mockResolvedValue([{ Status: {} }, { Status: {} }]);
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running 2/2" });
});
it("reports partial status for replicated services with fewer running tasks", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "3" } } } }),
});
state.docker.listTasks.mockResolvedValue([{ Status: {} }]);
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "partial 1/3" });
});
it("handles global services by inspecting a local task container when possible", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),
});
state.docker.listTasks.mockResolvedValue([
{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "running" } },
]);
state.docker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "unhealthy" } } }),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running", health: "unhealthy" });
});
it("falls back to task status when global service container inspect fails", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),
});
state.docker.listTasks.mockResolvedValue([
{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "pending" } },
]);
state.docker.getContainer.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("nope")),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "pending" });
});
it("returns 404 when swarm is enabled but the service does not exist", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("not found")),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ status: "not found" });
});
it("logs and returns 500 when the docker query throws", async () => {
getDockerArguments.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "boom" } });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
import { createHash } from "crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
function sha256(input) {
return createHash("sha256").update(input).digest("hex");
}
const { readFileSync, checkAndCopyConfig, CONF_DIR } = vi.hoisted(() => ({
readFileSync: vi.fn(),
checkAndCopyConfig: vi.fn(),
CONF_DIR: "/conf",
}));
vi.mock("fs", () => ({
readFileSync,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
CONF_DIR,
}));
import handler from "pages/api/hash";
describe("pages/api/hash", () => {
const originalBuildTime = process.env.HOMEPAGE_BUILDTIME;
beforeEach(() => {
vi.clearAllMocks();
process.env.HOMEPAGE_BUILDTIME = originalBuildTime;
});
it("returns a combined sha256 hash of known config files and build time", async () => {
process.env.HOMEPAGE_BUILDTIME = "build-1";
// Return deterministic contents based on file name.
readFileSync.mockImplementation((filePath) => {
const name = filePath.split("/").pop();
return `content:${name}`;
});
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
const configs = [
"docker.yaml",
"settings.yaml",
"services.yaml",
"bookmarks.yaml",
"widgets.yaml",
"custom.css",
"custom.js",
];
const hashes = configs.map((c) => sha256(`content:${c}`));
const expected = sha256(hashes.join("") + "build-1");
expect(checkAndCopyConfig).toHaveBeenCalled();
expect(res.body).toEqual({ hash: expected });
});
});

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import createMockRes from "test-utils/create-mock-res";
import handler from "pages/api/healthcheck";
describe("pages/api/healthcheck", () => {
it("returns 'up'", () => {
const req = {};
const res = createMockRes();
handler(req, res);
expect(res.body).toBe("up");
});
});

View File

@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getKubeConfig, coreApi, metricsApi, MetricsCtor, logger } = vi.hoisted(() => {
const metricsApi = {
getPodMetrics: vi.fn(),
};
function MetricsCtor() {
return metricsApi;
}
return {
getKubeConfig: vi.fn(),
coreApi: { listNamespacedPod: vi.fn() },
metricsApi,
MetricsCtor,
logger: { error: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
CoreV1Api: function CoreV1Api() {},
Metrics: MetricsCtor,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/kubernetes", () => ({
getKubeConfig,
}));
import handler from "pages/api/kubernetes/stats/[...service]";
describe("pages/api/kubernetes/stats/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
getKubeConfig.mockReturnValue({
makeApiClient: () => coreApi,
});
});
it("returns 400 when namespace/appName params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "kubernetes query parameters are required" });
});
it("returns 500 when kubernetes is not configured", async () => {
getKubeConfig.mockReturnValue(null);
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "No kubernetes configuration" });
});
it("returns 500 when listNamespacedPod fails", async () => {
coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: "nope", response: "nope" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Error communicating with kubernetes" });
});
it("returns 404 when no pods match the selector", async () => {
coreApi.listNamespacedPod.mockResolvedValue({ items: [] });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({
error: "no pods found with namespace=default and labelSelector=app.kubernetes.io/name=app",
});
});
it("computes limits even when metrics are missing (404 from metrics server)", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [
{
metadata: { name: "pod-a" },
spec: {
containers: [
{ resources: { limits: { cpu: "500m", memory: "1Gi" } } },
{ resources: { limits: { cpu: "250m" } } },
],
},
},
],
});
metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 404, body: "no metrics", response: "no metrics" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
stats: {
mem: 0,
cpu: 0,
cpuLimit: 0.75,
memLimit: 1000000000,
cpuUsage: 0,
memUsage: 0,
},
});
});
it("logs when metrics lookup fails with a non-404 error and still returns computed limits", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [
{
metadata: { name: "pod-a" },
spec: {
containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }],
},
},
],
});
metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 500, body: "boom", response: "boom" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(logger.error).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body.stats.cpuLimit).toBe(0.5);
expect(res.body.stats.memLimit).toBe(1000000000);
expect(res.body.stats.cpu).toBe(0);
expect(res.body.stats.mem).toBe(0);
});
it("aggregates usage for matched pods and reports percent usage", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [
{
metadata: { name: "pod-a" },
spec: { containers: [{ resources: { limits: { cpu: "1000m", memory: "2Gi" } } }] },
},
{
metadata: { name: "pod-b" },
spec: { containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }] },
},
],
});
metricsApi.getPodMetrics.mockResolvedValue({
items: [
// includes a non-selected pod, should be ignored
{ metadata: { name: "other" }, containers: [{ usage: { cpu: "100m", memory: "10Mi" } }] },
{
metadata: { name: "pod-a" },
containers: [{ usage: { cpu: "250m", memory: "100Mi" } }, { usage: { cpu: "250m", memory: "100Mi" } }],
},
{ metadata: { name: "pod-b" }, containers: [{ usage: { cpu: "500m", memory: "1Gi" } }] },
],
});
const req = { query: { service: ["default", "app"], podSelector: "app=test" } };
const res = createMockRes();
await handler(req, res);
const { stats } = res.body;
expect(stats.cpuLimit).toBe(1.5);
expect(stats.memLimit).toBe(3000000000);
expect(stats.cpu).toBeCloseTo(1.0, 5);
expect(stats.mem).toBe(1200000000);
expect(stats.cpuUsage).toBeCloseTo((100 * 1.0) / 1.5, 5);
expect(stats.memUsage).toBeCloseTo((100 * 1200000000) / 3000000000, 5);
});
it("returns 500 when an unexpected error is thrown", async () => {
getKubeConfig.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "unknown error" });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getKubeConfig, coreApi, logger } = vi.hoisted(() => ({
getKubeConfig: vi.fn(),
coreApi: { listNamespacedPod: vi.fn() },
logger: { error: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/kubernetes", () => ({
getKubeConfig,
}));
import handler from "pages/api/kubernetes/status/[...service]";
describe("pages/api/kubernetes/status/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
getKubeConfig.mockReturnValue({
makeApiClient: () => coreApi,
});
});
it("returns 400 when namespace/appName params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "kubernetes query parameters are required" });
});
it("returns 500 when kubernetes is not configured", async () => {
getKubeConfig.mockReturnValue(null);
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "No kubernetes configuration" });
});
it("returns 500 when listNamespacedPod fails", async () => {
coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: "nope", response: "nope" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Error communicating with kubernetes" });
});
it("returns 404 when no pods match the selector", async () => {
coreApi.listNamespacedPod.mockResolvedValue({ items: [] });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ status: "not found" });
});
it("returns partial when some pods are ready but not all", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [{ status: { phase: "Running" } }, { status: { phase: "Pending" } }],
});
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "partial" });
});
it("returns running when all pods are ready", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [{ status: { phase: "Running" } }, { status: { phase: "Succeeded" } }],
});
const req = { query: { service: ["default", "app"], podSelector: "app=test" } };
const res = createMockRes();
await handler(req, res);
expect(coreApi.listNamespacedPod).toHaveBeenCalledWith({
namespace: "default",
labelSelector: "app=test",
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running" });
});
it("returns 500 when an unexpected error is thrown", async () => {
getKubeConfig.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "unknown error" });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getServiceItem, ping, logger } = vi.hoisted(() => ({
getServiceItem: vi.fn(),
ping: { probe: vi.fn() },
logger: { debug: vi.fn() },
}));
vi.mock("utils/config/service-helpers", () => ({
getServiceItem,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("ping", () => ({
promise: ping,
}));
import handler from "pages/api/ping";
describe("pages/api/ping", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when service item isn't found", async () => {
getServiceItem.mockResolvedValueOnce(null);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Unable to find service");
});
it("returns 400 when ping host isn't configured", async () => {
getServiceItem.mockResolvedValueOnce({ ping: "" });
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("No ping host given");
});
it("pings the hostname extracted from a URL", async () => {
getServiceItem.mockResolvedValueOnce({ ping: "http://example.com:1234/path" });
ping.probe.mockResolvedValueOnce({ alive: true });
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(ping.probe).toHaveBeenCalledWith("example.com");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ alive: true });
});
it("returns 400 when ping throws", async () => {
getServiceItem.mockResolvedValueOnce({ ping: "example.com" });
ping.probe.mockRejectedValueOnce(new Error("nope"));
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Error attempting ping");
});
});

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getProxmoxConfig, httpProxy, logger } = vi.hoisted(() => ({
getProxmoxConfig: vi.fn(),
httpProxy: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/config/proxmox", () => ({
getProxmoxConfig,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/proxmox/stats/[...service]";
describe("pages/api/proxmox/stats/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when node param is missing", async () => {
const req = { query: { service: [], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Proxmox node parameter is required" });
});
it("returns 500 when proxmox config is missing", async () => {
getProxmoxConfig.mockReturnValue(null);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Proxmox server configuration not found" });
});
it("returns 400 when node config is missing and legacy credentials are not present", async () => {
getProxmoxConfig.mockReturnValue({ other: { url: "http://x", token: "t", secret: "s" } });
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(
expect.objectContaining({
error: expect.stringContaining("Proxmox config not found for the specified node"),
}),
);
});
it("returns status/cpu/mem for a successful Proxmox response using per-node credentials", async () => {
getProxmoxConfig.mockReturnValue({
pve: { url: "http://pve", token: "tok", secret: "sec" },
});
httpProxy.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ data: { status: "running", cpu: 0.2, mem: 123 } })),
]);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://pve/api2/json/nodes/pve/qemu/100/status/current", {
method: "GET",
headers: { Authorization: "PVEAPIToken=tok=sec" },
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running", cpu: 0.2, mem: 123 });
});
it("falls back to legacy top-level credentials when no node block exists", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ data: { cpu: 0.1, mem: 1 } })),
]);
const req = { query: { service: ["pve", "100"], type: "lxc" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://pve/api2/json/nodes/pve/lxc/100/status/current", expect.any(Object));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "unknown", cpu: 0.1, mem: 1 });
});
it("returns a non-200 status when Proxmox responds with an error", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from(JSON.stringify({ error: "no" }))]);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(401);
expect(res.body).toEqual({ error: "Failed to fetch Proxmox qemu status" });
});
it("returns 500 when the Proxmox response is missing expected data", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({}))]);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Invalid response from Proxmox API" });
});
it("logs and returns 500 when an unexpected error occurs", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockRejectedValueOnce(new Error("boom"));
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(logger.error).toHaveBeenCalled();
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to fetch Proxmox status" });
});
});

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { cachedRequest, logger } = vi.hoisted(() => ({
cachedRequest: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/releases";
describe("pages/api/releases", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns cached GitHub releases", async () => {
cachedRequest.mockResolvedValueOnce([{ tag_name: "v1" }]);
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual([{ tag_name: "v1" }]);
});
it("returns [] when cachedRequest throws", async () => {
cachedRequest.mockRejectedValueOnce(new Error("nope"));
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual([]);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
import handler from "pages/api/revalidate";
describe("pages/api/revalidate", () => {
it("revalidates and returns {revalidated:true}", async () => {
const req = {};
const res = createMockRes();
res.revalidate = vi.fn().mockResolvedValueOnce(undefined);
await handler(req, res);
expect(res.revalidate).toHaveBeenCalledWith("/");
expect(res.body).toEqual({ revalidated: true });
});
it("returns 500 when revalidate throws", async () => {
const req = {};
const res = createMockRes();
res.revalidate = vi.fn().mockRejectedValueOnce(new Error("nope"));
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toBe("Error revalidating");
});
});

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { providers, getSettings, widgetsFromConfig, cachedRequest } = vi.hoisted(() => ({
providers: {
custom: { name: "Custom", url: false, suggestionUrl: null },
google: { name: "Google", url: "https://google?q=", suggestionUrl: "https://google/suggest?q=" },
empty: { name: "NoSuggest", url: "x", suggestionUrl: null },
},
getSettings: vi.fn(),
widgetsFromConfig: vi.fn(),
cachedRequest: vi.fn(),
}));
vi.mock("components/widgets/search/search", () => ({
searchProviders: {
custom: providers.custom,
google: providers.google,
empty: providers.empty,
},
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
vi.mock("utils/config/widget-helpers", () => ({
widgetsFromConfig,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/search/searchSuggestion";
describe("pages/api/search/searchSuggestion", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset provider objects since handler mutates the Custom provider.
providers.custom.url = false;
providers.custom.suggestionUrl = null;
});
it("returns empty suggestions when providerName is unknown", async () => {
const req = { query: { query: "hello", providerName: "Unknown" } };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual(["hello", []]);
});
it("returns empty suggestions when provider has no suggestionUrl", async () => {
const req = { query: { query: "hello", providerName: "NoSuggest" } };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual(["hello", []]);
});
it("calls cachedRequest for a standard provider", async () => {
cachedRequest.mockResolvedValueOnce(["q", ["a"]]);
const req = { query: { query: "hello world", providerName: "Google" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://google/suggest?q=hello%20world", 5, "Mozilla/5.0");
expect(res.body).toEqual(["q", ["a"]]);
});
it("resolves Custom provider suggestionUrl from widgets.yaml when present", async () => {
widgetsFromConfig.mockResolvedValueOnce([
{ type: "search", options: { url: "https://custom?q=", suggestionUrl: "https://custom/suggest?q=" } },
]);
cachedRequest.mockResolvedValueOnce(["q", ["x"]]);
const req = { query: { query: "hello", providerName: "Custom" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://custom/suggest?q=hello", 5, "Mozilla/5.0");
expect(res.body).toEqual(["q", ["x"]]);
});
it("falls back to quicklaunch custom settings when no search widget is configured", async () => {
widgetsFromConfig.mockResolvedValueOnce([]);
getSettings.mockReturnValueOnce({
quicklaunch: { provider: "custom", url: "https://ql?q=", suggestionUrl: "https://ql/suggest?q=" },
});
cachedRequest.mockResolvedValueOnce(["q", ["y"]]);
const req = { query: { query: "hello", providerName: "Custom" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://ql/suggest?q=hello", 5, "Mozilla/5.0");
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { servicesResponse } = vi.hoisted(() => ({
servicesResponse: vi.fn(),
}));
vi.mock("utils/config/api-response", () => ({
servicesResponse,
}));
import handler from "pages/api/services/index";
describe("pages/api/services/index", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns servicesResponse()", async () => {
servicesResponse.mockResolvedValueOnce({ services: [] });
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual({ services: [] });
});
});

View File

@@ -0,0 +1,347 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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() }),
}));
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
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: calendarProxy }));
// Provide a minimal widget registry for mapping tests.
vi.mock("widgets/widgets", () => ({
default: {
linkwarden: {
api: "{url}/api/v1/{endpoint}",
mappings: {
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,
},
},
}));
import servicesProxy from "pages/api/services/proxy";
function createMockRes() {
const res = {
statusCode: undefined,
body: undefined,
status: (code) => {
res.statusCode = code;
return res;
},
json: (data) => {
res.body = data;
return res;
},
send: (data) => {
res.body = data;
return res;
},
end: () => res,
setHeader: vi.fn(),
};
return res;
}
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 }));
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(handlerFn.handler).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ endpoint: "collections" });
});
it("returns 403 for unsupported endpoint mapping", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
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: "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("falls back to the service proxy handler when mapping.proxyHandler is not a function", async () => {
getServiceWidget.mockResolvedValue({ type: "mapbroken" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const widgets = (await import("widgets/widgets")).default;
widgets.mapbroken = {
api: "{url}/{endpoint}",
mappings: {
x: { endpoint: "ok", proxyHandler: "nope" },
},
};
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "x" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(handlerFn.handler).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body.endpoint).toBe("ok");
});
it("returns 403 when a widget defines a non-function proxyHandler", async () => {
getServiceWidget.mockResolvedValue({ type: "brokenhandler" });
const widgets = (await import("widgets/widgets")).default;
widgets.brokenhandler = {
api: "{url}/{endpoint}",
proxyHandler: "nope",
};
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "any" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unknown proxy service type" });
});
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" });
});
});

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getServiceItem, httpProxy, perf, logger } = vi.hoisted(() => ({
getServiceItem: vi.fn(),
httpProxy: vi.fn(),
perf: { now: vi.fn() },
logger: { debug: vi.fn() },
}));
vi.mock("perf_hooks", () => ({
performance: perf,
}));
vi.mock("utils/config/service-helpers", () => ({
getServiceItem,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/siteMonitor";
describe("pages/api/siteMonitor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when the service item is missing", async () => {
getServiceItem.mockResolvedValueOnce(null);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Unable to find service");
});
it("returns 400 when the monitor URL is missing", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "" });
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("No http monitor URL given");
});
it("uses HEAD and returns status + latency when the response is OK", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
perf.now.mockReturnValueOnce(1).mockReturnValueOnce(11);
httpProxy.mockResolvedValueOnce([200]);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://example.com", { method: "HEAD" });
expect(res.statusCode).toBe(200);
expect(res.body.status).toBe(200);
expect(res.body.latency).toBe(10);
});
it("falls back to GET when HEAD is rejected", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
perf.now.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(5).mockReturnValueOnce(15);
httpProxy.mockResolvedValueOnce([500]).mockResolvedValueOnce([200]);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenNthCalledWith(1, "http://example.com", { method: "HEAD" });
expect(httpProxy).toHaveBeenNthCalledWith(2, "http://example.com");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: 200, latency: 10 });
});
it("returns 400 when httpProxy throws", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
httpProxy.mockRejectedValueOnce(new Error("nope"));
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Error attempting http monitor");
});
});

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({
checkAndCopyConfig: vi.fn(),
getSettings: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
getSettings,
}));
import handler from "pages/api/theme";
describe("pages/api/theme", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns defaults when settings are missing", () => {
getSettings.mockReturnValueOnce({});
const res = createMockRes();
handler({ res });
expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ color: "slate", theme: "dark" });
});
it("returns configured color + theme when present", () => {
getSettings.mockReturnValueOnce({ color: "red", theme: "light" });
const res = createMockRes();
handler({ res });
expect(res.body).toEqual({ color: "red", theme: "light" });
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { checkAndCopyConfig } = vi.hoisted(() => ({
checkAndCopyConfig: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
}));
import handler from "pages/api/validate";
describe("pages/api/validate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns errors for any configs that don't validate", async () => {
checkAndCopyConfig.mockReturnValueOnce(true).mockReturnValueOnce("settings bad").mockReturnValue(true);
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual(["settings bad"]);
});
});

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({
getPrivateWidgetOptions: vi.fn(),
httpProxy: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/config/widget-helpers", () => ({
getPrivateWidgetOptions,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/widgets/glances";
describe("pages/api/widgets/glances", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when the widget URL is missing", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("Missing Glances URL");
});
it("returns cpu/load/mem and includes optional endpoints when requested", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances", username: "u", password: "p" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]) // mem
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify("1 days"))]) // uptime
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ label: "cpu_thermal", value: 50 }]))]) // sensors
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ mnt_point: "/", percent: 1 }]))]); // fs
const req = { query: { index: "0", uptime: "1", cputemp: "1", disk: "1", version: "4" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith(
"http://glances/api/4/cpu",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: expect.any(String) }),
}),
);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
cpu: { total: 1 },
load: { avg: 2 },
mem: { available: 3 },
uptime: "1 days",
sensors: [{ label: "cpu_thermal", value: 50 }],
fs: [{ mnt_point: "/", percent: 1 }],
});
});
it("does not call optional endpoints unless requested", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]); // mem
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBeUndefined();
expect(res.statusCode).toBe(200);
});
it("returns 400 when glances returns 401", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining("Authorization failure") }));
});
it("returns 400 when glances returns a non-200 status for a downstream call", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
.mockResolvedValueOnce([500, null, Buffer.from("nope")]); // load
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining("HTTP 500") }));
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { widgetsResponse } = vi.hoisted(() => ({
widgetsResponse: vi.fn(),
}));
vi.mock("utils/config/api-response", () => ({
widgetsResponse,
}));
import handler from "pages/api/widgets/index";
describe("pages/api/widgets/index", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns widgetsResponse()", async () => {
widgetsResponse.mockResolvedValueOnce([{ type: "logo", options: {} }]);
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual([{ type: "logo", options: {} }]);
});
});

View File

@@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { kc, coreApi, metricsApi, getKubeConfig, parseCpu, parseMemory, logger } = vi.hoisted(() => {
const coreApi = { listNode: vi.fn() };
const metricsApi = { getNodeMetrics: vi.fn() };
const kc = {
makeApiClient: vi.fn(() => coreApi),
};
return {
kc,
coreApi,
metricsApi,
getKubeConfig: vi.fn(),
parseCpu: vi.fn(),
parseMemory: vi.fn(),
logger: { error: vi.fn(), debug: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
CoreV1Api: class CoreV1Api {},
Metrics: class Metrics {
constructor() {
return metricsApi;
}
},
}));
vi.mock("utils/config/kubernetes", () => ({
getKubeConfig,
}));
vi.mock("utils/kubernetes/utils", () => ({
parseCpu,
parseMemory,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/widgets/kubernetes";
describe("pages/api/widgets/kubernetes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 500 when no kube config is available", async () => {
getKubeConfig.mockReturnValueOnce(null);
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error).toBe("No kubernetes configuration");
});
it("returns 500 when listing nodes fails", async () => {
getKubeConfig.mockReturnValueOnce(kc);
coreApi.listNode.mockResolvedValueOnce(null);
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error).toContain("fetching nodes");
});
it("logs and returns 500 when listing nodes throws", async () => {
getKubeConfig.mockReturnValueOnce(kc);
coreApi.listNode.mockRejectedValueOnce({ statusCode: 500, body: "nope", response: "nope" });
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(logger.error).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalled();
expect(res.statusCode).toBe(500);
expect(res.body.error).toContain("fetching nodes");
});
it("returns 500 when metrics lookup fails", async () => {
getKubeConfig.mockReturnValueOnce(kc);
parseMemory.mockReturnValue(100);
coreApi.listNode.mockResolvedValueOnce({
items: [
{
metadata: { name: "n1" },
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
},
],
});
metricsApi.getNodeMetrics.mockRejectedValueOnce(new Error("nope"));
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error).toContain("Error getting metrics");
});
it("returns cluster totals and per-node usage", async () => {
getKubeConfig.mockReturnValueOnce(kc);
parseMemory.mockImplementation((value) => {
if (value === "100") return 100;
if (value === "50") return 50;
if (value === "30") return 30;
return 0;
});
parseCpu.mockImplementation((value) => {
if (value === "100m") return 0.1;
if (value === "200m") return 0.2;
return 0;
});
coreApi.listNode.mockResolvedValueOnce({
items: [
{
metadata: { name: "n1" },
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
},
{
metadata: { name: "n2" },
status: { capacity: { cpu: "2", memory: "50" }, conditions: [{ type: "Ready", status: "False" }] },
},
],
});
metricsApi.getNodeMetrics.mockResolvedValueOnce({
items: [
{ metadata: { name: "n1" }, usage: { cpu: "100m", memory: "30" } },
{ metadata: { name: "n2" }, usage: { cpu: "200m", memory: "50" } },
],
});
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.cluster.cpu.total).toBe(3);
expect(res.body.cluster.cpu.load).toBeCloseTo(0.3);
expect(res.body.cluster.memory.total).toBe(150);
expect(res.body.nodes).toHaveLength(2);
expect(res.body.nodes.find((n) => n.name === "n1").cpu.percent).toBeCloseTo(10);
});
it("returns a metrics error when metrics contain an unexpected node name", async () => {
getKubeConfig.mockReturnValueOnce(kc);
parseMemory.mockReturnValue(100);
parseCpu.mockReturnValue(0.1);
coreApi.listNode.mockResolvedValueOnce({
items: [
{
metadata: { name: "n1" },
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
},
],
});
metricsApi.getNodeMetrics.mockResolvedValueOnce({
items: [{ metadata: { name: "n2" }, usage: { cpu: "100m", memory: "30" } }],
});
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error).toContain("Error getting metrics");
expect(logger.error).toHaveBeenCalled();
});
it("returns 500 when an unexpected error is thrown", async () => {
getKubeConfig.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "unknown error" });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getSettings, httpProxy, logger } = vi.hoisted(() => ({
getSettings: vi.fn(),
httpProxy: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/widgets/longhorn";
describe("pages/api/widgets/longhorn", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when the longhorn URL isn't configured", async () => {
getSettings.mockReturnValueOnce({ providers: { longhorn: {} } });
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("Missing Longhorn URL");
});
it("parses and aggregates node disk totals, including a total node", async () => {
getSettings.mockReturnValueOnce({
providers: { longhorn: { url: "http://lh", username: "u", password: "p" } },
});
const payload = {
data: [
{
id: "n1",
disks: {
d1: { storageAvailable: 1, storageMaximum: 10, storageReserved: 2, storageScheduled: 3 },
},
},
{
id: "n2",
disks: {
d1: { storageAvailable: 4, storageMaximum: 20, storageReserved: 5, storageScheduled: 6 },
d2: { storageAvailable: 1, storageMaximum: 1, storageReserved: 1, storageScheduled: 1 },
},
},
],
};
httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify(payload)]);
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith(
"http://lh/v1/nodes",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: expect.any(String) }),
}),
);
expect(res.headers["Content-Type"]).toBe("application/json");
expect(res.statusCode).toBe(200);
const nodes = res.body.nodes;
expect(nodes.map((n) => n.id)).toEqual(["n1", "n2", "total"]);
expect(nodes.find((n) => n.id === "total")).toEqual(
expect.objectContaining({
id: "total",
available: 6,
maximum: 31,
reserved: 8,
scheduled: 10,
}),
);
});
it("handles nodes without disks and logs non-200 responses", async () => {
getSettings.mockReturnValueOnce({ providers: { longhorn: { url: "http://lh" } } });
const payload = { data: [{ id: "n1" }] };
httpProxy.mockResolvedValueOnce([401, "application/json", JSON.stringify(payload)]);
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(logger.error).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body.nodes).toEqual([
{ id: "n1", available: 0, maximum: 0, reserved: 0, scheduled: 0 },
{ id: "total", available: 0, maximum: 0, reserved: 0, scheduled: 0 },
]);
});
it("returns nodes=null when the API returns a null payload", async () => {
getSettings.mockReturnValueOnce({ providers: { longhorn: { url: "http://lh" } } });
httpProxy.mockResolvedValueOnce([200, "application/json", "null"]);
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ nodes: null });
});
});

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { cachedRequest } = vi.hoisted(() => ({
cachedRequest: vi.fn(),
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/widgets/openmeteo";
describe("pages/api/widgets/openmeteo", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("builds the open-meteo URL with units + timezone and calls cachedRequest", async () => {
cachedRequest.mockResolvedValueOnce({ ok: true });
const req = {
query: { latitude: "1", longitude: "2", units: "metric", cache: "5" },
};
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith(
"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset&current_weather=true&temperature_unit=celsius&timezone=auto",
"5",
);
expect(res.body).toEqual({ ok: true });
});
it("uses the provided timezone and fahrenheit for non-metric units", async () => {
cachedRequest.mockResolvedValueOnce({ ok: true });
const req = {
query: { latitude: "1", longitude: "2", units: "imperial", cache: 1, timezone: "UTC" },
};
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith(
"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset&current_weather=true&temperature_unit=fahrenheit&timezone=UTC",
1,
);
});
});

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({
getSettings: vi.fn(),
getPrivateWidgetOptions: vi.fn(),
cachedRequest: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
vi.mock("utils/config/widget-helpers", () => ({
getPrivateWidgetOptions,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/widgets/openweathermap";
describe("pages/api/widgets/openweathermap", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when no API key and no provider are supplied", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing API key or provider" });
});
it("returns 400 when provider doesn't match endpoint and no per-widget key exists", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", provider: "weatherapi" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid provider for endpoint" });
});
it("uses key from widget options when present", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" });
cachedRequest.mockResolvedValueOnce({ ok: true });
const req = {
query: { latitude: "1", longitude: "2", units: "metric", lang: "en", cache: "1", index: "2" },
};
const res = createMockRes();
await handler(req, res);
expect(getPrivateWidgetOptions).toHaveBeenCalledWith("openweathermap", "2");
expect(cachedRequest).toHaveBeenCalledWith(
"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-widget&units=metric&lang=en",
"1",
);
expect(res.body).toEqual({ ok: true });
});
it("falls back to settings provider key when provider=openweathermap", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
getSettings.mockReturnValueOnce({ providers: { openweathermap: "from-settings" } });
cachedRequest.mockResolvedValueOnce({ ok: true });
const req = {
query: {
latitude: "1",
longitude: "2",
units: "imperial",
lang: "en",
provider: "openweathermap",
cache: 2,
index: "0",
},
};
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith(
"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-settings&units=imperial&lang=en",
2,
);
expect(res.body).toEqual({ ok: true });
});
it("returns 400 when provider=openweathermap but settings do not provide an api key", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
getSettings.mockReturnValueOnce({ providers: {} });
const req = {
query: {
latitude: "1",
longitude: "2",
units: "metric",
lang: "en",
provider: "openweathermap",
cache: 1,
index: "0",
},
};
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing API key" });
});
});

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { si, logger } = vi.hoisted(() => ({
si: {
currentLoad: vi.fn(),
fsSize: vi.fn(),
mem: vi.fn(),
cpuTemperature: vi.fn(),
time: vi.fn(),
networkStats: vi.fn(),
networkInterfaceDefault: vi.fn(),
},
logger: {
debug: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("systeminformation", () => ({ default: si }));
import handler from "pages/api/widgets/resources";
describe("pages/api/widgets/resources", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns CPU load data", async () => {
si.currentLoad.mockResolvedValueOnce({ currentLoad: 12.34, avgLoad: 1.23 });
const req = { query: { type: "cpu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.cpu).toEqual({ usage: 12.34, load: 1.23 });
});
it("returns 404 when requested disk target does not exist", async () => {
si.fsSize.mockResolvedValueOnce([{ mount: "/" }]);
const req = { query: { type: "disk", target: "/missing" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "Resource not available." });
expect(logger.warn).toHaveBeenCalled();
});
it("returns disk info for the requested mount", async () => {
si.fsSize.mockResolvedValueOnce([{ mount: "/data", size: 1 }]);
const req = { query: { type: "disk", target: "/data" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.drive).toEqual({ mount: "/data", size: 1 });
});
it("returns memory, cpu temp and uptime", async () => {
si.mem.mockResolvedValueOnce({ total: 10 });
si.cpuTemperature.mockResolvedValueOnce({ main: 50 });
si.time.mockResolvedValueOnce({ uptime: 123 });
const resMem = createMockRes();
await handler({ query: { type: "memory" } }, resMem);
expect(resMem.statusCode).toBe(200);
expect(resMem.body.memory).toEqual({ total: 10 });
const resTemp = createMockRes();
await handler({ query: { type: "cputemp" } }, resTemp);
expect(resTemp.statusCode).toBe(200);
expect(resTemp.body.cputemp).toEqual({ main: 50 });
const resUptime = createMockRes();
await handler({ query: { type: "uptime" } }, resUptime);
expect(resUptime.statusCode).toBe(200);
expect(resUptime.body.uptime).toBe(123);
});
it("returns 404 when requested network interface does not exist", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]);
const req = { query: { type: "network", interfaceName: "missing" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "Interface not found" });
});
it("returns default interface network stats", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
si.networkInterfaceDefault.mockResolvedValueOnce("en0");
const req = { query: { type: "network" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.interface).toBe("en0");
expect(res.body.network).toEqual({ iface: "en0", rx_bytes: 1 });
});
it("returns 404 when the default interface cannot be found in networkStats", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
si.networkInterfaceDefault.mockResolvedValueOnce("en1");
const req = { query: { type: "network" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "Default interface not found" });
});
it("returns 400 for an invalid type", async () => {
const req = { query: { type: "nope" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "invalid type" });
});
});

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getSettings, cachedRequest, logger } = vi.hoisted(() => ({
getSettings: vi.fn(),
cachedRequest: vi.fn(),
logger: { debug: vi.fn() },
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/widgets/stocks";
describe("pages/api/widgets/stocks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("validates watchlist and provider", async () => {
const res1 = createMockRes();
await handler({ query: {} }, res1);
expect(res1.statusCode).toBe(400);
const res2 = createMockRes();
await handler({ query: { watchlist: "null", provider: "finnhub" } }, res2);
expect(res2.statusCode).toBe(400);
const res3 = createMockRes();
await handler({ query: { watchlist: "AAPL,AAPL", provider: "finnhub" } }, res3);
expect(res3.statusCode).toBe(400);
expect(res3.body.error).toContain("duplicates");
const res4 = createMockRes();
await handler({ query: { watchlist: "AAPL", provider: "nope" } }, res4);
expect(res4.statusCode).toBe(400);
expect(res4.body.error).toContain("Invalid provider");
const res5 = createMockRes();
await handler({ query: { watchlist: "AAPL" } }, res5);
expect(res5.statusCode).toBe(400);
expect(res5.body.error).toContain("Missing provider");
const res6 = createMockRes();
await handler({ query: { watchlist: "A,B,C,D,E,F,G,H,I", provider: "finnhub" } }, res6);
expect(res6.statusCode).toBe(400);
expect(res6.body.error).toContain("Max items");
});
it("returns 400 when API key isn't configured for provider", async () => {
getSettings.mockReturnValueOnce({ providers: {} });
const req = { query: { watchlist: "AAPL", provider: "finnhub" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("API Key");
});
it("tolerates missing providers config and returns a helpful error", async () => {
getSettings.mockReturnValueOnce({});
const req = { query: { watchlist: "AAPL", provider: "finnhub" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("API Key");
});
it("returns a normalized stocks response and rounds values", async () => {
getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } });
cachedRequest
.mockResolvedValueOnce({ c: 10.123, dp: -1.234 }) // AAPL
.mockResolvedValueOnce({ c: null, dp: null }); // MSFT
const req = { query: { watchlist: "AAPL,MSFT", provider: "finnhub", cache: "1" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://finnhub.io/api/v1/quote?symbol=AAPL&token=k", "1");
expect(res.body).toEqual({
stocks: [
{ ticker: "AAPL", currentPrice: "10.12", percentChange: -1.23 },
{ ticker: "MSFT", currentPrice: null, percentChange: null },
],
});
});
it("returns null entries when the watchlist includes empty tickers", async () => {
getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } });
cachedRequest.mockResolvedValueOnce({ c: 1, dp: 1 });
const req = { query: { watchlist: "AAPL,", provider: "finnhub" } };
const res = createMockRes();
await handler(req, res);
expect(res.body.stocks[0]).toEqual({ ticker: "AAPL", currentPrice: "1.00", percentChange: 1 });
expect(res.body.stocks[1]).toEqual({ ticker: null, currentPrice: null, percentChange: null });
});
});

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({
getSettings: vi.fn(),
getPrivateWidgetOptions: vi.fn(),
cachedRequest: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
vi.mock("utils/config/widget-helpers", () => ({
getPrivateWidgetOptions,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/widgets/weather";
describe("pages/api/widgets/weatherapi", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when no API key and no provider are supplied", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const req = { query: { latitude: "1", longitude: "2", lang: "en", index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing API key or provider" });
});
it("uses key from widget options when present", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" });
cachedRequest.mockResolvedValueOnce({ ok: true });
const req = { query: { latitude: "1", longitude: "2", lang: "en", cache: 1, index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith(
"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-widget&lang=en",
1,
);
expect(res.body).toEqual({ ok: true });
});
it("falls back to settings provider key when provider=weatherapi", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
getSettings.mockReturnValueOnce({ providers: { weatherapi: "from-settings" } });
cachedRequest.mockResolvedValueOnce({ ok: true });
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi", cache: "2" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith(
"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-settings&lang=en",
"2",
);
});
it("rejects unsupported providers", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "nope" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid provider for endpoint" });
});
it("returns 400 when a provider is set but no API key can be resolved", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
getSettings.mockReturnValueOnce({ providers: {} });
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing API key" });
});
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import themes from "utils/styles/themes";
const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
import BrowserConfig, { getServerSideProps } from "pages/browserconfig.xml.jsx";
function createMockRes() {
return {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
}
describe("pages/browserconfig.xml", () => {
it("writes a browserconfig xml response using the selected theme color", async () => {
getSettings.mockReturnValueOnce({ color: "slate", theme: "dark" });
const res = createMockRes();
await getServerSideProps({ res });
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/xml");
expect(res.end).toHaveBeenCalled();
const xml = res.write.mock.calls[0][0];
expect(xml).toContain('<?xml version="1.0" encoding="utf-8"?>');
expect(xml).toContain('<square150x150logo src="/mstile-150x150.png?v=2"/>');
expect(xml).toContain(`<TileColor>${themes.slate.dark}</TileColor>`);
});
it("exports a placeholder component", () => {
expect(BrowserConfig()).toBeUndefined();
});
});

View File

@@ -0,0 +1,533 @@
// @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();
const renderResult = 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>,
);
return { ...renderResult, setTheme, setColor, setSettings, setActiveTab };
}
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");
});
it("supports legacy string backgrounds in settings", async () => {
await renderIndex({
initialSettings: {
title: "Homepage",
color: "slate",
background: "https://example.com/bg.jpg",
layout: {},
},
theme: "dark",
color: "emerald",
});
expect(document.querySelector("#background")).toBeTruthy();
});
});
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();
});
it("mutates the hash when the window regains focus", async () => {
state.validateData = [];
state.hashData = { hash: "h" };
state.windowFocused = true;
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
await waitFor(() => {
expect(state.mutateHash).toHaveBeenCalled();
});
});
it("stores the initial hash in localStorage when none exists", async () => {
state.validateData = [];
state.hashData = { hash: "first-hash" };
localStorage.removeItem("hash");
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
await waitFor(() => {
expect(localStorage.getItem("hash")).toBe("first-hash");
});
});
});
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("renders services and bookmark groups when present", async () => {
await renderIndex({
initialSettings: { title: "Homepage", layout: {} },
settings: { title: "Homepage", layout: {}, language: "en" },
});
expect(await screen.findByTestId("services-group")).toHaveTextContent("Services");
expect(screen.getByTestId("bookmarks-group")).toHaveTextContent("Bookmarks");
});
it("renders tab navigation and filters groups by active tab", async () => {
state.servicesData = [{ name: "Services", services: [], groups: [] }];
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }];
await renderIndex({
initialSettings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } },
settings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } },
activeTab: "main",
});
expect(await screen.findAllByTestId("tab")).toHaveLength(1);
expect(screen.getAllByTestId("services-group")[0]).toHaveTextContent("Services");
expect(screen.getAllByTestId("bookmarks-group")[0]).toHaveTextContent("Bookmarks");
});
it("waits for settings.layout to populate when it differs from initial settings", async () => {
state.servicesData = [{ name: "Services", services: [], groups: [] }];
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }];
await renderIndex({
initialSettings: { title: "Homepage", layout: {} },
// Missing layout triggers the temporary `<div />` return to avoid eager widget fetches.
settings: { title: "Homepage" },
});
expect(screen.queryByTestId("services-group")).toBeNull();
expect(screen.queryByTestId("bookmarks-group")).toBeNull();
});
it("applies cardBlur classes for tabs and boxed headers when configured", async () => {
state.servicesData = [{ name: "Services", services: [], groups: [] }];
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }];
state.widgetsData = [{ type: "search" }];
await renderIndex({
initialSettings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } },
settings: {
title: "Homepage",
layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } },
headerStyle: "boxed",
cardBlur: "sm",
},
activeTab: "main",
});
expect(document.querySelector("#myTab")?.className).toContain("backdrop-blur-sm");
expect(document.querySelector("#information-widgets")?.className).toContain("backdrop-blur-sm");
});
it("applies settings-driven language/theme/color updates and renders head tags", async () => {
state.servicesData = [];
state.bookmarksData = [];
state.widgetsData = [];
const { setTheme, setColor, setSettings } = await renderIndex({
initialSettings: { title: "Homepage", layout: {} },
settings: {
title: "Homepage",
layout: {},
language: "en",
theme: "light",
color: "emerald",
disableIndexing: true,
base: "/base/",
favicon: "/x.ico",
},
theme: "dark",
color: "slate",
});
await waitFor(() => {
expect(setSettings).toHaveBeenCalled();
});
expect(i18n.changeLanguage).toHaveBeenCalledWith("en");
expect(setTheme).toHaveBeenCalledWith("light");
expect(setColor).toHaveBeenCalledWith("emerald");
expect(document.querySelector('meta[name="robots"][content="noindex, nofollow"]')).toBeTruthy();
expect(document.querySelector("base")?.getAttribute("href")).toBe("/base/");
expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico");
});
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"]);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
import RobotsTxt, { getServerSideProps } from "pages/robots.txt.js";
function createMockRes() {
return {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
}
describe("pages/robots.txt", () => {
it("allows indexing when disableIndexing is falsey", async () => {
getSettings.mockReturnValueOnce({ disableIndexing: false });
const res = createMockRes();
await getServerSideProps({ res });
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/plain");
expect(res.write).toHaveBeenCalledWith("User-agent: *\nAllow: /");
expect(res.end).toHaveBeenCalled();
});
it("disallows indexing when disableIndexing is truthy", async () => {
getSettings.mockReturnValueOnce({ disableIndexing: true });
const res = createMockRes();
await getServerSideProps({ res });
expect(res.write).toHaveBeenCalledWith("User-agent: *\nDisallow: /");
});
it("exports a placeholder component", () => {
expect(RobotsTxt()).toBeNull();
});
});

View File

@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import themes from "utils/styles/themes";
const { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({
checkAndCopyConfig: vi.fn(),
getSettings: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
getSettings,
}));
import Webmanifest, { getServerSideProps } from "pages/site.webmanifest.jsx";
function createMockRes() {
return {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
}
describe("pages/site.webmanifest", () => {
it("writes a manifest json response and triggers a settings config check", async () => {
getSettings.mockReturnValueOnce({
title: "My Homepage",
startUrl: "/start",
color: "slate",
theme: "dark",
pwa: {
icons: [{ src: "/i.png", sizes: "1x1", type: "image/png" }],
shortcuts: [{ name: "One", url: "/one" }],
},
});
const res = createMockRes();
await getServerSideProps({ res });
expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/manifest+json");
expect(res.end).toHaveBeenCalled();
const manifest = JSON.parse(res.write.mock.calls[0][0]);
expect(manifest.name).toBe("My Homepage");
expect(manifest.short_name).toBe("My Homepage");
expect(manifest.start_url).toBe("/start");
expect(manifest.icons).toEqual([{ src: "/i.png", sizes: "1x1", type: "image/png" }]);
expect(manifest.shortcuts).toEqual([{ name: "One", url: "/one" }]);
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([]);
});
it("exports a placeholder component", () => {
expect(Webmanifest()).toBeNull();
});
});