Chore: homepage tests (#6278)

This commit is contained in:
shamoon
2026-02-04 19:58:39 -08:00
committed by GitHub
parent 7d019185a3
commit 872a3600aa
558 changed files with 32606 additions and 84 deletions
+64
View 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");
});
});
+2 -1
View File
@@ -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
View 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
View 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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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");
});
});
@@ -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)");
});
});
@@ -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
View 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
View 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
View 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}");
});
});