fix: complete widget modernization review

This commit is contained in:
Thomas Camlong committed 2026-08-02 08:05:04 +02:00
1 parent 79ae42f0c3
commit 6f27ede8bf
35 files changed
+621 -265

No files matched your search

@@ -0,0 +1,175 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { IntegrationKind } from "@homarr/definitions";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
import { calendarRouter } from "../../widgets/calendar";
import { mediaRequestsRouter } from "../../widgets/media-requests";
import { mediaServerRouter } from "../../widgets/media-server";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
});
const createHandler = <T>(integration: { name: string }, data: T) => ({
getDataAsync: async () => {
if (integration.name === "Offline") throw new Error("offline");
return { data, timestamp: new Date() };
},
});
vi.mock("@homarr/request-handler/calendar", () => ({
calendarMonthRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/media-server", () => ({
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
return {
...actual,
mediaRequestListRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, [
{
id: 42,
name: "Healthy request",
type: "movie",
backdropImageUrl: "https://example.com/backdrop.jpg",
posterImagePath: "https://example.com/poster.jpg",
href: "https://example.com/request/42",
createdAt: new Date("2026-08-01T12:00:00Z"),
status: "pending",
availability: "requested",
} satisfies MediaRequest,
]),
},
};
});
vi.mock("@homarr/request-handler/media-request-stats", () => ({
mediaRequestStatsRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, {
stats: { total: 0, movie: 0, tv: 0, pending: 0, approved: 0, declined: 0, processing: 0, available: 0 },
users: [
{
id: 7,
displayName: "Healthy user",
avatar: "https://example.com/avatar.jpg",
requestCount: 3,
link: "https://example.com/user/7",
},
],
} satisfies MediaRequestStats),
},
}));
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const setupAsync = async (kind: IntegrationKind) => {
const db = createDb();
const userId = createId();
const onlineId = createId();
const offlineId = createId();
await db.insert(users).values({ id: userId });
await db.insert(integrations).values([
{ id: onlineId, kind, name: "Online", url: "https://online.example.com" },
{ id: offlineId, kind, name: "Offline", url: "https://offline.example.com" },
]);
await db.insert(integrationUserPermissions).values([
{ integrationId: onlineId, userId, permission: "use" },
{ integrationId: offlineId, userId, permission: "use" },
]);
return {
db: db as Database,
session: createSession(userId),
integrationIds: [onlineId, offlineId],
onlineId,
offlineId,
};
};
beforeEach(() => vi.clearAllMocks());
describe("partial integration failures", () => {
test("calendar and media server retain a failed integration record", async () => {
const calendar = await setupAsync("sonarr");
const calendarResults = await calendarRouter
.createCaller({ db: calendar.db, deviceType: undefined, session: calendar.session })
.findAllEvents({
integrationIds: calendar.integrationIds,
year: 2026,
month: 7,
releaseType: [],
showUnmonitored: false,
});
expect(calendarResults).toEqual(
expect.arrayContaining([
expect.objectContaining({ integration: expect.objectContaining({ id: calendar.offlineId }), error: "offline" }),
]),
);
const mediaServer = await setupAsync("plex");
const streamResults = await mediaServerRouter
.createCaller({ db: mediaServer.db, deviceType: undefined, session: mediaServer.session })
.getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false });
expect(streamResults).toEqual(
expect.arrayContaining([
expect.objectContaining({ integrationId: mediaServer.offlineId, sessions: [], error: "offline" }),
]),
);
});
test("media request responses expose failed integrations without dropping successful data", async () => {
const mediaRequests = await setupAsync("overseerr");
const caller = mediaRequestsRouter.createCaller({
db: mediaRequests.db,
deviceType: undefined,
session: mediaRequests.session,
});
const latest = await caller.getLatestRequests({
integrationIds: mediaRequests.integrationIds,
statuses: ["pending"],
recentDays: 0,
});
expect(latest.requests).toEqual([
expect.objectContaining({
id: 42,
name: "Healthy request",
integrationId: mediaRequests.onlineId,
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
}),
]);
expect(latest.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "offline" }),
]);
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
expect(stats.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "offline" }),
]);
expect(stats.stats).toHaveLength(1);
expect(stats.users).toEqual([
expect.objectContaining({
id: 7,
displayName: "Healthy user",
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
}),
]);
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { everyBeszelIntegrationFailed } from "../beszel-failures";
describe("everyBeszelIntegrationFailed", () => {
it("keeps partial successes", () => {
expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false);
});
it("detects when every integration failed", () => {
expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true);
});
it("allows an empty integration selection", () => {
expect(everyBeszelIntegrationFailed([])).toBe(false);
});
});
@@ -1,5 +0,0 @@
export const everyBeszelIntegrationFailed = (results: readonly unknown[]) =>
results.length > 0 &&
results.every(
(result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined,
);
+2 -17
View File
@@ -13,7 +13,6 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { everyBeszelIntegrationFailed } from "./beszel-failures";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
@@ -21,20 +20,6 @@ const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) {
if (everyBeszelIntegrationFailed(results)) {
const firstError = results[0];
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Every Beszel integration failed while loading ${operation}: ${
typeof firstError === "object" && firstError !== null && "error" in firstError
? String(firstError.error)
: "unknown error"
}`,
});
}
}
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -70,6 +55,7 @@ export const beszelRouter = createTRPCRouter({
updatedAt: new Date(0),
error: errorMessage(error),
}),
throwOnAllFailures: true,
},
);
logger.debug("getSystems completed", {
@@ -78,7 +64,6 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
throwIfEveryBeszelIntegrationFailed(results, "systems");
return results;
}),
@@ -144,6 +129,7 @@ export const beszelRouter = createTRPCRouter({
updatedAt: new Date(0),
error: errorMessage(error),
}),
throwOnAllFailures: true,
},
);
logger.debug("getAlerts completed", {
@@ -152,7 +138,6 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
throwIfEveryBeszelIntegrationFailed(results, "alerts");
return results;
}),
+29 -13
View File
@@ -27,19 +27,35 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
};
});
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
events: [],
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -1,7 +1,9 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
mediaRequestListInputSchema,
@@ -13,6 +15,12 @@ import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
interface IntegrationQueryResult<TData> {
integration: { id: string; name: string; kind: IntegrationKindByCategory<"mediaRequest"> };
data: TData;
error?: string;
}
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.meta({
@@ -25,11 +33,26 @@ export const mediaRequestsRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: [],
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
const requests = results
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
@@ -47,6 +70,12 @@ export const mediaRequestsRouter = createTRPCRouter({
mediaRequestStatusConfiguration[dataB.status].position
);
});
return {
requests,
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}),
getStats: publicProcedure
.meta({
@@ -58,12 +87,30 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null;
users: MediaRequestStats["users"];
}>[] = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { ...data, stats: data.stats as typeof data.stats | null },
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { stats: null, users: [] },
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
return {
stats: results.flatMap((result) => result.data.stats),
stats: results.flatMap((result) => (result.data.stats ? [result.data.stats] : [])),
users: results
.map((result) =>
result.data.users.map((user) => ({
@@ -74,6 +121,9 @@ export const mediaRequestsRouter = createTRPCRouter({
.flat()
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}),
answerRequest: protectedProcedure
+25 -11
View File
@@ -23,16 +23,30 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: [],
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
});