From 724894a27c9db0e4100724bf33335f8d1231e1ef Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:01:44 -0800 Subject: [PATCH] test: cover info widget API routes --- src/pages/api/widgets/glances.test.js | 76 ++++++++++ src/pages/api/widgets/index.test.js | 30 ++++ src/pages/api/widgets/kubernetes.test.js | 147 +++++++++++++++++++ src/pages/api/widgets/longhorn.test.js | 87 +++++++++++ src/pages/api/widgets/openmeteo.test.js | 52 +++++++ src/pages/api/widgets/openweathermap.test.js | 99 +++++++++++++ src/pages/api/widgets/resources.js | 4 +- src/pages/api/widgets/resources.test.js | 127 ++++++++++++++++ src/pages/api/widgets/stocks.test.js | 82 +++++++++++ src/pages/api/widgets/weather.test.js | 73 +++++++++ 10 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 src/pages/api/widgets/glances.test.js create mode 100644 src/pages/api/widgets/index.test.js create mode 100644 src/pages/api/widgets/kubernetes.test.js create mode 100644 src/pages/api/widgets/longhorn.test.js create mode 100644 src/pages/api/widgets/openmeteo.test.js create mode 100644 src/pages/api/widgets/openweathermap.test.js create mode 100644 src/pages/api/widgets/resources.test.js create mode 100644 src/pages/api/widgets/stocks.test.js create mode 100644 src/pages/api/widgets/weather.test.js diff --git a/src/pages/api/widgets/glances.test.js b/src/pages/api/widgets/glances.test.js new file mode 100644 index 000000000..1993b6363 --- /dev/null +++ b/src/pages/api/widgets/glances.test.js @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({ + getPrivateWidgetOptions: vi.fn(), + httpProxy: vi.fn(), + logger: { error: vi.fn() }, +})); + +vi.mock("utils/config/widget-helpers", () => ({ + getPrivateWidgetOptions, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import handler from "./glances"; + +describe("pages/api/widgets/glances", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when the widget URL is missing", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + + const req = { query: { index: "0" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe("Missing Glances URL"); + }); + + it("returns cpu/load/mem and includes optional endpoints when requested", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances", username: "u", password: "p" }); + + httpProxy + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]) // mem + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify("1 days"))]) // uptime + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ label: "cpu_thermal", value: 50 }]))]) // sensors + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ mnt_point: "/", percent: 1 }]))]); // fs + + const req = { query: { index: "0", uptime: "1", cputemp: "1", disk: "1", version: "4" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(httpProxy).toHaveBeenCalledWith( + "http://glances/api/4/cpu", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ Authorization: expect.any(String) }), + }), + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + cpu: { total: 1 }, + load: { avg: 2 }, + mem: { available: 3 }, + uptime: "1 days", + sensors: [{ label: "cpu_thermal", value: 50 }], + fs: [{ mnt_point: "/", percent: 1 }], + }); + }); +}); diff --git a/src/pages/api/widgets/index.test.js b/src/pages/api/widgets/index.test.js new file mode 100644 index 000000000..e8c5622e3 --- /dev/null +++ b/src/pages/api/widgets/index.test.js @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { widgetsResponse } = vi.hoisted(() => ({ + widgetsResponse: vi.fn(), +})); + +vi.mock("utils/config/api-response", () => ({ + widgetsResponse, +})); + +import handler from "./index"; + +describe("pages/api/widgets/index", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns widgetsResponse()", async () => { + widgetsResponse.mockResolvedValueOnce([{ type: "logo", options: {} }]); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual([{ type: "logo", options: {} }]); + }); +}); diff --git a/src/pages/api/widgets/kubernetes.test.js b/src/pages/api/widgets/kubernetes.test.js new file mode 100644 index 000000000..a01a875cf --- /dev/null +++ b/src/pages/api/widgets/kubernetes.test.js @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { kc, coreApi, metricsApi, getKubeConfig, parseCpu, parseMemory, logger } = vi.hoisted(() => { + const coreApi = { listNode: vi.fn() }; + const metricsApi = { getNodeMetrics: vi.fn() }; + + const kc = { + makeApiClient: vi.fn(() => coreApi), + }; + + return { + kc, + coreApi, + metricsApi, + getKubeConfig: vi.fn(), + parseCpu: vi.fn(), + parseMemory: vi.fn(), + logger: { error: vi.fn(), debug: vi.fn() }, + }; +}); + +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 "./kubernetes"; + +describe("pages/api/widgets/kubernetes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 500 when no kube config is available", async () => { + getKubeConfig.mockReturnValueOnce(null); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toBe("No kubernetes configuration"); + }); + + it("returns 500 when listing nodes fails", async () => { + getKubeConfig.mockReturnValueOnce(kc); + coreApi.listNode.mockResolvedValueOnce(null); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toContain("fetching nodes"); + }); + + it("returns 500 when metrics lookup fails", async () => { + getKubeConfig.mockReturnValueOnce(kc); + parseMemory.mockReturnValue(100); + coreApi.listNode.mockResolvedValueOnce({ + items: [ + { + metadata: { name: "n1" }, + status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] }, + }, + ], + }); + metricsApi.getNodeMetrics.mockRejectedValueOnce(new Error("nope")); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toContain("Error getting metrics"); + }); + + it("returns cluster totals and per-node usage", async () => { + getKubeConfig.mockReturnValueOnce(kc); + + parseMemory.mockImplementation((value) => { + if (value === "100") return 100; + if (value === "50") return 50; + if (value === "30") return 30; + return 0; + }); + parseCpu.mockImplementation((value) => { + if (value === "100m") return 0.1; + if (value === "200m") return 0.2; + return 0; + }); + + coreApi.listNode.mockResolvedValueOnce({ + items: [ + { + metadata: { name: "n1" }, + status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] }, + }, + { + metadata: { name: "n2" }, + status: { capacity: { cpu: "2", memory: "50" }, conditions: [{ type: "Ready", status: "False" }] }, + }, + ], + }); + + metricsApi.getNodeMetrics.mockResolvedValueOnce({ + items: [ + { metadata: { name: "n1" }, usage: { cpu: "100m", memory: "30" } }, + { metadata: { name: "n2" }, usage: { cpu: "200m", memory: "50" } }, + ], + }); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.cluster.cpu.total).toBe(3); + expect(res.body.cluster.cpu.load).toBeCloseTo(0.3); + expect(res.body.cluster.memory.total).toBe(150); + expect(res.body.nodes).toHaveLength(2); + expect(res.body.nodes.find((n) => n.name === "n1").cpu.percent).toBeCloseTo(10); + }); +}); diff --git a/src/pages/api/widgets/longhorn.test.js b/src/pages/api/widgets/longhorn.test.js new file mode 100644 index 000000000..e3668719d --- /dev/null +++ b/src/pages/api/widgets/longhorn.test.js @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getSettings, httpProxy, logger } = vi.hoisted(() => ({ + getSettings: vi.fn(), + httpProxy: vi.fn(), + logger: { error: vi.fn() }, +})); + +vi.mock("../../../utils/config/config", () => ({ + getSettings, +})); + +vi.mock("../../../utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("../../../utils/logger", () => ({ + default: () => logger, +})); + +import handler from "./longhorn"; + +describe("pages/api/widgets/longhorn", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when the longhorn URL isn't configured", async () => { + getSettings.mockReturnValueOnce({ providers: { longhorn: {} } }); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe("Missing Longhorn URL"); + }); + + it("parses and aggregates node disk totals, including a total node", async () => { + getSettings.mockReturnValueOnce({ + providers: { longhorn: { url: "http://lh", username: "u", password: "p" } }, + }); + + const payload = { + data: [ + { + id: "n1", + disks: { + d1: { storageAvailable: 1, storageMaximum: 10, storageReserved: 2, storageScheduled: 3 }, + }, + }, + { + id: "n2", + disks: { + d1: { storageAvailable: 4, storageMaximum: 20, storageReserved: 5, storageScheduled: 6 }, + d2: { storageAvailable: 1, storageMaximum: 1, storageReserved: 1, storageScheduled: 1 }, + }, + }, + ], + }; + + httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify(payload)]); + + const req = { query: {} }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.headers["Content-Type"]).toBe("application/json"); + expect(res.statusCode).toBe(200); + + const nodes = res.body.nodes; + expect(nodes.map((n) => n.id)).toEqual(["n1", "n2", "total"]); + expect(nodes.find((n) => n.id === "total")).toEqual( + expect.objectContaining({ + id: "total", + available: 6, + maximum: 31, + reserved: 8, + scheduled: 10, + }), + ); + }); +}); diff --git a/src/pages/api/widgets/openmeteo.test.js b/src/pages/api/widgets/openmeteo.test.js new file mode 100644 index 000000000..3a67a93f7 --- /dev/null +++ b/src/pages/api/widgets/openmeteo.test.js @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { cachedRequest } = vi.hoisted(() => ({ + cachedRequest: vi.fn(), +})); + +vi.mock("utils/proxy/http", () => ({ + cachedRequest, +})); + +import handler from "./openmeteo"; + +describe("pages/api/widgets/openmeteo", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("builds the open-meteo URL with units + timezone and calls cachedRequest", async () => { + cachedRequest.mockResolvedValueOnce({ ok: true }); + + const req = { + query: { latitude: "1", longitude: "2", units: "metric", cache: "5" }, + }; + const res = createMockRes(); + + await handler(req, res); + + expect(cachedRequest).toHaveBeenCalledWith( + "https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset¤t_weather=true&temperature_unit=celsius&timezone=auto", + "5", + ); + expect(res.body).toEqual({ ok: true }); + }); + + it("uses the provided timezone and fahrenheit for non-metric units", async () => { + cachedRequest.mockResolvedValueOnce({ ok: true }); + + const req = { + query: { latitude: "1", longitude: "2", units: "imperial", cache: 1, timezone: "UTC" }, + }; + const res = createMockRes(); + + await handler(req, res); + + expect(cachedRequest).toHaveBeenCalledWith( + "https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset¤t_weather=true&temperature_unit=fahrenheit&timezone=UTC", + 1, + ); + }); +}); diff --git a/src/pages/api/widgets/openweathermap.test.js b/src/pages/api/widgets/openweathermap.test.js new file mode 100644 index 000000000..0ba3af920 --- /dev/null +++ b/src/pages/api/widgets/openweathermap.test.js @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({ + getSettings: vi.fn(), + getPrivateWidgetOptions: vi.fn(), + cachedRequest: vi.fn(), +})); + +vi.mock("utils/config/config", () => ({ + getSettings, +})); + +vi.mock("utils/config/widget-helpers", () => ({ + getPrivateWidgetOptions, +})); + +vi.mock("utils/proxy/http", () => ({ + cachedRequest, +})); + +import handler from "./openweathermap"; + +describe("pages/api/widgets/openweathermap", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when no API key and no provider are supplied", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + + const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", index: "0" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Missing API key or provider" }); + }); + + it("returns 400 when provider doesn't match endpoint and no per-widget key exists", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + + const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", provider: "weatherapi" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Invalid provider for endpoint" }); + }); + + it("uses key from widget options when present", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" }); + cachedRequest.mockResolvedValueOnce({ ok: true }); + + const req = { + query: { latitude: "1", longitude: "2", units: "metric", lang: "en", cache: "1", index: "2" }, + }; + const res = createMockRes(); + + await handler(req, res); + + expect(getPrivateWidgetOptions).toHaveBeenCalledWith("openweathermap", "2"); + expect(cachedRequest).toHaveBeenCalledWith( + "https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-widget&units=metric&lang=en", + "1", + ); + expect(res.body).toEqual({ ok: true }); + }); + + it("falls back to settings provider key when provider=openweathermap", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + getSettings.mockReturnValueOnce({ providers: { openweathermap: "from-settings" } }); + cachedRequest.mockResolvedValueOnce({ ok: true }); + + const req = { + query: { + latitude: "1", + longitude: "2", + units: "imperial", + lang: "en", + provider: "openweathermap", + cache: 2, + index: "0", + }, + }; + const res = createMockRes(); + + await handler(req, res); + + expect(cachedRequest).toHaveBeenCalledWith( + "https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-settings&units=imperial&lang=en", + 2, + ); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/src/pages/api/widgets/resources.js b/src/pages/api/widgets/resources.js index 21819206c..19db010f5 100644 --- a/src/pages/api/widgets/resources.js +++ b/src/pages/api/widgets/resources.js @@ -1,9 +1,9 @@ +import si from "systeminformation"; + import createLogger from "utils/logger"; const logger = createLogger("resources"); -const si = require("systeminformation"); - export default async function handler(req, res) { const { type, target, interfaceName = "default" } = req.query; diff --git a/src/pages/api/widgets/resources.test.js b/src/pages/api/widgets/resources.test.js new file mode 100644 index 000000000..5fc96f44c --- /dev/null +++ b/src/pages/api/widgets/resources.test.js @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { si, logger } = vi.hoisted(() => ({ + si: { + currentLoad: vi.fn(), + fsSize: vi.fn(), + mem: vi.fn(), + cpuTemperature: vi.fn(), + time: vi.fn(), + networkStats: vi.fn(), + networkInterfaceDefault: vi.fn(), + }, + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("systeminformation", () => ({ default: si })); + +import handler from "./resources"; + +describe("pages/api/widgets/resources", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns CPU load data", async () => { + si.currentLoad.mockResolvedValueOnce({ currentLoad: 12.34, avgLoad: 1.23 }); + + const req = { query: { type: "cpu" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.cpu).toEqual({ usage: 12.34, load: 1.23 }); + }); + + it("returns 404 when requested disk target does not exist", async () => { + si.fsSize.mockResolvedValueOnce([{ mount: "/" }]); + + const req = { query: { type: "disk", target: "/missing" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: "Resource not available." }); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("returns disk info for the requested mount", async () => { + si.fsSize.mockResolvedValueOnce([{ mount: "/data", size: 1 }]); + + const req = { query: { type: "disk", target: "/data" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.drive).toEqual({ mount: "/data", size: 1 }); + }); + + it("returns memory, cpu temp and uptime", async () => { + si.mem.mockResolvedValueOnce({ total: 10 }); + si.cpuTemperature.mockResolvedValueOnce({ main: 50 }); + si.time.mockResolvedValueOnce({ uptime: 123 }); + + const resMem = createMockRes(); + await handler({ query: { type: "memory" } }, resMem); + expect(resMem.statusCode).toBe(200); + expect(resMem.body.memory).toEqual({ total: 10 }); + + const resTemp = createMockRes(); + await handler({ query: { type: "cputemp" } }, resTemp); + expect(resTemp.statusCode).toBe(200); + expect(resTemp.body.cputemp).toEqual({ main: 50 }); + + const resUptime = createMockRes(); + await handler({ query: { type: "uptime" } }, resUptime); + expect(resUptime.statusCode).toBe(200); + expect(resUptime.body.uptime).toBe(123); + }); + + it("returns 404 when requested network interface does not exist", async () => { + si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]); + + const req = { query: { type: "network", interfaceName: "missing" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: "Interface not found" }); + }); + + it("returns default interface network stats", async () => { + si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]); + si.networkInterfaceDefault.mockResolvedValueOnce("en0"); + + const req = { query: { type: "network" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.interface).toBe("en0"); + expect(res.body.network).toEqual({ iface: "en0", rx_bytes: 1 }); + }); + + it("returns 400 for an invalid type", async () => { + const req = { query: { type: "nope" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "invalid type" }); + }); +}); diff --git a/src/pages/api/widgets/stocks.test.js b/src/pages/api/widgets/stocks.test.js new file mode 100644 index 000000000..370d01cd3 --- /dev/null +++ b/src/pages/api/widgets/stocks.test.js @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getSettings, cachedRequest, logger } = vi.hoisted(() => ({ + getSettings: vi.fn(), + cachedRequest: vi.fn(), + logger: { debug: vi.fn() }, +})); + +vi.mock("utils/config/config", () => ({ + getSettings, +})); + +vi.mock("utils/proxy/http", () => ({ + cachedRequest, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import handler from "./stocks"; + +describe("pages/api/widgets/stocks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("validates watchlist and provider", async () => { + const res1 = createMockRes(); + await handler({ query: {} }, res1); + expect(res1.statusCode).toBe(400); + + const res2 = createMockRes(); + await handler({ query: { watchlist: "null", provider: "finnhub" } }, res2); + expect(res2.statusCode).toBe(400); + + const res3 = createMockRes(); + await handler({ query: { watchlist: "AAPL,AAPL", provider: "finnhub" } }, res3); + expect(res3.statusCode).toBe(400); + expect(res3.body.error).toContain("duplicates"); + + const res4 = createMockRes(); + await handler({ query: { watchlist: "AAPL", provider: "nope" } }, res4); + expect(res4.statusCode).toBe(400); + expect(res4.body.error).toContain("Invalid provider"); + }); + + it("returns 400 when API key isn't configured for provider", async () => { + getSettings.mockReturnValueOnce({ providers: {} }); + + const req = { query: { watchlist: "AAPL", provider: "finnhub" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("API Key"); + }); + + it("returns a normalized stocks response and rounds values", async () => { + getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } }); + + cachedRequest + .mockResolvedValueOnce({ c: 10.123, dp: -1.234 }) // AAPL + .mockResolvedValueOnce({ c: null, dp: null }); // MSFT + + const req = { query: { watchlist: "AAPL,MSFT", provider: "finnhub", cache: "1" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(cachedRequest).toHaveBeenCalledWith("https://finnhub.io/api/v1/quote?symbol=AAPL&token=k", "1"); + expect(res.body).toEqual({ + stocks: [ + { ticker: "AAPL", currentPrice: "10.12", percentChange: -1.23 }, + { ticker: "MSFT", currentPrice: null, percentChange: null }, + ], + }); + }); +}); diff --git a/src/pages/api/widgets/weather.test.js b/src/pages/api/widgets/weather.test.js new file mode 100644 index 000000000..b88e4f257 --- /dev/null +++ b/src/pages/api/widgets/weather.test.js @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({ + getSettings: vi.fn(), + getPrivateWidgetOptions: vi.fn(), + cachedRequest: vi.fn(), +})); + +vi.mock("utils/config/config", () => ({ + getSettings, +})); + +vi.mock("utils/config/widget-helpers", () => ({ + getPrivateWidgetOptions, +})); + +vi.mock("utils/proxy/http", () => ({ + cachedRequest, +})); + +import handler from "./weather"; + +describe("pages/api/widgets/weatherapi", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when no API key and no provider are supplied", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + + const req = { query: { latitude: "1", longitude: "2", lang: "en", index: "0" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Missing API key or provider" }); + }); + + it("uses key from widget options when present", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" }); + cachedRequest.mockResolvedValueOnce({ ok: true }); + + const req = { query: { latitude: "1", longitude: "2", lang: "en", cache: 1, index: "0" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(cachedRequest).toHaveBeenCalledWith( + "http://api.weatherapi.com/v1/current.json?q=1,2&key=from-widget&lang=en", + 1, + ); + expect(res.body).toEqual({ ok: true }); + }); + + it("falls back to settings provider key when provider=weatherapi", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + getSettings.mockReturnValueOnce({ providers: { weatherapi: "from-settings" } }); + cachedRequest.mockResolvedValueOnce({ ok: true }); + + const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi", cache: "2" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(cachedRequest).toHaveBeenCalledWith( + "http://api.weatherapi.com/v1/current.json?q=1,2&key=from-settings&lang=en", + "2", + ); + }); +});