Add core unit tests (config, highlights, proxy handlers)

This commit is contained in:
shamoon
2026-02-03 13:27:00 -08:00
parent da7296e05a
commit 27b3e50227
4 changed files with 271 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: 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: vi.fn() }));
// Provide a minimal widget registry for mapping tests.
vi.mock("widgets/widgets", () => ({
default: {
linkwarden: {
api: "{url}/api/v1/{endpoint}",
mappings: {
collections: { endpoint: "collections" },
},
},
},
}));
import servicesProxy from "./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", () => {
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" });
});
});

View File

@@ -0,0 +1,59 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import cache from "memory-cache";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("utils/config/config", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
cache.del("homepageEnvironmentVariables");
});
afterEach(() => {
process.env = originalEnv;
cache.del("homepageEnvironmentVariables");
});
it("substituteEnvironmentVars replaces HOMEPAGE_VAR_* placeholders", async () => {
process.env.HOMEPAGE_VAR_FOO = "bar";
const mod = await import("./config");
expect(mod.substituteEnvironmentVars("x {{HOMEPAGE_VAR_FOO}} y")).toBe("x bar y");
});
it("substituteEnvironmentVars replaces HOMEPAGE_FILE_* placeholders with file contents", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "homepage-config-test-"));
const secretPath = path.join(dir, "secret.txt");
writeFileSync(secretPath, "secret", "utf8");
process.env.HOMEPAGE_FILE_SECRET = secretPath;
const mod = await import("./config");
expect(mod.substituteEnvironmentVars("token={{HOMEPAGE_FILE_SECRET}}")).toBe("token=secret");
});
it("getSettings reads from HOMEPAGE_CONFIG_DIR and converts layout list to an object", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "homepage-settings-test-"));
process.env.HOMEPAGE_CONFIG_DIR = dir;
process.env.HOMEPAGE_VAR_TITLE = "MyTitle";
// Create a minimal settings.yaml; checkAndCopyConfig will see it exists and won't copy skeleton.
writeFileSync(
path.join(dir, "settings.yaml"),
['title: "{{HOMEPAGE_VAR_TITLE}}"', "layout:", " - GroupA:", " style: row"].join("\n"),
"utf8",
);
vi.resetModules(); // ensure CONF_DIR is computed from updated env
const mod = await import("./config");
const settings = mod.getSettings();
expect(settings.title).toBe("MyTitle");
expect(settings.layout).toEqual({ GroupA: { style: "row" } });
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { buildHighlightConfig, evaluateHighlight, getHighlightClass } from "./highlights";
describe("utils/highlights", () => {
it("buildHighlightConfig merges levels and namespaces unqualified field keys", () => {
const cfg = buildHighlightConfig(
{ levels: { warn: "global-warn" } },
{
levels: { warn: "widget-warn", custom: "widget-custom" },
cpu: { numeric: { when: "gt", value: 80, level: "warn" } },
},
"resources",
);
expect(cfg).not.toBeNull();
expect(cfg.levels.warn).toBe("widget-warn");
expect(cfg.levels.custom).toBe("widget-custom");
// Field keys get normalized + namespaced.
expect(cfg.fields.cpu).toBeTruthy();
expect(cfg.fields["resources.cpu"]).toBeTruthy();
});
it("evaluateHighlight returns matching numeric rule with valueOnly metadata", () => {
const cfg = buildHighlightConfig(
null,
{
// valueOnly should propagate through the result so Block can apply styling.
cpu: { valueOnly: true, numeric: { when: "gte", value: 90, level: "danger" } },
},
"resources",
);
const hit = evaluateHighlight("resources.cpu", " 90 ", cfg);
expect(hit).toMatchObject({ level: "danger", source: "numeric", valueOnly: true });
});
it("evaluateHighlight supports string rules (case-insensitive includes)", () => {
const cfg = buildHighlightConfig(null, { status: { string: { when: "includes", value: "down", level: "warn" } } });
const hit = evaluateHighlight("status", "Service DOWN", cfg);
expect(hit).toMatchObject({ level: "warn", source: "string" });
});
it("getHighlightClass returns configured class for a level", () => {
const cfg = buildHighlightConfig({ levels: { danger: "danger-class" } }, {}, "x");
expect(getHighlightClass("danger", cfg)).toBe("danger-class");
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
const { httpProxy } = vi.hoisted(() => ({ httpProxy: vi.fn() }));
const { validateWidgetData } = vi.hoisted(() => ({ validateWidgetData: vi.fn(() => true) }));
const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() }));
vi.mock("utils/logger", () => ({
default: () => ({
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("utils/proxy/http", () => ({ httpProxy }));
vi.mock("utils/proxy/validate-widget-data", () => ({ default: validateWidgetData }));
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
// Keep the widget registry minimal so the test doesn't import the whole widget graph.
vi.mock("widgets/widgets", () => ({
default: {
linkwarden: { api: "{url}/api/v1/{endpoint}" },
nextcloud: { api: "{url}/ocs/v2.php/apps/serverinfo/api/v1/{endpoint}" },
},
}));
import credentialedProxyHandler from "./credentialed";
function createMockRes() {
const res = {
headers: {},
statusCode: undefined,
body: undefined,
setHeader: (k, v) => {
res.headers[k] = v;
},
status: (code) => {
res.statusCode = code;
return res;
},
json: (data) => {
res.body = data;
return res;
},
send: (data) => {
res.body = data;
return res;
},
end: () => res,
};
return res;
}
describe("utils/proxy/handlers/credentialed", () => {
it("uses Bearer auth for linkwarden widgets", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalled();
const [, params] = httpProxy.mock.calls[0];
expect(params.headers.Authorization).toBe("Bearer token");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it("uses NC-Token auth for nextcloud widgets when key is provided", async () => {
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", key: "nc-token" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "status", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers["NC-Token"]).toBe("nc-token");
expect(params.headers.Authorization).toBeUndefined();
});
});