mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-04 09:11:21 -07:00
Chore: homepage tests (#6278)
This commit is contained in:
63
src/widgets/adguard/component.test.jsx
Normal file
63
src/widgets/adguard/component.test.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/adguard/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "adguard" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("adguard.queries")).toBeInTheDocument();
|
||||
expect(screen.getByText("adguard.blocked")).toBeInTheDocument();
|
||||
expect(screen.getByText("adguard.filtered")).toBeInTheDocument();
|
||||
expect(screen.getByText("adguard.latency")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "adguard" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders computed filtered and latency values", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
num_dns_queries: 100,
|
||||
num_blocked_filtering: 20,
|
||||
num_replaced_safebrowsing: 1,
|
||||
num_replaced_safesearch: 2,
|
||||
num_replaced_parental: 3,
|
||||
avg_processing_time: 0.01,
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "adguard" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
expect(screen.getByText("6")).toBeInTheDocument(); // filtered sum
|
||||
expect(screen.getByText("10")).toBeInTheDocument(); // 0.01s -> 10ms
|
||||
});
|
||||
});
|
||||
12
src/widgets/adguard/widget.test.js
Normal file
12
src/widgets/adguard/widget.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("adguard widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.mappings?.stats?.endpoint).toBe("stats");
|
||||
});
|
||||
});
|
||||
48
src/widgets/apcups/component.test.jsx
Normal file
48
src/widgets/apcups/component.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/apcups/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "apcups" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("apcups.status")).toBeInTheDocument();
|
||||
expect(screen.getByText("apcups.load")).toBeInTheDocument();
|
||||
expect(screen.getByText("apcups.bcharge")).toBeInTheDocument();
|
||||
expect(screen.getByText("apcups.timeleft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { status: "ONLINE", load: "12", bcharge: "99", timeleft: "30" },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "apcups" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("ONLINE")).toBeInTheDocument();
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
expect(screen.getByText("99")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
88
src/widgets/apcups/proxy.test.js
Normal file
88
src/widgets/apcups/proxy.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
function encodeLine(line) {
|
||||
const buf = Buffer.alloc(2 + line.length);
|
||||
buf.writeUInt16BE(line.length, 0);
|
||||
buf.write(line, 2, "ascii");
|
||||
return buf;
|
||||
}
|
||||
|
||||
const { getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("node:net", () => {
|
||||
class FakeSocket {
|
||||
constructor() {
|
||||
this._handlers = new Map();
|
||||
}
|
||||
setTimeout() {}
|
||||
connect() {
|
||||
queueMicrotask(() => this._emit("connect"));
|
||||
}
|
||||
on(event, cb) {
|
||||
const set = this._handlers.get(event) ?? new Set();
|
||||
set.add(cb);
|
||||
this._handlers.set(event, set);
|
||||
}
|
||||
write() {
|
||||
const response = Buffer.concat([
|
||||
encodeLine("STATUS : ONLINE"),
|
||||
encodeLine("LOADPCT : 10.0"),
|
||||
encodeLine("BCHARGE : 99.0"),
|
||||
encodeLine("TIMELEFT : 12.3"),
|
||||
encodeLine("END APC"),
|
||||
Buffer.from([0x00, 0x00]),
|
||||
]);
|
||||
queueMicrotask(() => this._emit("data", response));
|
||||
}
|
||||
end() {}
|
||||
destroy() {}
|
||||
_emit(event, payload) {
|
||||
const set = this._handlers.get(event);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => cb(payload));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
Socket: FakeSocket,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import apcupsProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/apcups/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parses the APCUPSD status response into JSON", async () => {
|
||||
getServiceWidget.mockResolvedValue({ url: "http://127.0.0.1:3551" });
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await apcupsProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: "ONLINE",
|
||||
load: "10.0",
|
||||
bcharge: "99.0",
|
||||
timeleft: "12.3",
|
||||
});
|
||||
});
|
||||
});
|
||||
13
src/widgets/apcups/widget.test.js
Normal file
13
src/widgets/apcups/widget.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("apcups widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
// apcups talks TCP directly, so it does not use an `{url}/...` API template.
|
||||
expect(widget.api).toBeUndefined();
|
||||
});
|
||||
});
|
||||
123
src/widgets/arcane/component.test.jsx
Normal file
123
src/widgets/arcane/component.test.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const blocks = Array.from(container.querySelectorAll(".service-block"));
|
||||
const block = blocks.find((b) => b.textContent?.includes(label));
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/arcane/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows an environment required error when env is missing", () => {
|
||||
renderWithProviders(<Component service={{ widget: { type: "arcane" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(useWidgetAPI).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading data", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
// defaults to the first four fields when none are provided
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("docker.running")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.stopped")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.total")).toBeInTheDocument();
|
||||
expect(screen.getByText("arcane.image_updates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates custom fields to the max allowed", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const service = {
|
||||
widget: { type: "arcane", env: "prod", fields: ["running", "stopped", "total", "images", "images_unused"] },
|
||||
};
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
// sliced to first four entries
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("docker.running")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.stopped")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.total")).toBeInTheDocument();
|
||||
expect(screen.getByText("arcane.images")).toBeInTheDocument();
|
||||
expect(screen.queryByText("arcane.images_unused")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error UI when any widget call fails", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "containers") {
|
||||
return { data: undefined, error: { message: "boom" } };
|
||||
}
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(0);
|
||||
expect(screen.getByText("boom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values when API data is available", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "containers") {
|
||||
return { data: { runningContainers: 3, totalContainers: 5, stoppedContainers: 2 }, error: undefined };
|
||||
}
|
||||
if (endpoint === "images") {
|
||||
return { data: { totalImages: 7, imagesInuse: 4, imagesUnused: 3 }, error: undefined };
|
||||
}
|
||||
if (endpoint === "updates") {
|
||||
return { data: { imagesWithUpdates: 2 }, error: undefined };
|
||||
}
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "docker.running", 3);
|
||||
expectBlockValue(container, "dockhand.stopped", 2);
|
||||
expectBlockValue(container, "dockhand.total", 5);
|
||||
expectBlockValue(container, "arcane.image_updates", 2);
|
||||
});
|
||||
|
||||
it("falls back to zero when counts are missing", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: {}, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "docker.running", 0);
|
||||
expectBlockValue(container, "dockhand.stopped", 0);
|
||||
expectBlockValue(container, "dockhand.total", 0);
|
||||
expectBlockValue(container, "arcane.image_updates", 0);
|
||||
});
|
||||
});
|
||||
11
src/widgets/arcane/widget.test.js
Normal file
11
src/widgets/arcane/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("arcane widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
65
src/widgets/argocd/component.test.jsx
Normal file
65
src/widgets/argocd/component.test.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const blocks = Array.from(container.querySelectorAll(".service-block"));
|
||||
const block = blocks.find((b) => b.textContent?.includes(label));
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/argocd/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults and truncates widget.fields to 4 and renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = { widget: { type: "argocd", fields: ["apps", "synced", "outOfSync", "healthy", "extra"] } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual(["apps", "synced", "outOfSync", "healthy"]);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("argocd.apps")).toBeInTheDocument();
|
||||
expect(screen.getByText("argocd.synced")).toBeInTheDocument();
|
||||
expect(screen.getByText("argocd.outOfSync")).toBeInTheDocument();
|
||||
expect(screen.getByText("argocd.healthy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders counts when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
{ status: { sync: { status: "Synced" }, health: { status: "Healthy" } } },
|
||||
{ status: { sync: { status: "OutOfSync" }, health: { status: "Degraded" } } },
|
||||
{ status: { sync: { status: "Synced" }, health: { status: "Healthy" } } },
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const service = { widget: { type: "argocd" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
// Default widget fields: apps/synced/outOfSync/healthy => all 4 should be visible.
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
|
||||
expectBlockValue(container, "argocd.apps", 3);
|
||||
expectBlockValue(container, "argocd.synced", 2);
|
||||
expectBlockValue(container, "argocd.outOfSync", 1);
|
||||
expectBlockValue(container, "argocd.healthy", 2);
|
||||
});
|
||||
});
|
||||
11
src/widgets/argocd/widget.test.js
Normal file
11
src/widgets/argocd/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("argocd widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
51
src/widgets/atsumeru/component.test.jsx
Normal file
51
src/widgets/atsumeru/component.test.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/atsumeru/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "atsumeru" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("atsumeru.series")).toBeInTheDocument();
|
||||
expect(screen.getByText("atsumeru.archives")).toBeInTheDocument();
|
||||
expect(screen.getByText("atsumeru.chapters")).toBeInTheDocument();
|
||||
expect(screen.getByText("atsumeru.categories")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { stats: { total_series: 1, total_archives: 2, total_chapters: 3, total_categories: 4 } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "atsumeru" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/atsumeru/widget.test.js
Normal file
11
src/widgets/atsumeru/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("atsumeru widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
62
src/widgets/audiobookshelf/component.test.jsx
Normal file
62
src/widgets/audiobookshelf/component.test.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const blocks = Array.from(container.querySelectorAll(".service-block"));
|
||||
const block = blocks.find((b) => b.textContent?.includes(label));
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/audiobookshelf/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "audiobookshelf" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("audiobookshelf.podcasts")).toBeInTheDocument();
|
||||
expect(screen.getByText("audiobookshelf.podcastsDuration")).toBeInTheDocument();
|
||||
expect(screen.getByText("audiobookshelf.books")).toBeInTheDocument();
|
||||
expect(screen.getByText("audiobookshelf.booksDuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("aggregates totals across libraries by mediaType", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{ mediaType: "podcast", stats: { totalItems: "2", totalDuration: "100" } },
|
||||
{ mediaType: "podcast", stats: { totalItems: "1", totalDuration: "200" } },
|
||||
{ mediaType: "book", stats: { totalItems: "4", totalDuration: "300" } },
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "audiobookshelf" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "audiobookshelf.podcasts", 3);
|
||||
expectBlockValue(container, "audiobookshelf.podcastsDuration", 300);
|
||||
expectBlockValue(container, "audiobookshelf.books", 4);
|
||||
expectBlockValue(container, "audiobookshelf.booksDuration", 300);
|
||||
});
|
||||
});
|
||||
67
src/widgets/audiobookshelf/proxy.test.js
Normal file
67
src/widgets/audiobookshelf/proxy.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
audiobookshelf: {
|
||||
api: "{url}/api/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import audiobookshelfProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/audiobookshelf/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retrieves libraries and per-library stats", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "audiobookshelf", url: "http://abs", key: "k" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
libraries: [
|
||||
{ id: "l1", name: "A" },
|
||||
{ id: "l2", name: "B" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ total: 1 }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ total: 2 }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "libraries", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await audiobookshelfProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer k");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{ id: "l1", name: "A", stats: { total: 1 } },
|
||||
{ id: "l2", name: "B", stats: { total: 2 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
12
src/widgets/audiobookshelf/widget.test.js
Normal file
12
src/widgets/audiobookshelf/widget.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("audiobookshelf widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.mappings?.libraries?.endpoint).toBe("libraries");
|
||||
});
|
||||
});
|
||||
108
src/widgets/authentik/component.test.jsx
Normal file
108
src/widgets/authentik/component.test.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const blocks = Array.from(container.querySelectorAll(".service-block"));
|
||||
const block = blocks.find((b) => b.textContent?.includes(label));
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/authentik/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "authentik", version: 2 } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expect(screen.getByText("authentik.users")).toBeInTheDocument();
|
||||
expect(screen.getByText("authentik.loginsLast24H")).toBeInTheDocument();
|
||||
expect(screen.getByText("authentik.failedLoginsLast24H")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes v2 login/failed counts from action data", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "users") return { data: { pagination: { count: 10 } }, error: undefined };
|
||||
if (endpoint === "loginv2")
|
||||
return {
|
||||
data: [
|
||||
{ action: "login", count: 2 },
|
||||
{ action: "logout", count: 9 },
|
||||
],
|
||||
error: undefined,
|
||||
};
|
||||
if (endpoint === "login_failedv2") return { data: [{ count: 3 }, { count: null }], error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "authentik", version: 2 } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expectBlockValue(container, "authentik.users", 10);
|
||||
expectBlockValue(container, "authentik.loginsLast24H", 2);
|
||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 3);
|
||||
});
|
||||
|
||||
it("computes v1 login/failed counts for entries within the last 24h window", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
|
||||
|
||||
const now = Date.now();
|
||||
const oneHourAgo = now - 60 * 60 * 1000;
|
||||
const twentyFiveHoursAgo = now - 25 * 60 * 60 * 1000;
|
||||
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "users") return { data: { pagination: { count: 5 } }, error: undefined };
|
||||
if (endpoint === "login")
|
||||
return {
|
||||
data: [
|
||||
{ x_cord: oneHourAgo, y_cord: 2 },
|
||||
{ x_cord: twentyFiveHoursAgo, y_cord: 100 },
|
||||
],
|
||||
error: undefined,
|
||||
};
|
||||
if (endpoint === "login_failed")
|
||||
return {
|
||||
data: [
|
||||
{ x_cord: oneHourAgo, y_cord: 1 },
|
||||
{ x_cord: twentyFiveHoursAgo, y_cord: 50 },
|
||||
],
|
||||
error: undefined,
|
||||
};
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "authentik", version: 1 } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expectBlockValue(container, "authentik.users", 5);
|
||||
expectBlockValue(container, "authentik.loginsLast24H", 2);
|
||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
|
||||
});
|
||||
});
|
||||
13
src/widgets/authentik/widget.test.js
Normal file
13
src/widgets/authentik/widget.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("authentik widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.api).toContain("/api/v3/");
|
||||
expect(widget.mappings?.users?.endpoint).toContain("core/users");
|
||||
});
|
||||
});
|
||||
60
src/widgets/autobrr/component.test.jsx
Normal file
60
src/widgets/autobrr/component.test.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const blocks = Array.from(container.querySelectorAll(".service-block"));
|
||||
const block = blocks.find((b) => b.textContent?.includes(label));
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/autobrr/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "autobrr" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("autobrr.approvedPushes")).toBeInTheDocument();
|
||||
expect(screen.getByText("autobrr.rejectedPushes")).toBeInTheDocument();
|
||||
expect(screen.getByText("autobrr.filters")).toBeInTheDocument();
|
||||
expect(screen.getByText("autobrr.indexers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values when loaded", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "stats") return { data: { push_approved_count: 1, push_rejected_count: 2 }, error: undefined };
|
||||
if (endpoint === "filters") return { data: [{}, {}], error: undefined };
|
||||
if (endpoint === "indexers") return { data: [{}], error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "autobrr" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "autobrr.approvedPushes", 1);
|
||||
expectBlockValue(container, "autobrr.rejectedPushes", 2);
|
||||
expectBlockValue(container, "autobrr.filters", 2);
|
||||
expectBlockValue(container, "autobrr.indexers", 1);
|
||||
});
|
||||
});
|
||||
11
src/widgets/autobrr/widget.test.js
Normal file
11
src/widgets/autobrr/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("autobrr widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
101
src/widgets/azuredevops/component.test.jsx
Normal file
101
src/widgets/azuredevops/component.test.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const blocks = Array.from(container.querySelectorAll(".service-block"));
|
||||
const block = blocks.find((b) => b.textContent?.includes(label));
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/azuredevops/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "azuredevops" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("azuredevops.result")).toBeInTheDocument();
|
||||
expect(screen.getByText("azuredevops.totalPrs")).toBeInTheDocument();
|
||||
expect(screen.getByText("azuredevops.myPrs")).toBeInTheDocument();
|
||||
expect(screen.getByText("azuredevops.approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pipeline result without PR blocks when includePR is false", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === null) return { data: undefined, error: undefined };
|
||||
if (endpoint === "pipeline")
|
||||
return { data: { value: [{ result: "succeeded", status: "completed" }] }, error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "azuredevops" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(1);
|
||||
expectBlockValue(container, "azuredevops.result", "azuredevops.succeeded");
|
||||
});
|
||||
|
||||
it("renders pipeline status and PR aggregates when includePR is true", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "pipeline") return { data: { value: [{ status: "inProgress" }] }, error: undefined };
|
||||
if (endpoint === "pr")
|
||||
return {
|
||||
data: {
|
||||
count: 3,
|
||||
value: [
|
||||
{ createdBy: { uniqueName: "me@example.com" }, reviewers: [{ vote: 5 }] },
|
||||
{ createdBy: { uniqueName: "me@example.com" }, reviewers: [{ vote: 0 }] },
|
||||
{ createdBy: { uniqueName: "other@example.com" }, reviewers: [{ vote: 10 }] },
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
};
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const service = {
|
||||
widget: { type: "azuredevops", userEmail: "me@example.com", repositoryId: "repo1" },
|
||||
};
|
||||
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "azuredevops.status", "azuredevops.inProgress");
|
||||
expectBlockValue(container, "azuredevops.totalPrs", 3);
|
||||
expectBlockValue(container, "azuredevops.myPrs", 2);
|
||||
expectBlockValue(container, "azuredevops.approved", 1);
|
||||
});
|
||||
|
||||
it("renders PR error message when PR call returns an errorCode", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "pipeline") return { data: { value: [{ result: "succeeded" }] }, error: undefined };
|
||||
if (endpoint === "pr") return { data: { errorCode: 1, message: "Bad PR" }, error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const service = { widget: { type: "azuredevops", userEmail: "me@example.com", repositoryId: "repo1" } };
|
||||
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("Bad PR")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/azuredevops/widget.test.js
Normal file
11
src/widgets/azuredevops/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("azuredevops widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
81
src/widgets/backrest/component.test.jsx
Normal file
81
src/widgets/backrest/component.test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/backrest/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults widget.fields and filters placeholders down to 4 blocks while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = { widget: { type: "backrest" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual([
|
||||
"num_success_latest",
|
||||
"num_failure_latest",
|
||||
"num_failure_30",
|
||||
"bytes_added_30",
|
||||
]);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
|
||||
expect(screen.getByText("backrest.num_success_latest")).toBeInTheDocument();
|
||||
expect(screen.getByText("backrest.num_failure_latest")).toBeInTheDocument();
|
||||
expect(screen.getByText("backrest.num_failure_30")).toBeInTheDocument();
|
||||
expect(screen.getByText("backrest.bytes_added_30")).toBeInTheDocument();
|
||||
expect(screen.queryByText("backrest.num_plans")).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates widget.fields to 4", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = {
|
||||
widget: { type: "backrest", fields: ["a", "b", "c", "d", "e"] },
|
||||
};
|
||||
|
||||
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("renders values and respects field filtering", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
numPlans: 10,
|
||||
numSuccessLatest: 1,
|
||||
numFailureLatest: 2,
|
||||
numSuccess30Days: 3,
|
||||
numFailure30Days: 4,
|
||||
bytesAdded30Days: 500,
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "backrest" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
// Default fields exclude num_plans and num_success_30
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.queryByText("backrest.num_plans")).toBeNull();
|
||||
expect(screen.queryByText("backrest.num_success_30")).toBeNull();
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("500")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,14 @@ import widgets from "widgets/widgets";
|
||||
const proxyName = "backrestProxyHandler";
|
||||
const logger = createLogger(proxyName);
|
||||
|
||||
function sumField(plans, field) {
|
||||
export function sumField(plans, field) {
|
||||
return plans.reduce((sum, plan) => {
|
||||
const num = Number(plan[field]);
|
||||
return sum + (Number.isNaN(num) ? 0 : num);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function buildResponse(plans) {
|
||||
export function buildResponse(plans) {
|
||||
const numSuccess30Days = sumField(plans, "backupsSuccessLast30days");
|
||||
const numFailure30Days = sumField(plans, "backupsFailed30days");
|
||||
const bytesAdded30Days = sumField(plans, "bytesAddedLast30days");
|
||||
|
||||
212
src/widgets/backrest/proxy.test.js
Normal file
212
src/widgets/backrest/proxy.test.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
backrest: {
|
||||
api: "{url}/v1.Backrest/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import backrestProxyHandler, { buildResponse } from "./proxy";
|
||||
|
||||
describe("backrest proxy buildResponse", () => {
|
||||
it("aggregates plan metrics and latest status counts", () => {
|
||||
const plans = [
|
||||
{
|
||||
backupsSuccessLast30days: 3,
|
||||
backupsFailed30days: 1,
|
||||
bytesAddedLast30days: 1000,
|
||||
recentBackups: { status: ["STATUS_SUCCESS"] },
|
||||
},
|
||||
{
|
||||
backupsSuccessLast30days: 2,
|
||||
backupsFailed30days: 0,
|
||||
bytesAddedLast30days: 500,
|
||||
recentBackups: { status: ["STATUS_ERROR"] },
|
||||
},
|
||||
{
|
||||
backupsSuccessLast30days: "not-a-number",
|
||||
backupsFailed30days: 4,
|
||||
bytesAddedLast30days: 250,
|
||||
recentBackups: { status: [] },
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildResponse(plans)).toEqual({
|
||||
numPlans: 3,
|
||||
numSuccess30Days: 5,
|
||||
numFailure30Days: 5,
|
||||
numSuccessLatest: 1,
|
||||
numFailureLatest: 1,
|
||||
bytesAdded30Days: 1750,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("widgets/backrest/proxy handler", () => {
|
||||
beforeEach(() => {
|
||||
httpProxy.mockReset();
|
||||
getServiceWidget.mockReset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when the query is missing group or service", async () => {
|
||||
const req = { query: { service: "svc" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await backrestProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
expect(getServiceWidget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when the widget cannot be resolved", async () => {
|
||||
getServiceWidget.mockResolvedValue(null);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await backrestProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("calls the Backrest API with basic auth and returns the aggregated summary", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "backrest",
|
||||
url: "http://backrest/",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
planSummaries: [
|
||||
{
|
||||
backupsSuccessLast30days: 1,
|
||||
backupsFailed30days: 0,
|
||||
bytesAddedLast30days: 10,
|
||||
recentBackups: { status: [] },
|
||||
},
|
||||
{
|
||||
backupsSuccessLast30days: 0,
|
||||
backupsFailed30days: 1,
|
||||
bytesAddedLast30days: 5,
|
||||
recentBackups: { status: ["STATUS_ERROR"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await backrestProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://backrest/v1.Backrest/GetSummaryDashboard");
|
||||
expect(httpProxy.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: expect.objectContaining({
|
||||
"content-type": "application/json",
|
||||
Authorization: `Basic ${Buffer.from("u:p").toString("base64")}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
numPlans: 2,
|
||||
numSuccess30Days: 1,
|
||||
numFailure30Days: 1,
|
||||
numSuccessLatest: 0,
|
||||
numFailureLatest: 1,
|
||||
bytesAdded30Days: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 500 when Backrest responds non-200", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "backrest", url: "http://backrest" });
|
||||
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await backrestProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Error getting data from Backrest",
|
||||
data: expect.any(Buffer),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 500 when the plans payload is invalid", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "backrest", url: "http://backrest" });
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ planSummaries: {} }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await backrestProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Invalid plans data",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 500 when httpProxy throws", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "backrest", url: "http://backrest" });
|
||||
httpProxy.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await backrestProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual(expect.objectContaining({ error: "Backrest API Error", message: "boom" }));
|
||||
});
|
||||
});
|
||||
13
src/widgets/backrest/widget.test.js
Normal file
13
src/widgets/backrest/widget.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("backrest widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.api).toContain("/v1.Backrest/");
|
||||
expect(widget.mappings?.summary?.endpoint).toBe("GetSummaryDashboard");
|
||||
});
|
||||
});
|
||||
56
src/widgets/bazarr/component.test.jsx
Normal file
56
src/widgets/bazarr/component.test.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/bazarr/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "bazarr" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("bazarr.missingEpisodes")).toBeInTheDocument();
|
||||
expect(screen.getByText("bazarr.missingMovies")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error UI when either endpoint errors", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: undefined, error: { message: "episodes bad" } })
|
||||
.mockReturnValueOnce({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "bazarr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders counts when loaded", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: { total: 11 }, error: undefined })
|
||||
.mockReturnValueOnce({ data: { total: 22 }, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "bazarr" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("11")).toBeInTheDocument();
|
||||
expect(screen.getByText("22")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
src/widgets/bazarr/widget.test.js
Normal file
16
src/widgets/bazarr/widget.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("bazarr widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.api).toContain("apikey={key}");
|
||||
|
||||
const moviesMapping = widget.mappings?.movies;
|
||||
expect(moviesMapping?.endpoint).toBe("movies");
|
||||
expect(moviesMapping?.map?.('{"total":123}')).toEqual({ total: 123 });
|
||||
});
|
||||
});
|
||||
97
src/widgets/beszel/component.test.jsx
Normal file
97
src/widgets/beszel/component.test.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/beszel/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading (systems view)", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = { widget: { type: "beszel" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual(["systems", "up"]);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("beszel.systems")).toBeInTheDocument();
|
||||
expect(screen.getByText("beszel.up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders system totals when loaded (systems view)", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
totalItems: 3,
|
||||
items: [{ status: "up" }, { status: "down" }, { status: "up" }],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "beszel" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "beszel.systems", 3);
|
||||
expectBlockValue(container, "beszel.up", "2 / 3");
|
||||
});
|
||||
|
||||
it("renders selected system details and filters to 4 default fields", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
totalItems: 1,
|
||||
items: [
|
||||
{
|
||||
id: "sys1",
|
||||
name: "MySystem",
|
||||
status: "up",
|
||||
updated: 123,
|
||||
info: { cpu: 10, mp: 20, dp: 30, b: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const service = { widget: { type: "beszel", systemId: "sys1" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual(["name", "status", "cpu", "memory"]);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
|
||||
expectBlockValue(container, "beszel.name", "MySystem");
|
||||
expectBlockValue(container, "beszel.status", "beszel.up");
|
||||
expectBlockValue(container, "beszel.cpu", 10);
|
||||
expectBlockValue(container, "beszel.memory", 20);
|
||||
expect(screen.queryByText("beszel.updated")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error when systemId is not found", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "beszel", systemId: "missing" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("System with id missing not found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
117
src/widgets/beszel/proxy.test.js
Normal file
117
src/widgets/beszel/proxy.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
beszel: {
|
||||
api: "{url}/{endpoint}",
|
||||
mappings: {
|
||||
authv1: { endpoint: "api/auth" },
|
||||
authv2: { endpoint: "api/auth/v2" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import beszelProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/beszel/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("logs in when token is missing and uses Bearer token for requests", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "beszel",
|
||||
url: "http://beszel",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "t1" }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [1] }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await beszelProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(2);
|
||||
expect(httpProxy.mock.calls[0][0]).toBe("http://beszel/api/auth");
|
||||
expect(httpProxy.mock.calls[1][0].toString()).toBe("http://beszel/items");
|
||||
expect(httpProxy.mock.calls[1][1]).toEqual({
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer t1",
|
||||
},
|
||||
});
|
||||
expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] })));
|
||||
});
|
||||
|
||||
it("retries after receiving an empty list by clearing cache and logging in again", async () => {
|
||||
cache.put("beszelProxyHandler__token.svc", "old");
|
||||
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "beszel",
|
||||
url: "http://beszel",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [] }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "new" }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [1] }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await beszelProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer old");
|
||||
expect(httpProxy.mock.calls[1][0]).toBe("http://beszel/api/auth");
|
||||
expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new");
|
||||
expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] })));
|
||||
});
|
||||
});
|
||||
11
src/widgets/beszel/widget.test.js
Normal file
11
src/widgets/beszel/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("beszel widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
50
src/widgets/booklore/component.test.jsx
Normal file
50
src/widgets/booklore/component.test.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/booklore/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "booklore" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("booklore.libraries")).toBeInTheDocument();
|
||||
expect(screen.getByText("booklore.books")).toBeInTheDocument();
|
||||
expect(screen.getByText("booklore.reading")).toBeInTheDocument();
|
||||
expect(screen.getByText("booklore.finished")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values with nullish fallback defaults", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { libraries: 1, books: 2, finished: 4 }, // reading missing -> 0
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "booklore" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
98
src/widgets/booklore/proxy.test.js
Normal file
98
src/widgets/booklore/proxy.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
booklore: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import bookloreProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/booklore/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("returns 400 when Booklore credentials are missing", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "booklore", url: "http://booklore" });
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await bookloreProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing Booklore credentials" });
|
||||
});
|
||||
|
||||
it("logs in and summarizes libraries and book statuses", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "booklore",
|
||||
url: "http://booklore",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
const books = [{ readStatus: "reading" }, { readStatus: "read" }, { readStatus: "READ" }, { readStatus: "other" }];
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ accessToken: "tok" }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([{ id: 1 }, { id: 2 }]))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify(books))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await bookloreProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
libraries: 2,
|
||||
books: 4,
|
||||
reading: 1,
|
||||
finished: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
11
src/widgets/booklore/widget.test.js
Normal file
11
src/widgets/booklore/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("booklore widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
64
src/widgets/caddy/component.test.jsx
Normal file
64
src/widgets/caddy/component.test.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/caddy/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "caddy" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expect(screen.getByText("caddy.upstreams")).toBeInTheDocument();
|
||||
expect(screen.getByText("caddy.requests")).toBeInTheDocument();
|
||||
expect(screen.getByText("caddy.requests_failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "caddy" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes upstream/request totals when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{ num_requests: 10, fails: 1 },
|
||||
{ num_requests: 5, fails: 2 },
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "caddy" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "caddy.upstreams", 2);
|
||||
expectBlockValue(container, "caddy.requests", 15);
|
||||
expectBlockValue(container, "caddy.requests_failed", 3);
|
||||
});
|
||||
});
|
||||
11
src/widgets/caddy/widget.test.js
Normal file
11
src/widgets/caddy/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("caddy widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
64
src/widgets/calendar/agenda.test.jsx
Normal file
64
src/widgets/calendar/agenda.test.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DateTime } from "luxon";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { EventStub, compareDateTimezoneStub } = vi.hoisted(() => ({
|
||||
EventStub: vi.fn(({ event, showDate, showTime }) => (
|
||||
<div data-testid="event" data-showdate={showDate ? "1" : "0"} data-showtime={showTime ? "1" : "0"}>
|
||||
{event.title}
|
||||
</div>
|
||||
)),
|
||||
compareDateTimezoneStub: vi.fn(
|
||||
(date, event) => date.startOf("day").toISODate() === event.date.startOf("day").toISODate(),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./event", () => ({
|
||||
default: EventStub,
|
||||
compareDateTimezone: compareDateTimezoneStub,
|
||||
}));
|
||||
|
||||
import Agenda from "./agenda";
|
||||
|
||||
describe("widgets/calendar/agenda", () => {
|
||||
it("renders an empty placeholder when showDate is not set", () => {
|
||||
const { container } = render(<Agenda service={{ widget: {} }} colorVariants={{}} events={{}} showDate={null} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders a no-events placeholder when there are no events in range", () => {
|
||||
render(<Agenda service={{ widget: {} }} colorVariants={{}} events={{}} showDate={DateTime.now()} />);
|
||||
expect(screen.getByText("calendar.noEventsToday")).toBeInTheDocument();
|
||||
expect(EventStub).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by previousDays, sorts, and enforces maxEvents", () => {
|
||||
const showDate = DateTime.local(2099, 1, 2).startOf("day");
|
||||
const service = { widget: { previousDays: 0, maxEvents: 2, showTime: true } };
|
||||
|
||||
const events = {
|
||||
old: { title: "Old", date: DateTime.local(2099, 1, 1, 0, 0), color: "gray" },
|
||||
a: { title: "A", date: DateTime.local(2099, 1, 2, 10, 0), color: "gray" },
|
||||
b: { title: "B", date: DateTime.local(2099, 1, 3, 10, 0), color: "gray" },
|
||||
c: { title: "C", date: DateTime.local(2099, 1, 4, 10, 0), color: "gray" },
|
||||
};
|
||||
|
||||
render(<Agenda service={service} colorVariants={{}} events={events} showDate={showDate} />);
|
||||
|
||||
// Old is filtered out, C is sliced out by maxEvents.
|
||||
expect(screen.queryByText("Old")).toBeNull();
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
expect(screen.getByText("B")).toBeInTheDocument();
|
||||
expect(screen.queryByText("C")).toBeNull();
|
||||
|
||||
const renderedEvents = screen.getAllByTestId("event");
|
||||
expect(renderedEvents).toHaveLength(2);
|
||||
|
||||
// showTime is only true for the selected day.
|
||||
const [first, second] = renderedEvents;
|
||||
expect(first).toHaveAttribute("data-showtime", "1");
|
||||
expect(second).toHaveAttribute("data-showtime", "0");
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,8 @@ export default function Component({ service }) {
|
||||
widget.integrations
|
||||
?.filter((integration) => integration?.type)
|
||||
.map((integration) => ({
|
||||
service: dynamic(() => import(`./integrations/${integration.type}`)),
|
||||
// Include the extension so Vite/Vitest can statically validate the import base.
|
||||
service: dynamic(() => import(`./integrations/${integration.type}.jsx`)),
|
||||
widget: { ...widget, ...integration },
|
||||
})) ?? [],
|
||||
[widget],
|
||||
|
||||
89
src/widgets/calendar/component.test.jsx
Normal file
89
src/widgets/calendar/component.test.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => (props) => (
|
||||
<div
|
||||
data-testid="calendar-integration"
|
||||
data-type={props.config.type}
|
||||
data-start={props.params.start}
|
||||
data-end={props.params.end}
|
||||
data-timezone={props.timezone || ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./monthly", () => ({
|
||||
default: ({ showDate }) => <div data-testid="calendar-monthly" data-show={showDate?.toISODate?.() || ""} />,
|
||||
}));
|
||||
|
||||
vi.mock("./agenda", () => ({
|
||||
default: ({ showDate }) => <div data-testid="calendar-agenda" data-show={showDate?.toISODate?.() || ""} />,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/calendar/component", () => {
|
||||
it("renders monthly view by default", async () => {
|
||||
renderWithProviders(<Component service={{ widget: { type: "calendar", integrations: [] } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("calendar-monthly")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("calendar-agenda")).toBeNull();
|
||||
|
||||
// showDate is set asynchronously in an effect; ensure it eventually resolves to a date string.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("calendar-monthly").getAttribute("data-show")).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders agenda view when configured", async () => {
|
||||
renderWithProviders(<Component service={{ widget: { type: "calendar", view: "agenda", integrations: [] } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("calendar-agenda")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("calendar-monthly")).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("calendar-agenda").getAttribute("data-show")).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("loads configured integrations and passes calculated params", async () => {
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: {
|
||||
type: "calendar",
|
||||
timezone: "UTC",
|
||||
integrations: [
|
||||
{
|
||||
type: "sonarr",
|
||||
name: "Sonarr",
|
||||
service_group: "Media",
|
||||
service_name: "Sonarr",
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
const integration = screen.getByTestId("calendar-integration");
|
||||
expect(integration.getAttribute("data-type")).toBe("sonarr");
|
||||
expect(integration.getAttribute("data-timezone")).toBe("UTC");
|
||||
|
||||
await waitFor(() => {
|
||||
// start/end should be yyyy-MM-dd after showDate is set.
|
||||
expect(integration.getAttribute("data-start")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(integration.getAttribute("data-end")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
src/widgets/calendar/event.test.jsx
Normal file
56
src/widgets/calendar/event.test.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DateTime } from "luxon";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import Event, { compareDateTimezone } from "./event";
|
||||
|
||||
describe("widgets/calendar/event", () => {
|
||||
it("renders an anchor when a url is provided and toggles additional text on hover", () => {
|
||||
const date = DateTime.fromISO("2099-01-01T13:00:00.000Z").setZone("utc");
|
||||
|
||||
render(
|
||||
<Event
|
||||
event={{
|
||||
title: "Primary",
|
||||
additional: "More info",
|
||||
date,
|
||||
color: "gray",
|
||||
url: "https://example.com",
|
||||
isCompleted: true,
|
||||
}}
|
||||
colorVariants={{ gray: "bg-gray-500" }}
|
||||
showDate
|
||||
showTime
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: /primary/i });
|
||||
expect(link).toHaveAttribute("href", "https://example.com");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
|
||||
// time is rendered when showTime=true
|
||||
expect(link.textContent).toContain("13:00");
|
||||
|
||||
// default shows title, hover shows `additional`
|
||||
expect(screen.getByText("Primary")).toBeInTheDocument();
|
||||
expect(screen.queryByText("More info")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(screen.getByText("More info")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(screen.getByText("Primary")).toBeInTheDocument();
|
||||
|
||||
// completed icon from react-icons renders an SVG
|
||||
expect(link.querySelector("svg")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("compareDateTimezone matches dates by day", () => {
|
||||
const day = DateTime.fromISO("2099-01-01T00:00:00.000Z").setZone("utc");
|
||||
expect(compareDateTimezone(day, { date: DateTime.fromISO("2099-01-01T23:59:00.000Z").setZone("utc") })).toBe(true);
|
||||
expect(compareDateTimezone(day, { date: DateTime.fromISO("2099-01-02T00:00:00.000Z").setZone("utc") })).toBe(false);
|
||||
});
|
||||
});
|
||||
64
src/widgets/calendar/integrations/ical.test.jsx
Normal file
64
src/widgets/calendar/integrations/ical.test.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Integration from "./ical";
|
||||
|
||||
describe("widgets/calendar/integrations/ical", () => {
|
||||
it("adds parsed events within the date range", async () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Test//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:uid1",
|
||||
"DTSTAMP:20990101T000000Z",
|
||||
"DTSTART:20990101T130000Z",
|
||||
"DTEND:20990101T140000Z",
|
||||
"SUMMARY:Test Event",
|
||||
"LOCATION:Office",
|
||||
"URL:https://example.com",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const setEvents = vi.fn();
|
||||
render(
|
||||
<Integration
|
||||
config={{ name: "Work", type: "ical", color: "blue", params: { showName: true } }}
|
||||
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
|
||||
setEvents={setEvents}
|
||||
hideErrors
|
||||
timezone="utc"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setEvents).toHaveBeenCalled());
|
||||
|
||||
const updater = setEvents.mock.calls[0][0];
|
||||
const next = updater({});
|
||||
const entries = Object.values(next);
|
||||
expect(entries).toHaveLength(1);
|
||||
|
||||
const [event] = entries;
|
||||
expect(event.title).toBe("Work: Test Event");
|
||||
expect(event.color).toBe("blue");
|
||||
expect(event.type).toBe("ical");
|
||||
expect(event.additional).toBe("Office");
|
||||
expect(event.url).toBe("https://example.com");
|
||||
expect(event.isCompleted).toBe(false);
|
||||
});
|
||||
});
|
||||
39
src/widgets/calendar/integrations/lidarr.test.jsx
Normal file
39
src/widgets/calendar/integrations/lidarr.test.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Integration from "./lidarr";
|
||||
|
||||
describe("widgets/calendar/integrations/lidarr", () => {
|
||||
it("adds release events", async () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{ artist: { artistName: "Artist" }, title: "Album", releaseDate: "2099-01-01T00:00:00.000Z", grabbed: true },
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const setEvents = vi.fn();
|
||||
render(
|
||||
<Integration
|
||||
config={{ type: "lidarr", color: "green" }}
|
||||
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
|
||||
setEvents={setEvents}
|
||||
hideErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setEvents).toHaveBeenCalled());
|
||||
|
||||
const next = setEvents.mock.calls[0][0]({});
|
||||
expect(Object.keys(next)).toEqual(["Artist - Album"]);
|
||||
expect(next["Artist - Album"].isCompleted).toBe(true);
|
||||
});
|
||||
});
|
||||
49
src/widgets/calendar/integrations/radarr.test.jsx
Normal file
49
src/widgets/calendar/integrations/radarr.test.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Integration from "./radarr";
|
||||
|
||||
describe("widgets/calendar/integrations/radarr", () => {
|
||||
it("adds cinema/physical/digital events", async () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
title: "Movie",
|
||||
titleSlug: "movie",
|
||||
hasFile: false,
|
||||
inCinemas: "2099-01-01T00:00:00.000Z",
|
||||
physicalRelease: "2099-01-02T00:00:00.000Z",
|
||||
digitalRelease: "2099-01-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const setEvents = vi.fn();
|
||||
render(
|
||||
<Integration
|
||||
config={{ type: "radarr", baseUrl: "https://radarr.example", color: "amber" }}
|
||||
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-10T00:00:00.000Z" }}
|
||||
setEvents={setEvents}
|
||||
hideErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setEvents).toHaveBeenCalled());
|
||||
|
||||
const next = setEvents.mock.calls[0][0]({});
|
||||
const keys = Object.keys(next);
|
||||
expect(keys.some((k) => k.includes("calendar.inCinemas"))).toBe(true);
|
||||
expect(keys.some((k) => k.includes("calendar.physicalRelease"))).toBe(true);
|
||||
expect(keys.some((k) => k.includes("calendar.digitalRelease"))).toBe(true);
|
||||
expect(Object.values(next)[0].url).toBe("https://radarr.example/movie/movie");
|
||||
});
|
||||
});
|
||||
48
src/widgets/calendar/integrations/readarr.test.jsx
Normal file
48
src/widgets/calendar/integrations/readarr.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Integration from "./readarr";
|
||||
|
||||
describe("widgets/calendar/integrations/readarr", () => {
|
||||
it("adds release events with author name", async () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
title: "Book",
|
||||
seriesTitle: "Series",
|
||||
releaseDate: "2099-01-01T00:00:00.000Z",
|
||||
grabbed: false,
|
||||
author: { authorName: "Author" },
|
||||
authorTitle: "Author Book",
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const setEvents = vi.fn();
|
||||
render(
|
||||
<Integration
|
||||
config={{ type: "readarr", color: "rose" }}
|
||||
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
|
||||
setEvents={setEvents}
|
||||
hideErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setEvents).toHaveBeenCalled());
|
||||
|
||||
const next = setEvents.mock.calls[0][0]({});
|
||||
const [key] = Object.keys(next);
|
||||
expect(key).toContain("Author");
|
||||
expect(key).toContain("Book");
|
||||
expect(key).toContain("(Series)");
|
||||
});
|
||||
});
|
||||
48
src/widgets/calendar/integrations/sonarr.test.jsx
Normal file
48
src/widgets/calendar/integrations/sonarr.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Integration from "./sonarr";
|
||||
|
||||
describe("widgets/calendar/integrations/sonarr", () => {
|
||||
it("adds episode events", async () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
series: { title: "Show", titleSlug: "show" },
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
airDateUtc: "2099-01-01T00:00:00.000Z",
|
||||
hasFile: true,
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const setEvents = vi.fn();
|
||||
render(
|
||||
<Integration
|
||||
config={{ type: "sonarr", baseUrl: "https://sonarr.example", color: "teal" }}
|
||||
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
|
||||
setEvents={setEvents}
|
||||
hideErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setEvents).toHaveBeenCalled());
|
||||
|
||||
const next = setEvents.mock.calls[0][0]({});
|
||||
const [entry] = Object.values(next);
|
||||
expect(entry.title).toBe("Show");
|
||||
expect(entry.additional).toBe("S1 E2");
|
||||
expect(entry.url).toBe("https://sonarr.example/series/show");
|
||||
expect(entry.isCompleted).toBe(true);
|
||||
});
|
||||
});
|
||||
71
src/widgets/calendar/monthly.test.jsx
Normal file
71
src/widgets/calendar/monthly.test.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DateTime } from "luxon";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { EventStub, compareDateTimezoneStub } = vi.hoisted(() => ({
|
||||
EventStub: vi.fn(({ event }) => <div data-testid="event">{event.title}</div>),
|
||||
compareDateTimezoneStub: vi.fn(
|
||||
(date, event) => date.startOf("day").toISODate() === event.date.startOf("day").toISODate(),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./event", () => ({
|
||||
default: EventStub,
|
||||
compareDateTimezone: compareDateTimezoneStub,
|
||||
}));
|
||||
|
||||
import Monthly from "./monthly";
|
||||
|
||||
describe("widgets/calendar/monthly", () => {
|
||||
it("renders an empty placeholder when showDate is not set", () => {
|
||||
const { container } = render(
|
||||
<Monthly
|
||||
service={{ widget: {} }}
|
||||
colorVariants={{}}
|
||||
events={{}}
|
||||
showDate={null}
|
||||
setShowDate={() => {}}
|
||||
currentDate={DateTime.now()}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("navigates months and renders day events", () => {
|
||||
const setShowDate = vi.fn();
|
||||
const showDate = DateTime.local(2099, 2, 15).startOf("day");
|
||||
const currentDate = DateTime.local(2099, 2, 4).startOf("day");
|
||||
const service = { widget: { maxEvents: 10, showTime: false } };
|
||||
|
||||
const events = {
|
||||
e1: { title: "Today Event", date: DateTime.local(2099, 2, 15, 10, 0), color: "zinc" },
|
||||
e2: { title: "Other Event", date: DateTime.local(2099, 2, 16, 10, 0), color: "zinc" },
|
||||
};
|
||||
|
||||
render(
|
||||
<Monthly
|
||||
service={service}
|
||||
colorVariants={{}}
|
||||
events={events}
|
||||
showDate={showDate}
|
||||
setShowDate={setShowDate}
|
||||
currentDate={currentDate}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Today Event")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Other Event")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: ">" }));
|
||||
expect(setShowDate).toHaveBeenCalled();
|
||||
expect(setShowDate.mock.calls[0][0].toISODate()).toBe(showDate.plus({ months: 1 }).startOf("day").toISODate());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "<" }));
|
||||
expect(setShowDate.mock.calls[1][0].toISODate()).toBe(showDate.minus({ months: 1 }).startOf("day").toISODate());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: showDate.toFormat("MMMM y") }));
|
||||
expect(setShowDate.mock.calls[2][0].toISODate()).toBe(currentDate.startOf("day").toISODate());
|
||||
});
|
||||
});
|
||||
95
src/widgets/calendar/proxy.test.js
Normal file
95
src/widgets/calendar/proxy.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
import calendarProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/calendar/proxy", () => {
|
||||
const envVersion = process.env.NEXT_PUBLIC_VERSION;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NEXT_PUBLIC_VERSION = envVersion;
|
||||
});
|
||||
|
||||
it("returns 400 when integration is missing", async () => {
|
||||
getServiceWidget.mockResolvedValue({ integrations: [] });
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await calendarProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid integration" });
|
||||
});
|
||||
|
||||
it("returns 403 when integration has no URL", async () => {
|
||||
getServiceWidget.mockResolvedValue({ integrations: [{ name: "foo", url: "" }] });
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await calendarProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "No integration URL specified" });
|
||||
});
|
||||
|
||||
it("adds a User-Agent for Outlook integrations and returns string data", async () => {
|
||||
process.env.NEXT_PUBLIC_VERSION = "1.2.3";
|
||||
getServiceWidget.mockResolvedValue({
|
||||
integrations: [{ name: "outlook", url: "https://example.com/outlook.ics" }],
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "text/calendar", Buffer.from("CAL")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "outlook", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await calendarProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith("https://example.com/outlook.ics", {
|
||||
headers: { "User-Agent": "gethomepage/1.2.3" },
|
||||
});
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/calendar");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ data: "CAL" });
|
||||
});
|
||||
|
||||
it("passes through non-200 status codes from integrations", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
integrations: [{ name: "foo", url: "https://example.com/foo.ics" }],
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([503, "text/plain", Buffer.from("nope")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await calendarProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.body).toEqual(Buffer.from("nope"));
|
||||
});
|
||||
});
|
||||
12
src/widgets/calendar/widget.test.js
Normal file
12
src/widgets/calendar/widget.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("calendar widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.api).toBe("{url}");
|
||||
});
|
||||
});
|
||||
48
src/widgets/calibreweb/component.test.jsx
Normal file
48
src/widgets/calibreweb/component.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/calibreweb/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "calibreweb" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("calibreweb.books")).toBeInTheDocument();
|
||||
expect(screen.getByText("calibreweb.authors")).toBeInTheDocument();
|
||||
expect(screen.getByText("calibreweb.categories")).toBeInTheDocument();
|
||||
expect(screen.getByText("calibreweb.series")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { books: 1, authors: 2, categories: 3, series: 4 },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "calibreweb" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/calibreweb/widget.test.js
Normal file
11
src/widgets/calibreweb/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("calibreweb widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
56
src/widgets/changedetectionio/component.test.jsx
Normal file
56
src/widgets/changedetectionio/component.test.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/changedetectionio/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "changedetectionio" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("changedetectionio.diffsDetected")).toBeInTheDocument();
|
||||
expect(screen.getByText("changedetectionio.totalObserved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes diffs detected (last_changed > 0 and not viewed)", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
a: { last_changed: 1, viewed: false },
|
||||
b: { last_changed: 0, viewed: false },
|
||||
c: { last_changed: 2, viewed: true },
|
||||
d: { last_changed: 3, viewed: false },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "changedetectionio" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "changedetectionio.totalObserved", 4);
|
||||
expectBlockValue(container, "changedetectionio.diffsDetected", 2);
|
||||
});
|
||||
});
|
||||
11
src/widgets/changedetectionio/widget.test.js
Normal file
11
src/widgets/changedetectionio/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("changedetectionio widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
55
src/widgets/channelsdvrserver/component.test.jsx
Normal file
55
src/widgets/channelsdvrserver/component.test.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/channelsdvrserver/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "channelsdvrserver" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("channelsdvrserver.shows")).toBeInTheDocument();
|
||||
expect(screen.getByText("channelsdvrserver.recordings")).toBeInTheDocument();
|
||||
expect(screen.getByText("channelsdvrserver.scheduled")).toBeInTheDocument();
|
||||
expect(screen.getByText("channelsdvrserver.passes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders values when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { stats: { groups: 1, files: 2, jobs: 3, rules: 4 } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "channelsdvrserver" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "channelsdvrserver.shows", 1);
|
||||
expectBlockValue(container, "channelsdvrserver.recordings", 2);
|
||||
expectBlockValue(container, "channelsdvrserver.scheduled", 3);
|
||||
expectBlockValue(container, "channelsdvrserver.passes", 4);
|
||||
});
|
||||
});
|
||||
11
src/widgets/channelsdvrserver/widget.test.js
Normal file
11
src/widgets/channelsdvrserver/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("channelsdvrserver widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
69
src/widgets/checkmk/component.test.jsx
Normal file
69
src/widgets/checkmk/component.test.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/checkmk/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls both endpoints with the expected query params and renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "checkmk" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(useWidgetAPI).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(Object),
|
||||
"services_info",
|
||||
expect.objectContaining({
|
||||
columns: "state",
|
||||
query: '{"op": "!=", "left": "state", "right": "0"}',
|
||||
}),
|
||||
);
|
||||
expect(useWidgetAPI).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Object),
|
||||
"hosts_info",
|
||||
expect.objectContaining({
|
||||
columns: "state",
|
||||
query: '{"op": "!=", "left": "state", "right": "0"}',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("checkmk.serviceErrors")).toBeInTheDocument();
|
||||
expect(screen.getByText("checkmk.hostErrors")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders counts when loaded", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: { value: [{}, {}] }, error: undefined })
|
||||
.mockReturnValueOnce({ data: { value: [{}] }, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "checkmk" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "checkmk.serviceErrors", 2);
|
||||
expectBlockValue(container, "checkmk.hostErrors", 1);
|
||||
});
|
||||
});
|
||||
11
src/widgets/checkmk/widget.test.js
Normal file
11
src/widgets/checkmk/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("checkmk widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
68
src/widgets/cloudflared/component.test.jsx
Normal file
68
src/widgets/cloudflared/component.test.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/cloudflared/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("cloudflared.status")).toBeInTheDocument();
|
||||
expect(screen.getByText("cloudflared.origin_ip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders status capitalization and origin_ip from nested connections", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
result: { status: "healthy", connections: { origin_ip: "1.2.3.4" } },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "cloudflared.status", "Healthy");
|
||||
expectBlockValue(container, "cloudflared.origin_ip", "1.2.3.4");
|
||||
});
|
||||
|
||||
it("falls back to origin_ip from first connection entry", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
result: { status: "down", connections: [{ origin_ip: "5.6.7.8" }] },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "cloudflared.origin_ip", "5.6.7.8");
|
||||
});
|
||||
});
|
||||
11
src/widgets/cloudflared/widget.test.js
Normal file
11
src/widgets/cloudflared/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("cloudflared widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
86
src/widgets/coinmarketcap/component.test.jsx
Normal file
86
src/widgets/coinmarketcap/component.test.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
// HeadlessUI dropdown is hard to test reliably; stub to a simple button.
|
||||
vi.mock("components/services/dropdown", () => ({
|
||||
default: ({ value, setValue }) => (
|
||||
<button type="button" data-testid="cmc-dropdown" onClick={() => setValue("24h")}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/coinmarketcap/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a configure message when no symbols/slugs are provided", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "coinmarketcap" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("coinmarketcap.configure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "coinmarketcap", symbols: ["BTC"] } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
// Error component normalizes the error into a message line we can assert on.
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders valid cryptos and updates percent change when date range changes", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
BTC: {
|
||||
id: 1,
|
||||
name: "Bitcoin",
|
||||
quote: { USD: { price: 30000, percent_change_1h: 1.234, percent_change_24h: -2.5 } },
|
||||
},
|
||||
ETH: {
|
||||
id: 2,
|
||||
name: "Ethereum",
|
||||
quote: { USD: { price: 2000, percent_change_1h: null, percent_change_24h: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{ widget: { type: "coinmarketcap", symbols: ["BTC", "ETH"], currency: "USD", defaultinterval: "1h" } }}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
// Only BTC is valid for 1h, ETH is filtered out due to null percent change.
|
||||
expect(screen.getByTestId("cmc-dropdown")).toHaveTextContent("1h");
|
||||
expect(screen.getByText("Bitcoin")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ethereum")).toBeNull();
|
||||
expect(screen.getByText("30000")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.23%")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("cmc-dropdown"));
|
||||
expect(screen.getByTestId("cmc-dropdown")).toHaveTextContent("24h");
|
||||
expect(screen.getByText("-2.50%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/coinmarketcap/widget.test.js
Normal file
11
src/widgets/coinmarketcap/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("coinmarketcap widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
61
src/widgets/crowdsec/component.test.jsx
Normal file
61
src/widgets/crowdsec/component.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/crowdsec/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("selects alerts24h endpoint when limit24h is enabled", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "crowdsec", limit24h: true } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(useWidgetAPI).toHaveBeenNthCalledWith(1, expect.any(Object), "alerts24h");
|
||||
expect(useWidgetAPI).toHaveBeenNthCalledWith(2, expect.any(Object), "bans");
|
||||
});
|
||||
|
||||
it("renders placeholders when both alerts and bans are missing", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "crowdsec" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("crowdsec.alerts")).toBeInTheDocument();
|
||||
expect(screen.getByText("crowdsec.bans")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 0-length arrays as 0 counts", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: [], error: undefined })
|
||||
.mockReturnValueOnce({ data: [], error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "crowdsec" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "crowdsec.alerts", 0);
|
||||
expectBlockValue(container, "crowdsec.bans", 0);
|
||||
});
|
||||
});
|
||||
92
src/widgets/crowdsec/proxy.test.js
Normal file
92
src/widgets/crowdsec/proxy.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
crowdsec: {
|
||||
api: "{url}/{endpoint}",
|
||||
loginURL: "{url}/login",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import crowdsecProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/crowdsec/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("logs in, caches a token, and uses it for requests", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "crowdsec",
|
||||
url: "http://cs",
|
||||
username: "machine",
|
||||
password: "pw",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ token: "tok", expire: new Date(Date.now() + 60_000).toISOString() }),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await crowdsecProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(2);
|
||||
expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("Bearer tok");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual(Buffer.from("data"));
|
||||
});
|
||||
|
||||
it("returns 500 if token cannot be obtained", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "crowdsec", url: "http://cs", username: "machine", password: "pw" });
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify({ expire: "2099-01-01T00:00:00Z" })]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await crowdsecProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
|
||||
});
|
||||
});
|
||||
11
src/widgets/crowdsec/widget.test.js
Normal file
11
src/widgets/crowdsec/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("crowdsec widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
202
src/widgets/customapi/component.test.jsx
Normal file
202
src/widgets/customapi/component.test.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/customapi/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholder blocks for the first 4 mappings while loading (block display)", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = {
|
||||
widget: {
|
||||
type: "customapi",
|
||||
mappings: [{ label: "a" }, { label: "b" }, { label: "c" }, { label: "d" }, { label: "e" }],
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("a")).toBeInTheDocument();
|
||||
expect(screen.getByText("b")).toBeInTheDocument();
|
||||
expect(screen.getByText("c")).toBeInTheDocument();
|
||||
expect(screen.getByText("d")).toBeInTheDocument();
|
||||
expect(screen.queryByText("e")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders list display, including additionalField and adaptive color", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { foo: { bar: 10 }, delta: -1 },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const service = {
|
||||
widget: {
|
||||
type: "customapi",
|
||||
display: "list",
|
||||
mappings: [
|
||||
{
|
||||
label: "Value",
|
||||
field: "foo.bar",
|
||||
format: "number",
|
||||
prefix: "$",
|
||||
additionalField: { field: "delta", color: "adaptive" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("Value")).toBeInTheDocument();
|
||||
expect(screen.getByText("$ 10")).toBeInTheDocument();
|
||||
|
||||
const delta = screen.getByText("-1");
|
||||
expect(delta.className).toContain("text-rose-300");
|
||||
});
|
||||
|
||||
it("shows error UI when widget API errors and mappings do not treat error as data", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(
|
||||
<Component service={{ widget: { type: "customapi", mappings: [{ label: "x", field: "x" }] } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats error payloads as data when a mapping targets the error field", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { error: "rate limited" },
|
||||
error: { message: "ignored" },
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: {
|
||||
type: "customapi",
|
||||
display: "list",
|
||||
mappings: [{ label: "Error", field: "error", format: "text" }],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.queryByText("ignored")).toBeNull();
|
||||
expect(screen.getByText("rate limited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders dynamic-list placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(
|
||||
<Component service={{ widget: { type: "customapi", display: "dynamic-list", mappings: {} } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders dynamic-list errors when required mapping properties are missing", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined });
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: {
|
||||
type: "customapi",
|
||||
display: "dynamic-list",
|
||||
mappings: { items: "items" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Name and label properties are required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders dynamic-list items with a target link and enforces the limit", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
{ id: "1", name: "First", value: 2 },
|
||||
{ id: "2", name: "Second", value: 3 },
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: {
|
||||
type: "customapi",
|
||||
display: "dynamic-list",
|
||||
mappings: {
|
||||
items: "items",
|
||||
name: "name",
|
||||
label: "value",
|
||||
target: "https://example.com/items/{id}",
|
||||
limit: "1",
|
||||
prefix: "#",
|
||||
scale: "2/1",
|
||||
format: "number",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("# 4")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second")).toBeNull();
|
||||
|
||||
const link = screen.getByRole("link", { name: /First/i });
|
||||
expect(link).toHaveAttribute("href", "https://example.com/items/1");
|
||||
});
|
||||
|
||||
it("supports legacy object field definitions and size formatting", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { a: { b: { c: ["x", "y", "z"] } } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: {
|
||||
type: "customapi",
|
||||
mappings: [
|
||||
{
|
||||
label: "Count",
|
||||
field: { a: { b: "c" } },
|
||||
format: "size",
|
||||
suffix: "items",
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Count")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 items")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/customapi/widget.test.js
Normal file
11
src/widgets/customapi/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("customapi widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
84
src/widgets/deluge/component.test.jsx
Normal file
84
src/widgets/deluge/component.test.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
vi.mock("../../components/widgets/queue/queueEntry", () => ({
|
||||
default: ({ title }) => <div data-testid="queue-entry">{title}</div>,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/deluge/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "deluge" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("deluge.leech")).toBeInTheDocument();
|
||||
expect(screen.getByText("deluge.download")).toBeInTheDocument();
|
||||
expect(screen.getByText("deluge.seed")).toBeInTheDocument();
|
||||
expect(screen.getByText("deluge.upload")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes leech/seed counts and upload/download rates, and renders leech progress entries", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
torrents: {
|
||||
a: { download_payload_rate: 10, upload_payload_rate: 1, total_remaining: 0, state: "Seeding", progress: 100 },
|
||||
b: {
|
||||
download_payload_rate: 5,
|
||||
upload_payload_rate: 2,
|
||||
total_remaining: 5,
|
||||
state: "Downloading",
|
||||
progress: 50,
|
||||
eta: 60,
|
||||
name: "B",
|
||||
},
|
||||
c: {
|
||||
download_payload_rate: 0,
|
||||
upload_payload_rate: 3,
|
||||
total_remaining: 10,
|
||||
state: "Downloading",
|
||||
progress: 10,
|
||||
eta: 120,
|
||||
name: "C",
|
||||
},
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const service = { widget: { type: "deluge", enableLeechProgress: true } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
// keys=3, completed=1 => leech=2
|
||||
expectBlockValue(container, "deluge.leech", 2);
|
||||
expectBlockValue(container, "deluge.seed", 1);
|
||||
expectBlockValue(container, "deluge.download", 15);
|
||||
expectBlockValue(container, "deluge.upload", 6);
|
||||
|
||||
// Only downloading torrents get QueueEntry.
|
||||
expect(screen.getAllByTestId("queue-entry").map((el) => el.textContent)).toEqual(["B", "C"]);
|
||||
});
|
||||
});
|
||||
63
src/widgets/deluge/proxy.test.js
Normal file
63
src/widgets/deluge/proxy.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { sendJsonRpcRequest, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
sendJsonRpcRequest: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/handlers/jsonrpc", () => ({
|
||||
sendJsonRpcRequest,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
deluge: {
|
||||
api: "{url}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import delugeProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/deluge/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("logs in and retries the update call after an auth error", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "deluge", url: "http://deluge", password: "pw" });
|
||||
|
||||
sendJsonRpcRequest
|
||||
// update_ui -> error code 1 => 403
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ error: { code: 1 } }))])
|
||||
// auth.login -> ok
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ result: true }))])
|
||||
// update_ui retry -> ok
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ result: { torrents: {} } }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await delugeProxyHandler(req, res);
|
||||
|
||||
expect(sendJsonRpcRequest).toHaveBeenCalledTimes(3);
|
||||
expect(sendJsonRpcRequest.mock.calls[0][1]).toBe("web.update_ui");
|
||||
expect(sendJsonRpcRequest.mock.calls[1][1]).toBe("auth.login");
|
||||
expect(sendJsonRpcRequest.mock.calls[2][1]).toBe("web.update_ui");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual(Buffer.from(JSON.stringify({ result: { torrents: {} } })));
|
||||
});
|
||||
});
|
||||
11
src/widgets/deluge/widget.test.js
Normal file
11
src/widgets/deluge/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("deluge widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
44
src/widgets/develancacheui/component.test.jsx
Normal file
44
src/widgets/develancacheui/component.test.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/develancacheui/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "develancacheui" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("develancacheui.cachehitbytes")).toBeInTheDocument();
|
||||
expect(screen.getByText("develancacheui.cachemissbytes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders byte totals when loaded", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { totalCacheHitBytes: 100, totalCacheMissBytes: 200 },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "develancacheui" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
expect(screen.getByText("200")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/develancacheui/widget.test.js
Normal file
11
src/widgets/develancacheui/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("develancacheui widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
61
src/widgets/diskstation/component.test.jsx
Normal file
61
src/widgets/diskstation/component.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/diskstation/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "diskstation" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("diskstation.uptime")).toBeInTheDocument();
|
||||
expect(screen.getByText("diskstation.volumeAvailable")).toBeInTheDocument();
|
||||
expect(screen.getByText("resources.cpu")).toBeInTheDocument();
|
||||
expect(screen.getByText("resources.mem")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes uptime days, volume free bytes, and CPU/memory usage", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: { data: { up_time: "48:00:00" } }, error: undefined })
|
||||
.mockReturnValueOnce({
|
||||
data: { data: { vol_info: [{ name: "vol1", used_size: "20", total_size: "100" }] } },
|
||||
error: undefined,
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: { data: { cpu: { user_load: "10", system_load: "5" }, memory: { real_usage: "25" } } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "diskstation", volume: "vol1" } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expectBlockValue(container, "diskstation.uptime", "2 diskstation.days");
|
||||
expectBlockValue(container, "diskstation.volumeAvailable", 80);
|
||||
expectBlockValue(container, "resources.cpu", 15);
|
||||
expectBlockValue(container, "resources.mem", 25);
|
||||
});
|
||||
});
|
||||
11
src/widgets/diskstation/widget.test.js
Normal file
11
src/widgets/diskstation/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("diskstation widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
54
src/widgets/dispatcharr/component.test.jsx
Normal file
54
src/widgets/dispatcharr/component.test.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/dispatcharr/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "dispatcharr" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("dispatcharr.channels")).toBeInTheDocument();
|
||||
expect(screen.getByText("dispatcharr.streams")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders counts and stream entries when enabled", () => {
|
||||
useWidgetAPI.mockReturnValueOnce({ data: [{}, {}, {}], error: undefined }).mockReturnValueOnce({
|
||||
data: {
|
||||
count: 1,
|
||||
channels: [{ stream_name: "Stream1", clients: [{}, {}], avg_bitrate: "1000kbps" }],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const service = { widget: { type: "dispatcharr", enableActiveStreams: true } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expectBlockValue(container, "dispatcharr.channels", 3);
|
||||
expectBlockValue(container, "dispatcharr.streams", 1);
|
||||
expect(screen.getByText(/Stream1 - Clients: 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText("1000kbps")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
108
src/widgets/dispatcharr/proxy.test.js
Normal file
108
src/widgets/dispatcharr/proxy.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
dispatcharr: {
|
||||
api: "{url}/{endpoint}",
|
||||
mappings: {
|
||||
token: { endpoint: "auth/token" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import dispatcharrProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/dispatcharr/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("logs in when token is missing and uses Bearer token for requests", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "dispatcharr",
|
||||
url: "http://dispatcharr",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ access: "t1" }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await dispatcharrProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(2);
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://dispatcharr/auth/token");
|
||||
expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("Bearer t1");
|
||||
expect(res.body).toEqual(Buffer.from("data"));
|
||||
});
|
||||
|
||||
it("retries after a bad response by clearing cache and logging in again", async () => {
|
||||
cache.put("dispatcharrProxyHandler__token.svc", "old");
|
||||
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "dispatcharr",
|
||||
url: "http://dispatcharr",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([400, "application/json", Buffer.from(JSON.stringify({ items: [] }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ access: "new" }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await dispatcharrProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[1][0].toString()).toBe("http://dispatcharr/auth/token");
|
||||
expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new");
|
||||
expect(res.body).toEqual(Buffer.from("ok"));
|
||||
});
|
||||
});
|
||||
11
src/widgets/dispatcharr/widget.test.js
Normal file
11
src/widgets/dispatcharr/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("dispatcharr widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
61
src/widgets/docker/component.test.jsx
Normal file
61
src/widgets/docker/component.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } 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 Component from "./component";
|
||||
|
||||
describe("widgets/docker/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders offline status when container is not running", () => {
|
||||
useSWR
|
||||
.mockReturnValueOnce({ data: { status: "exited" }, error: undefined }) // status
|
||||
.mockReturnValueOnce({ data: undefined, error: undefined }); // stats
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "docker", container: "c" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("widget.status")).toBeInTheDocument();
|
||||
expect(screen.getByText("docker.offline")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cpu/mem/rx/tx values when stats are available", () => {
|
||||
useSWR
|
||||
.mockReturnValueOnce({ data: { status: "running" }, error: undefined }) // status
|
||||
.mockReturnValueOnce({
|
||||
data: {
|
||||
stats: {
|
||||
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
|
||||
memory_stats: { usage: 1000, total_inactive_file: 100 },
|
||||
networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } },
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "docker", container: "c" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
// cpu: (100/1000)*2*100=20
|
||||
expect(container.textContent).toContain("20");
|
||||
// mem used: 1000-100=900
|
||||
expect(container.textContent).toContain("900");
|
||||
// rx=4, tx=6
|
||||
expect(container.textContent).toContain("4");
|
||||
expect(container.textContent).toContain("6");
|
||||
});
|
||||
});
|
||||
55
src/widgets/docker/stats-helpers.test.js
Normal file
55
src/widgets/docker/stats-helpers.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from "./stats-helpers";
|
||||
|
||||
describe("widgets/docker/stats-helpers", () => {
|
||||
it("calculateCPUPercent returns 0 when deltas are not positive", () => {
|
||||
expect(
|
||||
calculateCPUPercent({
|
||||
cpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000, online_cpus: 2 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("calculateCPUPercent computes percent and rounds to 1 decimal", () => {
|
||||
// cpuDelta=100, systemDelta=1000, cpus=2 => (100/1000)*2*100 = 20.0
|
||||
expect(
|
||||
calculateCPUPercent({
|
||||
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
|
||||
}),
|
||||
).toBe(20);
|
||||
});
|
||||
|
||||
it("calculateUsedMemory subtracts inactive file (prefers total_inactive_file)", () => {
|
||||
const stats = {
|
||||
memory_stats: {
|
||||
usage: 1000,
|
||||
total_inactive_file: 100,
|
||||
stats: { inactive_file: 200 },
|
||||
},
|
||||
};
|
||||
expect(calculateUsedMemory(stats)).toBe(900);
|
||||
});
|
||||
|
||||
it("calculateUsedMemory falls back to stats.inactive_file when total_inactive_file missing", () => {
|
||||
const stats = {
|
||||
memory_stats: {
|
||||
usage: 1000,
|
||||
stats: { inactive_file: 200 },
|
||||
},
|
||||
};
|
||||
expect(calculateUsedMemory(stats)).toBe(800);
|
||||
});
|
||||
|
||||
it("calculateThroughput uses the special networks.network key when present", () => {
|
||||
const stats = { networks: { network: { rx_bytes: 5, tx_bytes: 6 }, eth0: { rx_bytes: 1, tx_bytes: 2 } } };
|
||||
expect(calculateThroughput(stats)).toEqual({ rxBytes: 5, txBytes: 6 });
|
||||
});
|
||||
|
||||
it("calculateThroughput sums all interfaces otherwise", () => {
|
||||
const stats = { networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } } };
|
||||
expect(calculateThroughput(stats)).toEqual({ rxBytes: 4, txBytes: 6 });
|
||||
});
|
||||
});
|
||||
61
src/widgets/dockhand/component.test.jsx
Normal file
61
src/widgets/dockhand/component.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/dockhand/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults fields and filters to 4 blocks while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = { widget: { type: "dockhand" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual(["running", "total", "cpu", "memory"]);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("dockhand.running")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.total")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.cpu")).toBeInTheDocument();
|
||||
expect(screen.getByText("dockhand.memory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders environment-specific values when widget.environment matches", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Prod",
|
||||
containers: { running: 2, total: 5, paused: 1, pendingUpdates: 3 },
|
||||
metrics: { cpuPercent: 10, memoryPercent: 20 },
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const service = { widget: { type: "dockhand", environment: "prod" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "dockhand.running", 2);
|
||||
expectBlockValue(container, "dockhand.total", 5);
|
||||
expectBlockValue(container, "dockhand.cpu", 10);
|
||||
expectBlockValue(container, "dockhand.memory", 20);
|
||||
});
|
||||
});
|
||||
82
src/widgets/dockhand/proxy.test.js
Normal file
82
src/widgets/dockhand/proxy.test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
dockhand: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import dockhandProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/dockhand/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retries after a 401 by logging in once", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "dockhand",
|
||||
url: "http://dockhand/",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]) // login
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); // retry
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api/v1/status", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await dockhandProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[1][0]).toBe("http://dockhand/api/auth/login");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual(Buffer.from("data"));
|
||||
});
|
||||
|
||||
it("returns a sanitized error response for HTTP errors", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "dockhand",
|
||||
url: "http://dockhand",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([500, "application/json", Buffer.from("boom")]);
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: { group: "g", service: "svc", endpoint: "api/v1/status?token=abc", index: "0" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await dockhandProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.message).toBe("HTTP Error");
|
||||
expect(res.body.error.url).toContain("token=***");
|
||||
});
|
||||
});
|
||||
11
src/widgets/dockhand/widget.test.js
Normal file
11
src/widgets/dockhand/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("dockhand widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
62
src/widgets/downloadstation/component.test.jsx
Normal file
62
src/widgets/downloadstation/component.test.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/downloadstation/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while tasks are missing", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "downloadstation" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("downloadstation.leech")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloadstation.download")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloadstation.seed")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloadstation.upload")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes leech/seed counts and total upload/download rates", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
tasks: [
|
||||
{ size: 10, additional: { transfer: { size_downloaded: 10, speed_download: 5, speed_upload: 1 } } },
|
||||
{ size: 20, additional: { transfer: { size_downloaded: 5, speed_download: 6, speed_upload: 2 } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "downloadstation" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
// completed = 1, leech = 1
|
||||
expectBlockValue(container, "downloadstation.seed", 1);
|
||||
expectBlockValue(container, "downloadstation.leech", 1);
|
||||
expectBlockValue(container, "downloadstation.download", 11);
|
||||
expectBlockValue(container, "downloadstation.upload", 3);
|
||||
});
|
||||
});
|
||||
11
src/widgets/downloadstation/widget.test.js
Normal file
11
src/widgets/downloadstation/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("downloadstation widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
103
src/widgets/emby/component.test.jsx
Normal file
103
src/widgets/emby/component.test.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function baseSession(overrides = {}) {
|
||||
return {
|
||||
Id: "s1",
|
||||
UserName: "Alice",
|
||||
NowPlayingItem: {
|
||||
Type: "Episode",
|
||||
Name: "Pilot",
|
||||
SeriesName: "Show",
|
||||
ParentIndexNumber: 1,
|
||||
IndexNumber: 2,
|
||||
RunTimeTicks: 100000000,
|
||||
},
|
||||
PlayState: { PositionTicks: 50000000, IsPaused: true, IsMuted: true },
|
||||
TranscodingInfo: { IsVideoDirect: true, VideoDecoderIsHardware: true, VideoEncoderIsHardware: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("widgets/emby/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading skeleton when sessions/count are missing", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() });
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "emby", enableBlocks: true, enableNowPlaying: true } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
// CountBlocks placeholders should be present.
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("emby.movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("emby.series")).toBeInTheDocument();
|
||||
expect(screen.getByText("emby.episodes")).toBeInTheDocument();
|
||||
expect(screen.getByText("emby.songs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders single-session view with expanded two rows and stream title with user + episode number", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: [baseSession()], error: undefined, mutate: vi.fn() }) // Sessions
|
||||
.mockReturnValueOnce({
|
||||
data: { MovieCount: 1, SeriesCount: 2, EpisodeCount: 3, SongCount: 4 },
|
||||
error: undefined,
|
||||
}); // Count
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: {
|
||||
type: "emby",
|
||||
enableBlocks: true,
|
||||
enableNowPlaying: true,
|
||||
enableUser: true,
|
||||
showEpisodeNumber: true,
|
||||
expandOneStreamToTwoRows: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show: S01 · E02 - Pilot (Alice)")).toBeInTheDocument();
|
||||
expect(screen.getByText(/00:05/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/00:10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no_active when there are no sessions playing", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({
|
||||
data: [{ Id: "s2", PlayState: { PositionTicks: 0 }, UserName: "Bob" }],
|
||||
error: undefined,
|
||||
mutate: vi.fn(),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: { MovieCount: 0, SeriesCount: 0, EpisodeCount: 0, SongCount: 0 },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Component service={{ widget: { type: "emby", enableNowPlaying: true, enableBlocks: true } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("emby.no_active")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/emby/widget.test.js
Normal file
11
src/widgets/emby/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("emby widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
57
src/widgets/esphome/component.test.jsx
Normal file
57
src/widgets/esphome/component.test.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/esphome/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults fields and filters placeholders to 4 blocks while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const service = { widget: { type: "esphome" } };
|
||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(service.widget.fields).toEqual(["online", "offline", "offline_alt", "total"]);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("esphome.online")).toBeInTheDocument();
|
||||
expect(screen.getByText("esphome.offline")).toBeInTheDocument();
|
||||
expect(screen.getByText("esphome.offline_alt")).toBeInTheDocument();
|
||||
expect(screen.getByText("esphome.total")).toBeInTheDocument();
|
||||
expect(screen.queryByText("esphome.unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("computes online/offline/unknown and filters to default fields", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { a: true, b: false, c: null },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "esphome" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "esphome.online", 1);
|
||||
expectBlockValue(container, "esphome.offline", 1);
|
||||
// offline_alt is count of not-true, i.e. false+null = 2
|
||||
expectBlockValue(container, "esphome.offline_alt", 2);
|
||||
expectBlockValue(container, "esphome.total", 3);
|
||||
});
|
||||
});
|
||||
11
src/widgets/esphome/widget.test.js
Normal file
11
src/widgets/esphome/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("esphome widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
72
src/widgets/evcc/component.test.jsx
Normal file
72
src/widgets/evcc/component.test.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/evcc/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "evcc", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("evcc.pv_power")).toBeInTheDocument();
|
||||
expect(screen.getByText("evcc.grid_power")).toBeInTheDocument();
|
||||
expect(screen.getByText("evcc.home_power")).toBeInTheDocument();
|
||||
expect(screen.getByText("evcc.charge_power")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "evcc", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders computed kilowatt values (including result wrapper, grid fallback, and loadpoint sum)", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
result: {
|
||||
pvPower: 1000,
|
||||
grid: { power: 2000 },
|
||||
homePower: 3000,
|
||||
loadpoints: [{ chargePower: 500 }, { chargePower: 1500 }],
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "evcc", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "evcc.pv_power", "1 evcc.kilowatt");
|
||||
expectBlockValue(container, "evcc.grid_power", "2 evcc.kilowatt");
|
||||
expectBlockValue(container, "evcc.home_power", "3 evcc.kilowatt");
|
||||
expectBlockValue(container, "evcc.charge_power", "2 evcc.kilowatt");
|
||||
});
|
||||
});
|
||||
11
src/widgets/evcc/widget.test.js
Normal file
11
src/widgets/evcc/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("evcc widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
62
src/widgets/filebrowser/component.test.jsx
Normal file
62
src/widgets/filebrowser/component.test.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/filebrowser/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "filebrowser", url: "http://x" } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expect(screen.getByText("filebrowser.available")).toBeInTheDocument();
|
||||
expect(screen.getByText("filebrowser.used")).toBeInTheDocument();
|
||||
expect(screen.getByText("filebrowser.total")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "filebrowser", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders computed available/used/total bytes", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: { total: 100, used: 40 }, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "filebrowser", url: "http://x" } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expectBlockValue(container, "filebrowser.available", 60);
|
||||
expectBlockValue(container, "filebrowser.used", 40);
|
||||
expectBlockValue(container, "filebrowser.total", 100);
|
||||
});
|
||||
});
|
||||
79
src/widgets/filebrowser/proxy.test.js
Normal file
79
src/widgets/filebrowser/proxy.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
filebrowser: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import filebrowserProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/filebrowser/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("logs in and uses X-AUTH token for subsequent requests", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "filebrowser",
|
||||
url: "http://fb",
|
||||
username: "u",
|
||||
password: "p",
|
||||
authHeader: "X-User",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "text/plain", "token123"])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "api/raw", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await filebrowserProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(2);
|
||||
expect(httpProxy.mock.calls[0][0]).toBe("http://fb/login");
|
||||
expect(httpProxy.mock.calls[0][1].headers).toEqual({ "X-User": "u" });
|
||||
expect(httpProxy.mock.calls[1][1].headers["X-AUTH"]).toBe("token123");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual(Buffer.from("data"));
|
||||
});
|
||||
|
||||
it("returns 500 when login fails", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "filebrowser", url: "http://fb", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValueOnce([401, "text/plain", "nope"]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "api/raw", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await filebrowserProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Failed to authenticate with Filebrowser" });
|
||||
});
|
||||
});
|
||||
11
src/widgets/filebrowser/widget.test.js
Normal file
11
src/widgets/filebrowser/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("filebrowser widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
71
src/widgets/fileflows/component.test.jsx
Normal file
71
src/widgets/fileflows/component.test.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/fileflows/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "fileflows", url: "http://x" } }} />,
|
||||
{
|
||||
settings: { hideErrors: false },
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("fileflows.queue")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileflows.processing")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileflows.processed")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileflows.time")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "fileflows", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders values and falls back time to 0:00", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { queue: 1, processing: 2, processed: 3, time: "" },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "fileflows", url: "http://x" } }} />,
|
||||
{
|
||||
settings: { hideErrors: false },
|
||||
},
|
||||
);
|
||||
|
||||
expectBlockValue(container, "fileflows.queue", 1);
|
||||
expectBlockValue(container, "fileflows.processing", 2);
|
||||
expectBlockValue(container, "fileflows.processed", 3);
|
||||
expectBlockValue(container, "fileflows.time", "0:00");
|
||||
});
|
||||
});
|
||||
11
src/widgets/fileflows/widget.test.js
Normal file
11
src/widgets/fileflows/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("fileflows widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
74
src/widgets/firefly/component.test.jsx
Normal file
74
src/widgets/firefly/component.test.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({
|
||||
useWidgetAPI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/firefly/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "firefly" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("firefly.networth")).toBeInTheDocument();
|
||||
expect(screen.getByText("firefly.budget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error UI when either request errors", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } }) // summary
|
||||
.mockReturnValueOnce({ data: undefined, error: undefined }); // budgets
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "firefly" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
// The widget uses a string error, which Error normalizes to { message }.
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Failed to load Firefly account summary and budgets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders net worth and budget summary", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({
|
||||
data: { "net-worth-in-EUR": { value_parsed: "100" } },
|
||||
error: undefined,
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
type: "available_budgets",
|
||||
attributes: {
|
||||
amount: "100",
|
||||
currency_symbol: "$",
|
||||
spent_in_budgets: [{ sum: "-10" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "firefly" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
expect(screen.getByText("$ 10 / $ 100")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
src/widgets/firefly/widget.test.js
Normal file
11
src/widgets/firefly/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("firefly widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
68
src/widgets/flood/component.test.jsx
Normal file
68
src/widgets/flood/component.test.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/flood/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "flood", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a helpful error when the API returns no torrent data", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "flood", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("No torrent data returned")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders computed leech/seed counts and up/down rates", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
torrents: {
|
||||
a: { downRate: 10, upRate: 20, status: ["downloading"] },
|
||||
b: { downRate: 5, upRate: 0, status: ["complete"] },
|
||||
c: { downRate: 0, upRate: 1, status: ["complete", "downloading"] },
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "flood", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "flood.leech", 2);
|
||||
expectBlockValue(container, "flood.download", 15);
|
||||
expectBlockValue(container, "flood.seed", 2);
|
||||
expectBlockValue(container, "flood.upload", 21);
|
||||
});
|
||||
});
|
||||
69
src/widgets/flood/proxy.test.js
Normal file
69
src/widgets/flood/proxy.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
import floodProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/flood/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("logs in and retries after a 401 response", async () => {
|
||||
getServiceWidget.mockResolvedValue({ url: "http://flood" });
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await floodProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://flood/api/stats");
|
||||
expect(httpProxy.mock.calls[1][0]).toBe("http://flood/api/auth/authenticate");
|
||||
expect(httpProxy.mock.calls[1][1].body).toBeNull();
|
||||
expect(httpProxy.mock.calls[2][0].toString()).toBe("http://flood/api/stats");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual(Buffer.from("data"));
|
||||
});
|
||||
|
||||
it("returns the login error status when authentication fails", async () => {
|
||||
getServiceWidget.mockResolvedValue({ url: "http://flood", username: "u", password: "p" });
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
|
||||
.mockResolvedValueOnce([500, "application/json", Buffer.from("bad")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await floodProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[1][1].body).toBe(JSON.stringify({ username: "u", password: "p" }));
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith(Buffer.from("bad"));
|
||||
});
|
||||
});
|
||||
11
src/widgets/flood/widget.test.js
Normal file
11
src/widgets/flood/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("flood widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
64
src/widgets/freshrss/component.test.jsx
Normal file
64
src/widgets/freshrss/component.test.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/freshrss/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "freshrss", url: "http://x" } }} />,
|
||||
{
|
||||
settings: { hideErrors: false },
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
|
||||
expect(screen.getByText("freshrss.unread")).toBeInTheDocument();
|
||||
expect(screen.getByText("freshrss.subscriptions")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders error UI when widget API errors", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "freshrss", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders unread and subscription counts", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: { unread: 7, subscriptions: 3 }, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "freshrss", url: "http://x" } }} />,
|
||||
{
|
||||
settings: { hideErrors: false },
|
||||
},
|
||||
);
|
||||
|
||||
expectBlockValue(container, "freshrss.unread", 7);
|
||||
expectBlockValue(container, "freshrss.subscriptions", 3);
|
||||
});
|
||||
});
|
||||
112
src/widgets/freshrss/proxy.test.js
Normal file
112
src/widgets/freshrss/proxy.test.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
freshrss: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import freshrssProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/freshrss/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("logs in, caches token, and returns subscription + unread counts", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "freshrss",
|
||||
url: "http://fresh",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "text/plain", Buffer.from("SID=1\nAuth=token123\n")])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ subscriptions: [1, 2, 3] }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ max: 7 }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await freshrssProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://fresh/accounts/ClientLogin");
|
||||
expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("GoogleLogin auth=token123");
|
||||
expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("GoogleLogin auth=token123");
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ subscriptions: 3, unread: 7 });
|
||||
});
|
||||
|
||||
it("retries API calls after a 401 by obtaining a new session token", async () => {
|
||||
cache.put("freshrssProxyHandler__sessionToken.svc", "old");
|
||||
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "freshrss",
|
||||
url: "http://fresh",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([401, "application/json", Buffer.from("{}")])
|
||||
.mockResolvedValueOnce([200, "text/plain", Buffer.from("SID=1\nAuth=newtok\n")])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ subscriptions: [1] }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ max: 2 }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await freshrssProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(4);
|
||||
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("accounts/ClientLogin"));
|
||||
expect(loginCalls).toHaveLength(1);
|
||||
const listCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("subscription/list"));
|
||||
expect(listCalls).toHaveLength(2);
|
||||
expect(res.body).toEqual({ subscriptions: 1, unread: 2 });
|
||||
});
|
||||
});
|
||||
11
src/widgets/freshrss/widget.test.js
Normal file
11
src/widgets/freshrss/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("freshrss widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
83
src/widgets/frigate/component.test.jsx
Normal file
83
src/widgets/frigate/component.test.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
function expectBlockValue(container, label, value) {
|
||||
const block = findServiceBlockByLabel(container, label);
|
||||
expect(block, `missing block for ${label}`).toBeTruthy();
|
||||
expect(block.textContent).toContain(String(value));
|
||||
}
|
||||
|
||||
describe("widgets/frigate/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: undefined, error: undefined }) // stats
|
||||
.mockReturnValueOnce({ data: undefined, error: undefined }); // events
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "frigate", url: "http://x" } }} />,
|
||||
{
|
||||
settings: { hideErrors: false },
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expect(screen.getByText("frigate.cameras")).toBeInTheDocument();
|
||||
expect(screen.getByText("frigate.uptime")).toBeInTheDocument();
|
||||
expect(screen.getByText("frigate.version")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders error UI when either endpoint errors", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } })
|
||||
.mockReturnValueOnce({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "frigate", url: "http://x" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders stats and recent events when enabled", () => {
|
||||
useWidgetAPI
|
||||
.mockReturnValueOnce({
|
||||
data: { num_cameras: 2, uptime: 3600, version: "1.0.0" },
|
||||
error: undefined,
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: [{ id: "e1", camera: "Cam1", label: "Person", score: 0.5, start_time: 123 }],
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "frigate", url: "http://x", enableRecentEvents: true } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expectBlockValue(container, "frigate.cameras", 2);
|
||||
expectBlockValue(container, "frigate.uptime", 3600);
|
||||
expectBlockValue(container, "frigate.version", "1.0.0");
|
||||
|
||||
// The event text is composed of multiple text nodes; match on the element's full textContent.
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.classList?.contains("absolute") && el.textContent?.includes("Cam1 (Person 50)")),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("123")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user