import { join } from "path"; import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; import type { StartedTestContainer } from "testcontainers"; import { beforeAll, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; import { SabnzbdIntegration } from "../src"; import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items"; vi.mock("@homarr/db", async (importActual) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actual = await importActual(); return { ...actual, db: createDb(), }; }); const DEFAULT_API_KEY = "8r45mfes43s3iw7x3oecto6dl9ilxnf9"; const IMAGE_NAME = "linuxserver/sabnzbd:latest"; describe("Sabnzbd integration", () => { beforeAll(async () => { const containerRuntimeClient = await getContainerRuntimeClient(); await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME)); }, 100_000); test("Test connection should work", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); // Act const result = await sabnzbdIntegration.testConnectionAsync(); // Assert expect(result.success).toBe(true); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("Test connection should fail with wrong ApiKey", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, "wrong-api-key"); // Act const result = await sabnzbdIntegration.testConnectionAsync(); // Assert expect(result.success).toBe(false); if (result.success) return; expect(result.error).toBeInstanceOf(TestConnectionError); expect(result.error.type).toBe("authorization"); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("pauseQueueAsync should work", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); // Acts const actAsync = async () => await sabnzbdIntegration.pauseQueueAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(actAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } }); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("resumeQueueAsync should work", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); await sabnzbdIntegration.pauseQueueAsync(); // Acts const actAsync = async () => await sabnzbdIntegration.resumeQueueAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(actAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.toMatchObject({ status: { paused: false }, }); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("Items should be empty", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); // Act const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.toMatchObject({ items: [], }); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("1 Items should exist after adding one", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); // Act const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(getAsync()).resolves.not.toThrow(); expect((await getAsync()).items).toHaveLength(1); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("Pause item should work", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); const item = await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); // Act const actAsync = async () => await sabnzbdIntegration.pauseItemAsync(item); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "downloading" }] }); await expect(actAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "paused" }] }); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("Resume item should work", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); const item = await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); await sabnzbdIntegration.pauseItemAsync(item); // Act const actAsync = async () => await sabnzbdIntegration.resumeItemAsync(item); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "paused" }] }); await expect(actAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "downloading" }] }); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds test("Delete item should result in empty items", async () => { // Arrange const startedContainer = await createSabnzbdContainer().start(); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); const item = await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); // Act - fromDisk already doesn't work for sabnzbd, so only test deletion itself. const actAsync = async () => await sabnzbdIntegration.deleteItemAsync({ ...item, progress: 0 } as DownloadClientItem, false); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); // Assert await expect(actAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.toMatchObject({ items: [] }); // Cleanup await startedContainer.stop(); }, 30_000); // Timeout of 30 seconds }); const createSabnzbdContainer = () => { return new GenericContainer(IMAGE_NAME) .withCopyFilesToContainer([ { source: join(__dirname, "/volumes/usenet/sabnzbd.ini"), target: "/config/sabnzbd.ini", }, ]) .withExposedPorts(1212) .withEnvironment({ PUID: "0", PGID: "0" }) .withWaitStrategy(Wait.forHttp("/", 1212)); }; const createSabnzbdIntegration = (container: StartedTestContainer, apiKey: string) => { return new SabnzbdIntegration({ id: "1", decryptedSecrets: [ { kind: "apiKey", value: apiKey, }, ], name: "Sabnzbd", url: `http://${container.getHost()}:${container.getMappedPort(1212)}`, }); }; const sabNzbdAddItemAsync = async ( container: StartedTestContainer, apiKey: string, integration: SabnzbdIntegration, ) => { // Add nzb file in the watch folder await container.copyFilesToContainer([ { source: join(__dirname, "/volumes/usenet/test_download_100MB.nzb"), target: "/nzb/test_download_100MB.nzb", }, ]); // Adding file is faster than triggering scan of the watch folder // (local add: 1.4-1.6s, scan trigger: 2.5-2.7s, auto scan: 2.9-3s) await fetch( `http://${container.getHost()}:${container.getMappedPort(1212)}/api` + "?mode=addlocalfile" + "&name=%2Fnzb%2Ftest_download_100MB.nzb" + `&apikey=${apiKey}`, ); // Retries up to 5 times to let SabNzbd scan and process the nzb (1 retry should suffice tbh) for (let i = 0; i < 5; i++) { const { items: [item], } = await integration.getClientJobsAndStatusAsync(); if (item) return item; } // Throws if it can't find the item throw new Error("No item found"); };