Files
homepage/src/utils/config/service-helpers.test.js

644 lines
22 KiB
JavaScript

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: [],
dockerContainersByServer: {},
dockerServicesByServer: {},
kubeConfig: null,
kubeServices: [],
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
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((conn) => ({
listContainers: vi.fn(async () => state.dockerContainersByServer[conn?.serverName] ?? state.dockerContainers),
listServices: vi.fn(async () => state.dockerServicesByServer[conn?.serverName] ?? state.dockerContainers),
}));
const dockerCfg = {
default: vi.fn((serverName) => ({ conn: { serverName } })),
};
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", () => ({
// Keep a stable logger instance so tests don't depend on module re-imports.
default: vi.fn(() => state.logger),
}));
describe("utils/config/service-helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
state.servicesYaml = null;
state.dockerYaml = null;
state.dockerContainers = [];
state.dockerContainersByServer = {};
state.dockerServicesByServer = {};
state.kubeConfig = null;
state.kubeServices = [];
config.getSettings.mockReturnValue({ instanceName: undefined });
});
it("servicesFromConfig returns [] when services.yaml is empty", async () => {
state.servicesYaml = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromConfig()).toEqual([]);
});
it("servicesFromDocker returns [] when docker.yaml is empty", async () => {
state.dockerYaml = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromDocker()).toEqual([]);
});
it("servicesFromDocker tolerates non-array container responses from Docker", async () => {
state.dockerYaml = { "docker-local": {} };
state.dockerContainersByServer["docker-local"] = Buffer.from("bad docker response");
const mod = await import("./service-helpers");
const discovered = await mod.servicesFromDocker();
expect(discovered).toEqual([]);
});
it("servicesFromConfig parses nested groups, assigns default weights, and skips invalid entries", async () => {
state.servicesYaml = [
{
Main: [
{
Child: [{ SvcA: { icon: "a" } }, { SvcB: { icon: "b", weight: 5 } }],
},
{ SvcRoot: { icon: "r" } },
{ BadSvc: null },
],
},
];
const mod = await import("./service-helpers");
const groups = await mod.servicesFromConfig();
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("Main");
expect(groups[0].type).toBe("group");
// Root services live on the group; child groups are nested.
expect(groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
{ name: "SvcRoot", weight: 100 },
]);
expect(groups[0].groups).toHaveLength(1);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
{ name: "SvcA", weight: 100 },
{ name: "SvcB", weight: 5 },
]);
expect(state.logger.warn).toHaveBeenCalled();
});
it("cleanServiceGroups normalizes weights, moves widget->widgets, and parses per-widget settings", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Group",
services: [
{
name: "svc",
showStats: "true",
weight: "not-a-number",
widgets: [
// Invalid fields/highlight should be dropped with a log message.
{ type: "iframe", fields: "{bad}", highlight: "{bad}", src: "https://example.com" },
// Type-specific boolean parsing.
{ type: "portainer", kubernetes: "true" },
{ type: "deluge", enableLeechProgress: "true", enableLeechSize: "false" },
],
// `widget` is appended after the `widgets` array.
widget: {
type: "glances",
metric: "cpu",
chart: false,
version: "3",
refreshInterval: 1500,
pointsLimit: 10,
diskUnits: "gb",
fields: '["cpu"]',
highlight: '{"level":"warning"}',
hideErrors: true,
},
},
{
name: "svc2",
weight: {},
widget: { type: "openwrt", interfaceName: "eth0" },
},
{
name: "svc3",
weight: "7",
widget: { type: "frigate", enableRecentEvents: true },
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
expect(cleaned).toHaveLength(1);
expect(cleaned[0].type).toBe("group");
expect(cleaned[0].services).toHaveLength(3);
const svc = cleaned[0].services[0];
expect(svc.showStats).toBe(true);
expect(svc.weight).toBe(0);
expect(svc.widgets).toHaveLength(4);
// The last widget is the appended `widget` entry; it should carry service metadata.
const glancesWidget = svc.widgets[3];
expect(glancesWidget.type).toBe("glances");
expect(glancesWidget.service_group).toBe("Group");
expect(glancesWidget.service_name).toBe("svc");
expect(glancesWidget.index).toBe(3);
expect(glancesWidget.hide_errors).toBe(true);
expect(glancesWidget.fields).toEqual(["cpu"]);
expect(glancesWidget.highlight).toEqual({ level: "warning" });
expect(glancesWidget.chart).toBe(false);
expect(glancesWidget.version).toBe(3);
// Type-specific parsing for other widgets.
expect(svc.widgets[1].kubernetes).toBe(true);
expect(svc.widgets[2].enableLeechProgress).toBe(true);
expect(svc.widgets[2].enableLeechSize).toBe(false);
const svc2 = cleaned[0].services[1];
expect(svc2.weight).toBe(0);
expect(svc2.widgets).toHaveLength(1);
expect(svc2.widgets[0]).toEqual(
expect.objectContaining({
type: "openwrt",
interfaceName: "eth0",
service_group: "Group",
service_name: "svc2",
index: 0,
}),
);
const svc3 = cleaned[0].services[2];
expect(svc3.weight).toBe(7);
expect(svc3.widgets[0]).toEqual(expect.objectContaining({ type: "frigate", enableRecentEvents: true }));
expect(state.logger.error).toHaveBeenCalled();
});
it("cleanServiceGroups applies widget-type specific mappings for commonly used widgets", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Core",
services: [
{
name: "svc",
weight: 100,
widgets: [
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
{ type: "beszel", version: "2", systemId: "sys" },
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
{ type: "crowdsec", limit24h: "true" },
{ type: "docker", server: "docker-local", container: "c1" },
{ type: "unifi", site: "Home" },
{ type: "proxmox", node: "pve" },
{ type: "proxmoxbackupserver", datastore: "ds" },
{ type: "komodo", showSummary: "true", showStacks: "false" },
{ type: "kubernetes", namespace: "default", app: "app", podSelector: "app=test" },
{
type: "iframe",
src: "https://example.com",
allowFullscreen: true,
allowPolicy: "geolocation",
allowScrolling: false,
classes: "x",
loadingStrategy: "lazy",
referrerPolicy: "no-referrer",
refreshInterval: 1000,
},
{ type: "qbittorrent", enableLeechProgress: "true", enableLeechSize: "true" },
{ type: "opnsense", wan: "wan1" },
{ type: "emby", enableBlocks: "true", enableNowPlaying: "false", enableMediaControl: "true" },
{ type: "tautulli", expandOneStreamToTwoRows: "true", showEpisodeNumber: "true", enableUser: "true" },
{ type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" },
{ type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
{ type: "glances", version: "4", metric: "cpu", refreshInterval: 2000, pointsLimit: 5, diskUnits: "gb" },
{ type: "mjpeg", stream: "s", fit: "contain" },
{ type: "openmediavault", method: "foo.bar" },
{ type: "customapi", mappings: { x: 1 }, display: { y: 2 }, refreshInterval: 5000 },
{
type: "calendar",
integrations: [],
firstDayInWeek: "monday",
view: "agenda",
maxEvents: 10,
previousDays: 2,
showTime: true,
timezone: "UTC",
},
{ type: "dockhand", environment: "prod" },
{ type: "hdhomerun", tuner: 1 },
{ type: "healthchecks", uuid: "u" },
{ type: "speedtest", bitratePrecision: "3", version: "1" },
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
{
type: "tracearr",
expandOneStreamToTwoRows: "true",
showEpisodeNumber: "true",
enableUser: "true",
view: "both",
},
{ type: "wgeasy", threshold: "10", version: "1" },
{ type: "technitium", range: "24h" },
{ type: "lubelogger", vehicleID: "12" },
{ type: "vikunja", enableTaskList: true, version: "1" },
{ type: "prometheusmetric", metrics: [], refreshInterval: 2500 },
{ type: "spoolman", spoolIds: [1, 2] },
{ type: "jellystat", days: "7" },
{ type: "grafana", alerts: [] },
{ type: "unraid", pool1: "a", pool2: "b", pool3: "c", pool4: "d" },
{ type: "yourspotify", interval: "daily" },
],
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
const widgets = cleaned[0].services[0].widgets;
expect(widgets.find((w) => w.type === "azuredevops")).toEqual(
expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
);
expect(widgets.find((w) => w.type === "beszel")).toEqual(expect.objectContaining({ version: 2, systemId: "sys" }));
expect(widgets.find((w) => w.type === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
expect(widgets.find((w) => w.type === "docker")).toEqual(
expect.objectContaining({ server: "docker-local", container: "c1" }),
);
expect(widgets.find((w) => w.type === "komodo")).toEqual(
expect.objectContaining({ showSummary: true, showStacks: false }),
);
expect(widgets.find((w) => w.type === "kubernetes")).toEqual(
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
);
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
expect(widgets.find((w) => w.type === "tracearr")).toEqual(
expect.objectContaining({
expandOneStreamToTwoRows: true,
showEpisodeNumber: true,
enableUser: true,
view: "both",
}),
);
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
});
it("cleanServiceGroups removes calendar integration urls from frontend widget payload", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Core",
services: [
{
name: "Calendar",
weight: 100,
widgets: [
{
type: "calendar",
integrations: [
{
type: "ical",
name: "EPL Fixtures",
url: "https://calendar.google.com/calendar/ical/example/public/basic.ics",
color: "purple",
},
],
},
],
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
const calendarWidget = cleaned[0].services[0].widgets[0];
expect(calendarWidget.integrations).toEqual([
{
type: "ical",
name: "EPL Fixtures",
color: "purple",
},
]);
});
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("getServiceItem returns false when the service cannot be found anywhere", async () => {
state.servicesYaml = null;
state.dockerYaml = null;
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.getServiceItem("MissingGroup", "MissingService")).toBe(false);
});
it("getServiceWidget returns false when the widget cannot be found", async () => {
state.servicesYaml = null;
state.dockerYaml = null;
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.default("MissingGroup", "MissingService", 0)).toBe(false);
});
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" });
});
it("servicesFromDocker maps homepage labels to groups, filters instance-scoped labels, and parses widget version", async () => {
config.getSettings.mockReturnValue({ instanceName: "foo" });
state.dockerYaml = {
"docker-local": {},
"docker-swarm": { swarm: true },
};
state.dockerContainersByServer["docker-local"] = [
{
Names: ["/c1"],
Labels: {
"homepage.group": "G",
"homepage.name": "Svc",
"homepage.href": "http://svc",
"homepage.widget.version": "3",
"homepage.instance.foo.description": "Desc",
"homepage.instance.bar.description": "Ignore",
},
},
// Missing required labels -> should be skipped with an error.
{
Names: ["/bad"],
Labels: {
"homepage.group": "G",
},
},
];
state.dockerServicesByServer["docker-swarm"] = [
// Swarm service label format.
{
Spec: {
Name: "swarm1",
Labels: {
"homepage.group": "G2",
"homepage.name": "SwarmSvc",
"homepage.widgets[0].version": "2",
},
},
},
];
const mod = await import("./service-helpers");
const discoveredGroups = await mod.servicesFromDocker();
expect(discoveredGroups).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "G",
services: [
expect.objectContaining({
name: "Svc",
server: "docker-local",
container: "c1",
href: "http://svc",
description: "Desc",
widget: { version: 3 },
}),
],
}),
expect.objectContaining({
name: "G2",
services: [
expect.objectContaining({
name: "SwarmSvc",
server: "docker-swarm",
container: "swarm1",
widgets: [{ version: 2 }],
}),
],
}),
]),
);
// The instance.bar.* labels should be ignored when instanceName=foo.
expect(JSON.stringify(discoveredGroups)).not.toContain("Ignore");
expect(state.logger.error).toHaveBeenCalled();
});
it("servicesFromDocker tolerates per-server failures and still returns other results", async () => {
state.dockerYaml = { "docker-a": {}, "docker-b": {} };
Docker.mockImplementationOnce(() => {
throw new Error("boom");
});
state.dockerContainers = [{ Names: ["/c1"], Labels: { "homepage.group": "G", "homepage.name": "Svc" } }];
const mod = await import("./service-helpers");
const discoveredGroups = await mod.servicesFromDocker();
expect(discoveredGroups).toEqual([
{ name: "G", services: [expect.objectContaining({ name: "Svc", container: "c1" })] },
]);
expect(["docker-a", "docker-b"]).toContain(discoveredGroups[0].services[0].server);
expect(state.logger.error).toHaveBeenCalled();
});
it("servicesFromKubernetes returns [] when kubernetes is not configured", async () => {
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromKubernetes()).toEqual([]);
});
it("servicesFromKubernetes maps discoverable resources into service groups", async () => {
config.getSettings.mockReturnValue({ instanceName: "foo" });
state.kubeConfig = {}; // truthy
kubeApi.listIngress.mockResolvedValueOnce([{ kind: "Ingress" }]);
kubeApi.isDiscoverable.mockReturnValueOnce(true);
state.kubeServices = [{ name: "S", group: "G", type: "service", href: "http://k" }];
const mod = await import("./service-helpers");
const groups = await mod.servicesFromKubernetes();
expect(groups).toEqual([
{
name: "G",
services: [{ name: "S", type: "service", href: "http://k" }],
},
]);
expect(kubeApi.isDiscoverable).toHaveBeenCalledWith({ kind: "Ingress" }, "foo");
});
it("servicesFromKubernetes logs and rethrows unexpected errors", async () => {
state.kubeConfig = {}; // truthy
kubeApi.listIngress.mockRejectedValueOnce(new Error("boom"));
const mod = await import("./service-helpers");
await expect(mod.servicesFromKubernetes()).rejects.toThrow("boom");
expect(state.logger.error).toHaveBeenCalled();
});
});