From 7df5aa9017c429e176b8dbb7eedbf89a14bd1e58 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:16:38 -0800 Subject: [PATCH] test: add config + widget helper coverage --- src/utils/config/api-response.test.js | 93 ++++++++++++ src/utils/config/service-helpers.test.js | 171 +++++++++++++++++++++++ src/utils/config/widget-helpers.test.js | 88 ++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 src/utils/config/api-response.test.js create mode 100644 src/utils/config/service-helpers.test.js create mode 100644 src/utils/config/widget-helpers.test.js diff --git a/src/utils/config/api-response.test.js b/src/utils/config/api-response.test.js new file mode 100644 index 000000000..5475f6011 --- /dev/null +++ b/src/utils/config/api-response.test.js @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fs, yaml, config, widgetHelpers, serviceHelpers } = vi.hoisted(() => ({ + fs: { + readFile: vi.fn(), + }, + yaml: { + load: vi.fn(), + }, + config: { + CONF_DIR: "/conf", + getSettings: vi.fn(), + substituteEnvironmentVars: vi.fn((s) => s), + default: vi.fn(), + }, + widgetHelpers: { + widgetsFromConfig: vi.fn(), + cleanWidgetGroups: vi.fn(), + }, + serviceHelpers: { + servicesFromDocker: vi.fn(), + servicesFromKubernetes: vi.fn(), + servicesFromConfig: vi.fn(), + cleanServiceGroups: vi.fn((g) => g), + findGroupByName: vi.fn(), + }, +})); + +vi.mock("fs", () => ({ + promises: fs, +})); + +vi.mock("js-yaml", () => ({ + default: yaml, + ...yaml, +})); + +vi.mock("utils/config/config", () => config); +vi.mock("utils/config/widget-helpers", () => widgetHelpers); +vi.mock("utils/config/service-helpers", () => serviceHelpers); + +import { bookmarksResponse, servicesResponse, widgetsResponse } from "./api-response"; + +describe("utils/config/api-response", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("bookmarksResponse sorts groups based on settings layout", async () => { + fs.readFile.mockResolvedValueOnce("ignored"); + config.getSettings.mockResolvedValueOnce({ layout: { B: {}, A: {} } }); + yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { B: [{ LinkB: [{ href: "b" }] }] }]); + + const res = await bookmarksResponse(); + expect(res.map((g) => g.name)).toEqual(["B", "A"]); + }); + + it("widgetsResponse returns sanitized configured widgets", async () => { + widgetHelpers.widgetsFromConfig.mockResolvedValueOnce([{ type: "search", options: { url: "x" } }]); + widgetHelpers.cleanWidgetGroups.mockResolvedValueOnce([{ type: "search", options: { index: 0 } }]); + + expect(await widgetsResponse()).toEqual([{ type: "search", options: { index: 0 } }]); + }); + + it("servicesResponse merges groups and sorts services by weight then name", async () => { + // Minimal stubs for findGroupByName used within servicesResponse. + serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null); + + serviceHelpers.servicesFromDocker.mockResolvedValueOnce([ + { + name: "GroupA", + services: [ + { name: "b", weight: 200 }, + { name: "a", weight: 200 }, + ], + groups: [], + }, + ]); + serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([ + { name: "GroupA", services: [{ name: "c", weight: 100 }], groups: [] }, + ]); + serviceHelpers.servicesFromConfig.mockResolvedValueOnce([ + { name: "GroupA", services: [{ name: "d", weight: 50 }], groups: [] }, + { name: "Empty", services: [], groups: [] }, + ]); + + config.getSettings.mockResolvedValueOnce({ layout: { GroupA: {}, GroupB: {} } }); + + const groups = await servicesResponse(); + expect(groups.map((g) => g.name)).toEqual(["GroupA"]); + expect(groups[0].services.map((s) => s.name)).toEqual(["d", "c", "a", "b"]); + }); +}); diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js new file mode 100644 index 000000000..6ef137780 --- /dev/null +++ b/src/utils/config/service-helpers.test.js @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi } = vi.hoisted(() => { + const state = { + servicesYaml: null, + dockerYaml: null, + dockerContainers: [], + kubeConfig: null, + kubeServices: [], + }; + + const fs = { + readFile: vi.fn(async (filePath) => { + if (String(filePath).endsWith("/services.yaml")) return "services"; + if (String(filePath).endsWith("/docker.yaml")) return "docker"; + return ""; + }), + }; + + const yaml = { + load: vi.fn((contents) => { + if (contents === "services") return state.servicesYaml; + if (contents === "docker") return state.dockerYaml; + return null; + }), + }; + + const config = { + CONF_DIR: "/conf", + getSettings: vi.fn(() => ({ instanceName: undefined })), + substituteEnvironmentVars: vi.fn((s) => s), + default: vi.fn(), + }; + + const Docker = vi.fn(() => ({ + listContainers: vi.fn(async () => state.dockerContainers), + listServices: vi.fn(async () => state.dockerContainers), + })); + + const dockerCfg = { + default: vi.fn(() => ({ conn: {} })), + }; + + const kubeCfg = { + getKubeConfig: vi.fn(() => state.kubeConfig), + }; + + const kubeApi = { + listIngress: vi.fn(async () => []), + listTraefikIngress: vi.fn(async () => []), + listHttpRoute: vi.fn(async () => []), + isDiscoverable: vi.fn(() => true), + constructedServiceFromResource: vi.fn(async () => state.kubeServices.shift()), + }; + + return { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi }; +}); + +vi.mock("fs", () => ({ + promises: fs, +})); + +vi.mock("js-yaml", () => ({ + default: yaml, + ...yaml, +})); + +vi.mock("utils/config/config", () => config); +vi.mock("dockerode", () => ({ default: Docker })); +vi.mock("utils/config/docker", () => dockerCfg); +vi.mock("utils/config/kubernetes", () => kubeCfg); +vi.mock("utils/kubernetes/export", () => ({ default: kubeApi })); + +vi.mock("utils/logger", () => ({ + default: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +describe("utils/config/service-helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.servicesYaml = null; + state.dockerYaml = null; + state.dockerContainers = []; + state.kubeConfig = null; + state.kubeServices = []; + }); + + it("findGroupByName deep-searches and annotates parent", async () => { + const mod = await import("./service-helpers"); + const { findGroupByName } = mod; + + const groups = [ + { + name: "Parent", + groups: [{ name: "Child", services: [], groups: [] }], + services: [], + }, + ]; + + const found = findGroupByName(groups, "Child"); + expect(found.name).toBe("Child"); + expect(found.parent).toBe("Parent"); + }); + + it("getServiceItem prefers configured services over docker/kubernetes", async () => { + // Service present in config -> should return early (no Docker init). + state.servicesYaml = [{ G: [{ S: { icon: "x" } }] }]; + + const mod = await import("./service-helpers"); + const serviceItem = await mod.getServiceItem("G", "S"); + + expect(serviceItem).toEqual(expect.objectContaining({ name: "S", type: "service", icon: "x" })); + expect(Docker).not.toHaveBeenCalled(); + expect(kubeCfg.getKubeConfig).not.toHaveBeenCalled(); + }); + + it("getServiceItem falls back to docker then kubernetes", async () => { + const mod = await import("./service-helpers"); + + // Miss in config, hit in Docker. + state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }]; + state.dockerYaml = { "docker-local": {} }; + state.dockerContainers = [ + { + Names: ["/c1"], + Labels: { + "homepage.group": "G", + "homepage.name": "S", + }, + }, + ]; + + expect(await mod.getServiceItem("G", "S")).toEqual( + expect.objectContaining({ name: "S", server: "docker-local", container: "c1" }), + ); + + // Miss in config, miss in Docker, hit in Kubernetes. + vi.resetModules(); + state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }]; + state.dockerYaml = { "docker-local": {} }; + state.dockerContainers = []; + state.kubeConfig = {}; // truthy => proceed + state.kubeServices = [{ name: "S", group: "G", type: "service" }]; + kubeApi.listIngress.mockResolvedValueOnce([{}]); + + const mod2 = await import("./service-helpers"); + expect(await mod2.getServiceItem("G", "S")).toEqual(expect.objectContaining({ name: "S", type: "service" })); + }); + + it("getServiceWidget returns widget or widgets[index]", async () => { + state.servicesYaml = [ + { + G: [ + { + S: { widget: { id: "single" }, widgets: [{ id: "w0" }, { id: "w1" }] }, + }, + ], + }, + ]; + + const mod = await import("./service-helpers"); + + expect(await mod.default("G", "S", -1)).toEqual({ id: "single" }); + expect(await mod.default("G", "S", "1")).toEqual({ id: "w1" }); + }); +}); diff --git a/src/utils/config/widget-helpers.test.js b/src/utils/config/widget-helpers.test.js new file mode 100644 index 000000000..4d7bbbdbc --- /dev/null +++ b/src/utils/config/widget-helpers.test.js @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fs, yaml, config } = vi.hoisted(() => ({ + fs: { + readFile: vi.fn(), + }, + yaml: { + load: vi.fn(), + }, + config: { + CONF_DIR: "/conf", + substituteEnvironmentVars: vi.fn((s) => s), + default: vi.fn(), + }, +})); + +vi.mock("fs", () => ({ + promises: fs, +})); + +vi.mock("js-yaml", () => ({ + default: yaml, + ...yaml, +})); + +vi.mock("utils/config/config", () => config); + +import { cleanWidgetGroups, getPrivateWidgetOptions, widgetsFromConfig } from "./widget-helpers"; + +describe("utils/config/widget-helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("widgetsFromConfig maps YAML into a typed widgets array with indices", async () => { + fs.readFile.mockResolvedValueOnce("ignored"); + yaml.load.mockReturnValueOnce([{ search: { provider: "google", url: "http://x", key: "k" } }]); + + const widgets = await widgetsFromConfig(); + expect(widgets).toEqual([ + { + type: "search", + options: { index: 0, provider: "google", url: "http://x", key: "k" }, + }, + ]); + }); + + it("cleanWidgetGroups removes private options and hides url except for search/glances", async () => { + const cleaned = await cleanWidgetGroups([ + { type: "search", options: { index: 0, url: "http://x", username: "u", password: "p" } }, + { type: "something", options: { index: 1, url: "http://y", key: "k", foo: 1 } }, + { type: "glances", options: { index: 2, url: "http://z", apiKey: "k", bar: 2 } }, + ]); + + expect(cleaned[0].options.url).toBe("http://x"); + expect(cleaned[0].options.username).toBeUndefined(); + + expect(cleaned[1].options.url).toBeUndefined(); + expect(cleaned[1].options.key).toBeUndefined(); + expect(cleaned[1].options.foo).toBe(1); + + expect(cleaned[2].options.url).toBe("http://z"); + expect(cleaned[2].options.apiKey).toBeUndefined(); + }); + + it("getPrivateWidgetOptions returns private options for a specific widget", async () => { + fs.readFile.mockResolvedValueOnce("ignored"); + yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]); + + const options = await getPrivateWidgetOptions("search", 0); + expect(options).toEqual( + expect.objectContaining({ + index: 0, + url: "http://x", + username: "u", + password: "p", + key: "k", + }), + ); + + // And the full list when no args are provided + fs.readFile.mockResolvedValueOnce("ignored"); + yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u" } }]); + const all = await getPrivateWidgetOptions(); + expect(Array.isArray(all)).toBe(true); + expect(all[0].options.url).toBe("http://x"); + }); +});