diff --git a/src/pages/api/docker/stats/service.test.js b/src/pages/api/docker/stats/service.test.js new file mode 100644 index 000000000..42c64e070 --- /dev/null +++ b/src/pages/api/docker/stats/service.test.js @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { state, getDockerArguments, Docker, logger } = vi.hoisted(() => { + const state = { + dockerArgs: { conn: {}, swarm: false }, + listContainersResult: [], + tasks: [], + statsByContainer: {}, + throwOnStatsFor: new Set(), + docker: null, + }; + + const getDockerArguments = vi.fn(() => state.dockerArgs); + + const Docker = vi.fn(() => { + const docker = { + listContainers: vi.fn(async () => state.listContainersResult), + listTasks: vi.fn(async () => state.tasks), + getContainer: vi.fn((idOrName) => ({ + stats: vi.fn(async () => { + if (state.throwOnStatsFor.has(idOrName)) throw new Error("nope"); + return state.statsByContainer[idOrName] ?? { ok: true }; + }), + })), + }; + + state.docker = docker; + return docker; + }); + + const logger = { error: vi.fn() }; + + return { state, getDockerArguments, Docker, logger }; +}); + +vi.mock("dockerode", () => ({ default: Docker })); +vi.mock("utils/config/docker", () => ({ default: getDockerArguments })); +vi.mock("utils/logger", () => ({ default: () => logger })); + +import handler from "./[...service]"; + +describe("pages/api/docker/stats/[...service]", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.dockerArgs = { conn: {}, swarm: false }; + state.listContainersResult = []; + state.tasks = []; + state.statsByContainer = {}; + state.throwOnStatsFor = new Set(); + state.docker = null; + }); + + it("returns 400 when docker parameters are missing", async () => { + const req = { query: { service: [] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("docker query parameters"); + }); + + it("returns 500 when docker listContainers returns a non-array", async () => { + state.listContainersResult = Buffer.from("nope"); + + const req = { query: { service: ["c1", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toBe("query failed"); + }); + + it("returns container stats when the container exists", async () => { + state.listContainersResult = [{ Id: "id1", Names: ["/c1"] }]; + state.statsByContainer.c1 = { cpu: 1 }; + + const req = { query: { service: ["c1", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(getDockerArguments).toHaveBeenCalledWith("docker-local"); + expect(state.docker.getContainer).toHaveBeenCalledWith("c1"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ stats: { cpu: 1 } }); + }); + + it("falls back to swarm tasks and returns a task container's stats when available", async () => { + state.dockerArgs = { conn: {}, swarm: true }; + state.listContainersResult = [{ Id: "local1", Names: ["/other"] }]; + state.tasks = [ + { Status: { ContainerStatus: { ContainerID: "local1" } } }, + { Status: { ContainerStatus: { ContainerID: "remote2" } } }, + ]; + state.statsByContainer.local1 = { cpu: 2 }; + + const req = { query: { service: ["swarmservice", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.stats).toEqual({ cpu: 2 }); + }); + + it("returns a 200 error payload when a swarm task exists but stats cannot be retrieved", async () => { + state.dockerArgs = { conn: {}, swarm: true }; + state.listContainersResult = [{ Id: "local1", Names: ["/other"] }]; + state.tasks = [{ Status: { ContainerStatus: { ContainerID: "local1" } } }]; + state.throwOnStatsFor.add("local1"); + + const req = { query: { service: ["swarmservice", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.error).toBe("Unable to retrieve stats"); + }); +}); diff --git a/src/pages/api/docker/status/service.test.js b/src/pages/api/docker/status/service.test.js new file mode 100644 index 000000000..d2718d788 --- /dev/null +++ b/src/pages/api/docker/status/service.test.js @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { state, getDockerArguments, Docker, logger } = vi.hoisted(() => { + const state = { + dockerArgs: { conn: {}, swarm: false }, + listContainersResult: [], + tasks: [], + inspectByContainer: {}, + serviceInfo: undefined, + docker: null, + }; + + const getDockerArguments = vi.fn(() => state.dockerArgs); + + const Docker = vi.fn(() => { + const docker = { + listContainers: vi.fn(async () => state.listContainersResult), + listTasks: vi.fn(async () => state.tasks), + getContainer: vi.fn((idOrName) => ({ + inspect: vi.fn(async () => state.inspectByContainer[idOrName]), + })), + getService: vi.fn(() => ({ + inspect: vi.fn(async () => state.serviceInfo), + })), + }; + + state.docker = docker; + return docker; + }); + + const logger = { error: vi.fn() }; + + return { state, getDockerArguments, Docker, logger }; +}); + +vi.mock("dockerode", () => ({ default: Docker })); +vi.mock("utils/config/docker", () => ({ default: getDockerArguments })); +vi.mock("utils/logger", () => ({ default: () => logger })); + +import handler from "./[...service]"; + +describe("pages/api/docker/status/[...service]", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.dockerArgs = { conn: {}, swarm: false }; + state.listContainersResult = []; + state.tasks = []; + state.inspectByContainer = {}; + state.serviceInfo = undefined; + state.docker = null; + }); + + it("returns 400 when docker parameters are missing", async () => { + const req = { query: { service: [] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("docker query parameters"); + }); + + it("returns status/health when the container exists", async () => { + state.listContainersResult = [{ Id: "id1", Names: ["/c1"] }]; + state.inspectByContainer.c1 = { State: { Status: "running", Health: { Status: "healthy" } } }; + + const req = { query: { service: ["c1", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ status: "running", health: "healthy" }); + }); + + it("returns replicated swarm service status based on task count", async () => { + state.dockerArgs = { conn: {}, swarm: true }; + state.listContainersResult = [{ Id: "id1", Names: ["/other"] }]; + state.serviceInfo = { Spec: { Mode: { Replicated: { Replicas: "3" } } } }; + state.tasks = [{}, {}, {}]; + + const req = { query: { service: ["svc", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.status).toBe("running 3/3"); + }); + + it("returns global swarm service status from a local task container when available", async () => { + state.dockerArgs = { conn: {}, swarm: true }; + state.listContainersResult = [{ Id: "local1", Names: ["/other"] }]; + state.serviceInfo = { Spec: { Mode: {} } }; + state.tasks = [{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "running" } }]; + state.inspectByContainer.local1 = { State: { Status: "running" } }; + + const req = { query: { service: ["svc", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.status).toBe("running"); + }); + + it("returns 404 when no container/service is found", async () => { + state.dockerArgs = { conn: {}, swarm: false }; + state.listContainersResult = [{ Id: "id1", Names: ["/other"] }]; + + const req = { query: { service: ["missing", "docker-local"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.body.status).toBe("not found"); + }); +}); diff --git a/src/pages/api/kubernetes/stats/service.test.js b/src/pages/api/kubernetes/stats/service.test.js new file mode 100644 index 000000000..36c856c06 --- /dev/null +++ b/src/pages/api/kubernetes/stats/service.test.js @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { coreApi, metricsApi, kc, getKubeConfig, parseCpu, parseMemory, logger } = vi.hoisted(() => { + const coreApi = { listNamespacedPod: vi.fn() }; + const metricsApi = { getPodMetrics: vi.fn() }; + const kc = { makeApiClient: vi.fn(() => coreApi) }; + + const getKubeConfig = vi.fn(); + const parseCpu = vi.fn(); + const parseMemory = vi.fn(); + const logger = { error: vi.fn() }; + + return { coreApi, metricsApi, kc, getKubeConfig, parseCpu, parseMemory, logger }; +}); + +vi.mock("@kubernetes/client-node", () => ({ + CoreV1Api: class CoreV1Api {}, + Metrics: class Metrics { + constructor() { + return metricsApi; + } + }, +})); + +vi.mock("../../../../utils/config/kubernetes", () => ({ + getKubeConfig, +})); + +vi.mock("../../../../utils/kubernetes/utils", () => ({ + parseCpu, + parseMemory, +})); + +vi.mock("../../../../utils/logger", () => ({ + default: () => logger, +})); + +import handler from "./[...service]"; + +describe("pages/api/kubernetes/stats/[...service]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when kubernetes parameters are missing", async () => { + const req = { query: { service: [] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("kubernetes query parameters"); + }); + + it("returns 500 when no kube config is available", async () => { + getKubeConfig.mockReturnValueOnce(null); + + const req = { query: { service: ["ns", "app"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toContain("No kubernetes configuration"); + }); + + it("returns 404 when no pods are found", async () => { + getKubeConfig.mockReturnValueOnce(kc); + coreApi.listNamespacedPod.mockResolvedValueOnce({ items: [] }); + + const req = { query: { service: ["ns", "app"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.body.error).toContain("no pods found"); + }); + + it("returns limits even when metrics are not available yet", async () => { + getKubeConfig.mockReturnValueOnce(kc); + parseCpu.mockImplementation((v) => (v === "500m" ? 0.5 : 0)); + parseMemory.mockImplementation((v) => (v === "1Gi" ? 1024 : 0)); + + coreApi.listNamespacedPod.mockResolvedValueOnce({ + items: [ + { + metadata: { name: "p1" }, + spec: { containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }] }, + }, + ], + }); + metricsApi.getPodMetrics.mockRejectedValueOnce({ statusCode: 404 }); + + const req = { query: { service: ["ns", "app"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.stats).toEqual( + expect.objectContaining({ + cpu: 0, + mem: 0, + cpuLimit: 0.5, + memLimit: 1024, + cpuUsage: 0, + memUsage: 0, + }), + ); + }); + + it("returns usage calculated from pod metrics", async () => { + getKubeConfig.mockReturnValueOnce(kc); + parseCpu.mockImplementation((v) => (v === "100m" ? 0.1 : v === "500m" ? 0.5 : 0)); + parseMemory.mockImplementation((v) => (v === "256Mi" ? 256 : v === "1Gi" ? 1024 : 0)); + + coreApi.listNamespacedPod.mockResolvedValueOnce({ + items: [ + { + metadata: { name: "p1" }, + spec: { containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }] }, + }, + ], + }); + metricsApi.getPodMetrics.mockResolvedValueOnce({ + items: [ + { + metadata: { name: "p1" }, + containers: [{ usage: { cpu: "100m", memory: "256Mi" } }], + }, + ], + }); + + const req = { query: { service: ["ns", "app"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.stats.cpu).toBeCloseTo(0.1); + expect(res.body.stats.mem).toBe(256); + expect(res.body.stats.cpuUsage).toBeCloseTo(20); + expect(res.body.stats.memUsage).toBeCloseTo(25); + }); +}); diff --git a/src/pages/api/kubernetes/status/service.test.js b/src/pages/api/kubernetes/status/service.test.js new file mode 100644 index 000000000..5c68a6801 --- /dev/null +++ b/src/pages/api/kubernetes/status/service.test.js @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { coreApi, kc, getKubeConfig, logger } = vi.hoisted(() => { + const coreApi = { listNamespacedPod: vi.fn() }; + const kc = { makeApiClient: vi.fn(() => coreApi) }; + const getKubeConfig = vi.fn(); + const logger = { error: vi.fn() }; + + return { coreApi, kc, getKubeConfig, logger }; +}); + +vi.mock("@kubernetes/client-node", () => ({ + CoreV1Api: class CoreV1Api {}, +})); + +vi.mock("../../../../utils/config/kubernetes", () => ({ + getKubeConfig, +})); + +vi.mock("../../../../utils/logger", () => ({ + default: () => logger, +})); + +import handler from "./[...service]"; + +describe("pages/api/kubernetes/status/[...service]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when kubernetes parameters are missing", async () => { + const req = { query: { service: [] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("kubernetes query parameters"); + }); + + it("returns 500 when no kube config is available", async () => { + getKubeConfig.mockReturnValueOnce(null); + + const req = { query: { service: ["ns", "app"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toContain("No kubernetes configuration"); + }); + + it("returns 404 when no pods are found", async () => { + getKubeConfig.mockReturnValueOnce(kc); + coreApi.listNamespacedPod.mockResolvedValueOnce({ items: [] }); + + const req = { query: { service: ["ns", "app"] } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.body.status).toBe("not found"); + }); + + it("computes running/partial/down from pod phases", async () => { + getKubeConfig.mockReturnValueOnce(kc); + coreApi.listNamespacedPod.mockResolvedValueOnce({ + items: [{ status: { phase: "Running" } }, { status: { phase: "Running" } }], + }); + + const resRunning = createMockRes(); + await handler({ query: { service: ["ns", "app"] } }, resRunning); + expect(resRunning.statusCode).toBe(200); + expect(resRunning.body.status).toBe("running"); + + getKubeConfig.mockReturnValueOnce(kc); + coreApi.listNamespacedPod.mockResolvedValueOnce({ + items: [{ status: { phase: "Running" } }, { status: { phase: "Pending" } }], + }); + + const resPartial = createMockRes(); + await handler({ query: { service: ["ns", "app"] } }, resPartial); + expect(resPartial.statusCode).toBe(200); + expect(resPartial.body.status).toBe("partial"); + }); +}); diff --git a/src/pages/api/proxmox/stats/service.test.js b/src/pages/api/proxmox/stats/service.test.js new file mode 100644 index 000000000..0fac843c7 --- /dev/null +++ b/src/pages/api/proxmox/stats/service.test.js @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getProxmoxConfig, httpProxy, logger } = vi.hoisted(() => ({ + getProxmoxConfig: vi.fn(), + httpProxy: vi.fn(), + logger: { error: vi.fn() }, +})); + +vi.mock("utils/config/proxmox", () => ({ + getProxmoxConfig, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import handler from "./[...service]"; + +describe("pages/api/proxmox/stats/[...service]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when proxmox node parameter is missing", async () => { + const req = { query: { service: [], type: "qemu" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("Proxmox node"); + }); + + it("returns 500 when proxmox config is missing", async () => { + getProxmoxConfig.mockReturnValueOnce(null); + + const req = { query: { service: ["pve", "100"], type: "qemu" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toContain("configuration"); + }); + + it("returns 400 when node config is missing and legacy creds are not present", async () => { + getProxmoxConfig.mockReturnValueOnce({}); + + const req = { query: { service: ["pve", "100"], type: "qemu" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("Proxmox config not found"); + }); + + it("calls proxmox status endpoint and returns normalized stats", async () => { + getProxmoxConfig.mockReturnValueOnce({ + pve: { url: "http://pve", token: "t", secret: "s" }, + }); + httpProxy.mockResolvedValueOnce([ + 200, + null, + Buffer.from(JSON.stringify({ data: { status: "running", cpu: 0.1, mem: 0.2 } })), + ]); + + const req = { query: { service: ["pve", "100"], type: "qemu" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(httpProxy).toHaveBeenCalledWith( + "http://pve/api2/json/nodes/pve/qemu/100/status/current", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "PVEAPIToken=t=s", + }), + }), + ); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ status: "running", cpu: 0.1, mem: 0.2 }); + }); + + it("returns proxmox http errors as response status codes", async () => { + getProxmoxConfig.mockReturnValueOnce({ + pve: { url: "http://pve", token: "t", secret: "s" }, + }); + httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]); + + const req = { query: { service: ["pve", "100"], type: "qemu" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + expect(res.body.error).toContain("Failed to fetch Proxmox"); + }); +});