diff --git a/src/components/widgets/weather/weather.test.jsx b/src/components/widgets/weather/weather.test.jsx
new file mode 100644
index 000000000..24a86e42d
--- /dev/null
+++ b/src/components/widgets/weather/weather.test.jsx
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+
+import { screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
+vi.mock("swr", () => ({ default: useSWR }));
+
+import WeatherApi from "./weather";
+
+describe("components/widgets/weather", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders a location prompt when no coordinates are available", () => {
+ renderWithProviders(, { settings: { target: "_self" } });
+
+ expect(screen.getByText("weather.current")).toBeInTheDocument();
+ expect(screen.getByText("weather.allow")).toBeInTheDocument();
+ });
+
+ it("renders temperature and condition when coordinates are provided", async () => {
+ useSWR.mockReturnValue({
+ data: {
+ current: {
+ temp_c: 21.5,
+ temp_f: 70.7,
+ is_day: 1,
+ condition: { code: 1000, text: "Sunny" },
+ },
+ },
+ error: undefined,
+ });
+
+ renderWithProviders(
+ ,
+ { settings: { target: "_self" } },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Home, 21.5")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Sunny")).toBeInTheDocument();
+ });
+});
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",
+ );
+ });
+});