From 3b6ccd239f9d97a76365a5d7267f610719248005 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:08:56 -0800 Subject: [PATCH] test: add API route tests --- src/pages/api/bookmarks.test.js | 30 +++++++++++ src/pages/api/config/path.test.js | 71 ++++++++++++++++++++++++ src/pages/api/hash.test.js | 64 ++++++++++++++++++++++ src/pages/api/healthcheck.test.js | 16 ++++++ src/pages/api/ping.test.js | 80 ++++++++++++++++++++++++++++ src/pages/api/releases.test.js | 46 ++++++++++++++++ src/pages/api/revalidate.test.js | 29 ++++++++++ src/pages/api/services/index.test.js | 30 +++++++++++ src/pages/api/theme.test.js | 41 ++++++++++++++ src/pages/api/validate.test.js | 30 +++++++++++ 10 files changed, 437 insertions(+) create mode 100644 src/pages/api/bookmarks.test.js create mode 100644 src/pages/api/config/path.test.js create mode 100644 src/pages/api/hash.test.js create mode 100644 src/pages/api/healthcheck.test.js create mode 100644 src/pages/api/ping.test.js create mode 100644 src/pages/api/releases.test.js create mode 100644 src/pages/api/revalidate.test.js create mode 100644 src/pages/api/services/index.test.js create mode 100644 src/pages/api/theme.test.js create mode 100644 src/pages/api/validate.test.js diff --git a/src/pages/api/bookmarks.test.js b/src/pages/api/bookmarks.test.js new file mode 100644 index 000000000..e5d807c5c --- /dev/null +++ b/src/pages/api/bookmarks.test.js @@ -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 "./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 }); + }); +}); diff --git a/src/pages/api/config/path.test.js b/src/pages/api/config/path.test.js new file mode 100644 index 000000000..b06b77c06 --- /dev/null +++ b/src/pages/api/config/path.test.js @@ -0,0 +1,71 @@ +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 "./[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{}"); + }); +}); diff --git a/src/pages/api/hash.test.js b/src/pages/api/hash.test.js new file mode 100644 index 000000000..c33294969 --- /dev/null +++ b/src/pages/api/hash.test.js @@ -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 "./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 }); + }); +}); diff --git a/src/pages/api/healthcheck.test.js b/src/pages/api/healthcheck.test.js new file mode 100644 index 000000000..2e2579ab4 --- /dev/null +++ b/src/pages/api/healthcheck.test.js @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +import handler from "./healthcheck"; + +describe("pages/api/healthcheck", () => { + it("returns 'up'", () => { + const req = {}; + const res = createMockRes(); + + handler(req, res); + + expect(res.body).toBe("up"); + }); +}); diff --git a/src/pages/api/ping.test.js b/src/pages/api/ping.test.js new file mode 100644 index 000000000..3318593bb --- /dev/null +++ b/src/pages/api/ping.test.js @@ -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 "./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"); + }); +}); diff --git a/src/pages/api/releases.test.js b/src/pages/api/releases.test.js new file mode 100644 index 000000000..c3129bb86 --- /dev/null +++ b/src/pages/api/releases.test.js @@ -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 "./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([]); + }); +}); diff --git a/src/pages/api/revalidate.test.js b/src/pages/api/revalidate.test.js new file mode 100644 index 000000000..dd2f51ad3 --- /dev/null +++ b/src/pages/api/revalidate.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +import handler from "./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"); + }); +}); diff --git a/src/pages/api/services/index.test.js b/src/pages/api/services/index.test.js new file mode 100644 index 000000000..1d981209e --- /dev/null +++ b/src/pages/api/services/index.test.js @@ -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 "./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: [] }); + }); +}); diff --git a/src/pages/api/theme.test.js b/src/pages/api/theme.test.js new file mode 100644 index 000000000..f454f5785 --- /dev/null +++ b/src/pages/api/theme.test.js @@ -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 "./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" }); + }); +}); diff --git a/src/pages/api/validate.test.js b/src/pages/api/validate.test.js new file mode 100644 index 000000000..c8746f09b --- /dev/null +++ b/src/pages/api/validate.test.js @@ -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 "./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"]); + }); +});