From dfb25a2d619cd320534f94e8a0ba954e736bd02e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:23:36 -0800 Subject: [PATCH] test: cover middleware + core utils (logger, hooks, proxy) --- src/middleware.test.js | 72 ++++++++++++++++ src/utils/hooks/window-focus.test.jsx | 27 ++++++ src/utils/layout/columns.test.js | 12 +++ src/utils/logger.test.js | 99 ++++++++++++++++++++++ src/utils/proxy/http.test.js | 112 +++++++++++++++++++++++++ src/utils/proxy/use-widget-api.test.js | 49 +++++++++++ 6 files changed, 371 insertions(+) create mode 100644 src/middleware.test.js create mode 100644 src/utils/hooks/window-focus.test.jsx create mode 100644 src/utils/layout/columns.test.js create mode 100644 src/utils/logger.test.js create mode 100644 src/utils/proxy/http.test.js create mode 100644 src/utils/proxy/use-widget-api.test.js diff --git a/src/middleware.test.js b/src/middleware.test.js new file mode 100644 index 000000000..cb37749ec --- /dev/null +++ b/src/middleware.test.js @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { NextResponse } = vi.hoisted(() => ({ + NextResponse: { + json: vi.fn((body, init) => ({ type: "json", body, init })), + next: vi.fn(() => ({ type: "next" })), + }, +})); + +vi.mock("next/server", () => ({ NextResponse })); + +import { middleware } from "./middleware"; + +function createReq(host) { + return { + headers: { + get: (key) => (key === "host" ? host : null), + }, + }; +} + +describe("middleware", () => { + const originalEnv = process.env; + const originalConsoleError = console.error; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + console.error = originalConsoleError; + }); + + it("allows requests for default localhost hosts", () => { + process.env.PORT = "3000"; + const res = middleware(createReq("localhost:3000")); + + expect(NextResponse.next).toHaveBeenCalled(); + expect(res).toEqual({ type: "next" }); + }); + + it("blocks requests when host is not allowed", () => { + process.env.PORT = "3000"; + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const res = middleware(createReq("evil.com")); + + expect(errSpy).toHaveBeenCalled(); + expect(NextResponse.json).toHaveBeenCalledWith( + { error: "Host validation failed. See logs for more details." }, + { status: 400 }, + ); + expect(res.type).toBe("json"); + expect(res.init.status).toBe(400); + }); + + it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => { + process.env.HOMEPAGE_ALLOWED_HOSTS = "*"; + const res = middleware(createReq("anything.example")); + + expect(NextResponse.next).toHaveBeenCalled(); + expect(res).toEqual({ type: "next" }); + }); + + it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => { + process.env.PORT = "3000"; + process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000"; + + const res = middleware(createReq("example.com:3000")); + + expect(NextResponse.next).toHaveBeenCalled(); + expect(res).toEqual({ type: "next" }); + }); +}); diff --git a/src/utils/hooks/window-focus.test.jsx b/src/utils/hooks/window-focus.test.jsx new file mode 100644 index 000000000..818fb15fe --- /dev/null +++ b/src/utils/hooks/window-focus.test.jsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import useWindowFocus from "./window-focus"; + +function Fixture() { + const focused = useWindowFocus(); + return
{String(focused)}
; +} + +describe("utils/hooks/window-focus", () => { + it("tracks focus/blur events", async () => { + vi.spyOn(document, "hasFocus").mockReturnValue(true); + + render(); + + expect(screen.getByTestId("focused")).toHaveTextContent("true"); + + window.dispatchEvent(new Event("blur")); + await waitFor(() => expect(screen.getByTestId("focused")).toHaveTextContent("false")); + + window.dispatchEvent(new Event("focus")); + await waitFor(() => expect(screen.getByTestId("focused")).toHaveTextContent("true")); + }); +}); diff --git a/src/utils/layout/columns.test.js b/src/utils/layout/columns.test.js new file mode 100644 index 000000000..1e8495390 --- /dev/null +++ b/src/utils/layout/columns.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { columnMap } from "./columns"; + +describe("utils/layout/columns", () => { + it("maps column counts to responsive grid classes", () => { + expect(columnMap).toHaveLength(9); + expect(columnMap[1]).toContain("grid-cols-1"); + expect(columnMap[2]).toContain("md:grid-cols-2"); + expect(columnMap[8]).toContain("lg:grid-cols-8"); + }); +}); diff --git a/src/utils/logger.test.js b/src/utils/logger.test.js new file mode 100644 index 000000000..e401785b5 --- /dev/null +++ b/src/utils/logger.test.js @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { state, winston, checkAndCopyConfig, getSettings } = vi.hoisted(() => { + const state = { + created: [], + lastCreateLoggerArgs: null, + }; + + function ConsoleTransport(opts) { + this.opts = opts; + } + function FileTransport(opts) { + this.opts = opts; + } + + const createLogger = vi.fn((args) => { + state.lastCreateLoggerArgs = args; + + const base = { + child: vi.fn(() => base), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + state.created.push(base); + return base; + }); + + const winston = { + transports: { Console: ConsoleTransport, File: FileTransport }, + format: { + combine: (...parts) => ({ parts }), + errors: () => ({}), + timestamp: () => ({}), + colorize: () => ({}), + printf: (fn) => fn, + }, + createLogger, + }; + + return { + state, + winston, + checkAndCopyConfig: vi.fn(), + getSettings: vi.fn(() => ({ logpath: "/tmp" })), + }; +}); + +vi.mock("winston", () => ({ default: winston, ...winston })); + +vi.mock("utils/config/config", () => ({ + default: checkAndCopyConfig, + CONF_DIR: "/conf", + getSettings, +})); + +describe("utils/logger", () => { + const originalEnv = process.env; + const originalConsole = { ...console }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore patched console methods if init() ran. + Object.assign(console, originalConsole); + }); + + it("initializes winston on first createLogger() and caches per label", async () => { + vi.resetModules(); + process.env.LOG_TARGETS = "stdout"; + + const createLogger = (await import("./logger")).default; + + const a1 = createLogger("a"); + const a2 = createLogger("a"); + const b = createLogger("b"); + + expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml"); + expect(winston.createLogger).toHaveBeenCalled(); + expect(a1).toBe(a2); + expect(b).toBeDefined(); + }); + + it("selects stdout/file/both transports based on LOG_TARGETS", async () => { + vi.resetModules(); + process.env.LOG_TARGETS = "file"; + + const createLogger = (await import("./logger")).default; + createLogger("x"); + + const transports = state.lastCreateLoggerArgs.transports; + expect(transports).toHaveLength(1); + expect(transports[0].opts.filename).toBe("/tmp/logs/homepage.log"); + }); +}); diff --git a/src/utils/proxy/http.test.js b/src/utils/proxy/http.test.js new file mode 100644 index 000000000..904ba6861 --- /dev/null +++ b/src/utils/proxy/http.test.js @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { state, cache, logger } = vi.hoisted(() => ({ + state: { + response: { + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Buffer.from(""), + }, + error: null, + }, + cache: { + get: vi.fn(), + put: vi.fn(), + }, + logger: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("follow-redirects", async () => { + const { EventEmitter } = await import("node:events"); + const { Readable } = await import("node:stream"); + + function Agent(opts) { + this.opts = opts; + } + + function makeRequest() { + return (url, params, cb) => { + const req = new EventEmitter(); + req.write = vi.fn(); + req.end = vi.fn(() => { + if (state.error) { + req.emit("error", state.error); + return; + } + + const res = new Readable({ + read() { + this.push(state.response.body); + this.push(null); + }, + }); + res.statusCode = state.response.statusCode; + res.headers = state.response.headers; + cb(res); + }); + return req; + }; + } + + return { + http: { request: makeRequest(), Agent }, + https: { request: makeRequest(), Agent }, + }; +}); + +vi.mock("memory-cache", () => ({ + default: cache, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +describe("utils/proxy/http cachedRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.error = null; + state.response = { + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Buffer.from(""), + }; + vi.resetModules(); + }); + + it("returns cached values without calling httpProxy", async () => { + cache.get.mockReturnValueOnce({ ok: true }); + const httpMod = await import("./http"); + const spy = vi.spyOn(httpMod, "httpProxy"); + + const data = await httpMod.cachedRequest("http://example.com"); + + expect(data).toEqual({ ok: true }); + expect(spy).not.toHaveBeenCalled(); + }); + + it("parses json buffer responses and caches the result", async () => { + cache.get.mockReturnValueOnce(null); + state.response.body = Buffer.from('{"a":1}'); + const httpMod = await import("./http"); + + const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua"); + + expect(data).toEqual({ a: 1 }); + expect(cache.put).toHaveBeenCalledWith("http://example.com/data", { a: 1 }, 1 * 1000 * 60); + }); + + it("falls back to string when cachedRequest cannot parse json", async () => { + cache.get.mockReturnValueOnce(null); + state.response.body = Buffer.from("not-json"); + const httpMod = await import("./http"); + + const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua"); + + expect(data).toBe("not-json"); + expect(logger.debug).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/proxy/use-widget-api.test.js b/src/utils/proxy/use-widget-api.test.js new file mode 100644 index 000000000..eb7e7d1b4 --- /dev/null +++ b/src/utils/proxy/use-widget-api.test.js @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() })); + +vi.mock("swr", () => ({ + default: useSWR, +})); + +import useWidgetAPI from "./use-widget-api"; + +describe("utils/proxy/use-widget-api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("formats the proxy url and passes refreshInterval when provided in options", () => { + useSWR.mockReturnValue({ data: { ok: true }, error: undefined, mutate: "m" }); + + const widget = { service_group: "g", service_name: "s", index: 0 }; + const result = useWidgetAPI(widget, "status", { refreshInterval: 123, foo: "bar" }); + + expect(useSWR).toHaveBeenCalledWith( + expect.stringContaining("/api/services/proxy?"), + expect.objectContaining({ refreshInterval: 123 }), + ); + expect(result.data).toEqual({ ok: true }); + expect(result.error).toBeUndefined(); + expect(result.mutate).toBe("m"); + }); + + it("returns data.error as the top-level error", () => { + const dataError = { message: "nope" }; + useSWR.mockReturnValue({ data: { error: dataError }, error: undefined, mutate: vi.fn() }); + + const widget = { service_group: "g", service_name: "s", index: 0 }; + const result = useWidgetAPI(widget, "status", {}); + + expect(result.error).toBe(dataError); + }); + + it("disables the request when endpoint is an empty string", () => { + useSWR.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() }); + + const widget = { service_group: "g", service_name: "s", index: 0 }; + useWidgetAPI(widget, ""); + + expect(useSWR).toHaveBeenCalledWith(null, {}); + }); +});