diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index 643a3b949..a9dbff5ba 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -19,8 +19,15 @@ export async function settleIntegrationQueries, ): Promise { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); - return settled.flatMap((result, index) => { - if (result.status === "fulfilled") return [result.value]; + const results: TResult[] = []; + const errors: unknown[] = []; + + settled.forEach((result, index) => { + if (result.status === "fulfilled") { + results.push(result.value); + return; + } + const integration = integrations[index]; logger.warn( new ErrorWithMetadata( @@ -29,7 +36,18 @@ export async function settleIntegrationQueries 0) { + throw errors[0]; + } + + return results; } diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts new file mode 100644 index 000000000..09f80bc31 --- /dev/null +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from "vitest"; + +import { settleIntegrationQueries } from "../settle-integrations"; + +const mocks = vi.hoisted(() => ({ + logger: { warn: vi.fn() }, +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => mocks.logger, +})); + +vi.mock("@homarr/core/infrastructure/logs/error", () => ({ + ErrorWithMetadata: Error, +})); + +const integrations = [ + { id: "first", name: "First", kind: "plex" }, + { id: "second", name: "Second", kind: "jellyfin" }, +]; + +describe("settleIntegrationQueries", () => { + test("returns an empty result when no integrations are configured", async () => { + await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]); + }); + + test("keeps successful integrations when another integration fails", async () => { + const error = new Error("Plex unavailable"); + + await expect( + settleIntegrationQueries(integrations, (integration) => + integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name), + ), + ).resolves.toEqual(["Second"]); + }); + + test("rejects when every integration fails", async () => { + const error = new Error("Integration unavailable"); + + await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error); + }); + + test("uses configured fallbacks when every integration fails", async () => { + await expect( + settleIntegrationQueries(integrations, () => Promise.reject(new Error("Integration unavailable")), { + fallback: (integration) => integration.name, + }), + ).resolves.toEqual(["First", "Second"]); + }); +}); diff --git a/packages/request-handler/src/media-release.ts b/packages/request-handler/src/media-release.ts index 02209f68f..994214b8e 100644 --- a/packages/request-handler/src/media-release.ts +++ b/packages/request-handler/src/media-release.ts @@ -13,4 +13,6 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMediaReleasesAsync(); }, + cacheTtlMs: 5 * 60 * 1000, + fallbackToStaleOnError: true, }); diff --git a/packages/request-handler/src/test/media-release.spec.ts b/packages/request-handler/src/test/media-release.spec.ts new file mode 100644 index 000000000..825107afd --- /dev/null +++ b/packages/request-handler/src/test/media-release.spec.ts @@ -0,0 +1,45 @@ +import type { MediaRelease } from "@homarr/integrations/types"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { mediaReleaseRequestHandler } from "../media-release"; + +const mocks = vi.hoisted(() => ({ + createIntegrationAsync: vi.fn(), +})); + +vi.mock("@homarr/integrations", () => ({ + createIntegrationAsync: mocks.createIntegrationAsync, +})); + +const integration = { + id: "plex-integration", + kind: "plex", +} as Parameters[0]; + +describe("mediaReleaseRequestHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T00:00:00Z")); + mediaReleaseRequestHandler.invalidateCache(); + mocks.createIntegrationAsync.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("keeps the last successful releases when a refresh fails", async () => { + const releases = [{ id: "release-1" }] as MediaRelease[]; + const getMediaReleasesAsync = vi.fn().mockResolvedValueOnce(releases).mockRejectedValueOnce(new Error("Plex down")); + mocks.createIntegrationAsync.mockResolvedValue({ getMediaReleasesAsync }); + + const handler = mediaReleaseRequestHandler.handler(integration, {}); + const initial = await handler.getDataAsync(); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + const fallback = await handler.getDataAsync(); + + expect(fallback).toEqual(initial); + expect(getMediaReleasesAsync).toHaveBeenCalledTimes(2); + }); +});