diff --git a/README.md b/README.md index 6a7d7b1..a46c19a 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Add a drive later by mounting it on the host, re-running `arrstack`, and enterin | --- | --- | | `arrstack install` | Runs the wizard and installs (default when no subcommand is given) | | `arrstack doctor` | Three passes: preflight (Docker installed + running, Compose v2 present, 10 GB free on `/` and on `storage_root`, ports 80 and 443 free), `docker compose ps` container state, then an HTTP probe on every service that declares an HTTP healthcheck | -| `arrstack update` | `docker compose pull` then `docker compose up -d`. Configs and media untouched | +| `arrstack update` | Pulls images, recreates containers, health-checks them, and seeds any missing Sonarr/Radarr/Prowlarr admin login. Configs and media untouched | | `arrstack show-password` | Prints `/admin.txt` (username, password, timestamp) to stdout | | `arrstack logs ` | Follows `docker compose logs -f ` for one service (`jellyfin`, `sonarr`, etc) | | `arrstack uninstall` | `docker compose down` only. Preserves `config/`, `state.json`, `admin.txt`, and media. Add `--purge` to also delete `/config/`; `state.json`, `admin.txt`, and media still survive | diff --git a/docs/guide/09-updating.md b/docs/guide/09-updating.md index 79a1e5c..117939e 100644 --- a/docs/guide/09-updating.md +++ b/docs/guide/09-updating.md @@ -18,11 +18,15 @@ arrstack doctor ## What `arrstack update` does 1. Reads `state.json` to find the install directory and compose file. -2. Runs `docker compose pull` against the generated `docker-compose.yml` so every enabled service fetches its latest image. -3. Runs `docker compose up -d`, which recreates only the containers whose images changed. -4. Prints a hint to run `arrstack doctor` next, which re-probes every service HTTP endpoint. +2. Re-renders `docker-compose.yml` and `Caddyfile` from the current binary's templates, so installer fixes reach an existing install. +3. Runs `docker compose pull` against the generated `docker-compose.yml` so every enabled service fetches its latest image. +4. Runs `docker compose up -d`, which recreates only the containers whose images changed. +5. Health-checks every enabled service and seeds a missing Sonarr, Radarr, or Prowlarr admin user from `admin.txt`. This repairs arr logins left behind by an install that aborted before its wiring steps. +6. Prunes dangling images and prints a hint to run `arrstack doctor` next. -Duration: 30 seconds to 5 minutes depending on image sizes and download speed. Health checks, image pruning, and single-service updates are not built in yet, run them with `docker compose` directly if you need them. +Duration: 30 seconds to 5 minutes depending on image sizes and download speed. To update a single service, use `docker compose` directly. + +`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`. ## Image tag policy diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index bc177e8..1a7b4c4 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -31,6 +31,24 @@ sudo -E arrstack install --install-dir /opt/arrstack # or run elevated --- +### admin.txt credentials are rejected after a failed install + +**Symptom.** The install failed at `docker compose pull` or the health gate, you started the containers by hand with `docker compose up -d`, and now the username/password in `admin.txt` does not log in to Sonarr, Radarr, Prowlarr, or Jellyfin. + +**Cause.** The installer writes `admin.txt` before it touches Docker, but it creates the arr admin users and the Jellyfin admin user during the wiring steps that run *after* the containers are healthy. An install that aborts early never reaches them. The containers still answer health checks, so everything looks up, but no login exists yet. Running `docker compose up -d` by hand does not run the wiring. + +**Fix.** Finish the install so the wiring steps run: +```bash +arrstack install --resume +``` +On arrstack 1.2.1 and later, `arrstack update` also seeds a missing Sonarr/Radarr/Prowlarr admin user, but it does not run the Jellyfin or Jellyseerr setup. Use `--resume` when Jellyfin login is also broken. + +`arrstack doctor` prints an "Install incomplete" warning when `state.json` has no `install_completed_at` timestamp, which is the signature of this state. + +**Prevention.** Let the installer finish instead of starting containers manually after a failed step. Re-run `arrstack install --resume` once the underlying image or config problem is fixed. + +--- + ### Jellyseerr EACCES on /app/config/logs **Symptom.** `jellyseerr | Error: EACCES: permission denied, mkdir '/app/config/logs'` followed by exit 1. diff --git a/package.json b/package.json index 3748972..96a88c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arrstack-installer", - "version": "1.2.0", + "version": "1.2.1", "type": "module", "bin": { "arrstack": "src/cli.ts" diff --git a/src/state/store.ts b/src/state/store.ts index f44ba94..a9ffec6 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -48,6 +48,29 @@ export function adminTxtGuardMessage(installDir: string): string | null { ); } +export interface AdminCredentials { + username: string; + password: string; +} + +/** + * Read the credentials the installer wrote at install time. Returns null when + * admin.txt is absent, unreadable, or incomplete, so callers can skip recovery + * work instead of crashing or rotating the password. + */ +export function readAdminCredentials(installDir: string): AdminCredentials | null { + const p = join(installDir, "admin.txt"); + try { + const text = readFileSync(p, "utf-8"); + const username = text.match(/^username:\s*(.+)$/m)?.[1]?.trim(); + const password = text.match(/^password:\s*(.+)$/m)?.[1]?.trim(); + if (!username || !password) return null; + return { username, password }; + } catch { + return null; + } +} + export function writeState(installDir: string, state: State): void { const statePath = join(installDir, STATE_FILE); const tmpPath = `${statePath}.tmp`; diff --git a/src/usecase/doctor.ts b/src/usecase/doctor.ts index 2c2a44c..ee1b253 100644 --- a/src/usecase/doctor.ts +++ b/src/usecase/doctor.ts @@ -72,6 +72,20 @@ export async function runDoctor(installDir: string): Promise { let allOk = true; + // A failed install (most often an image pull failure) writes state.json + // before the docker steps but never reaches the wiring steps. Containers can + // still be started by hand and answer health checks while no arr admin user + // exists, so admin.txt credentials fail to log in. Surface that here. + if (!state.install_completed_at) { + section("Install state"); + allOk = false; + fail( + "Install incomplete", + "state.json has no install_completed_at timestamp", + "Run: arrstack install --resume to finish service wiring (arr logins, Jellyfin setup)", + ); + } + // --- Preflight checks --- section("System checks"); // Post-install: skip 80/443-free checks. Our own Caddy is supposed to be diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 12c4b9e..9a3427f 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -30,7 +30,7 @@ import { repushProwlarrIndexersToApps, } from "../wiring/prowlarr-flaresolverr.js"; import { linkJellyseerrToArrs } from "../wiring/jellyseerr-arr.js"; -import { seedArrAdmin } from "../wiring/arr-auth.js"; +import { seedArrAdmin, ARR_AUTH_TARGETS } from "../wiring/arr-auth.js"; import { configureTrailarr } from "../wiring/trailarr.js"; import { configureArr } from "../wiring/sonarr-radarr.js"; import { configureQbit } from "../wiring/qbittorrent.js"; @@ -569,11 +569,7 @@ export async function runInstall( // Step 12c2: Seed admin credentials on each arr app. Sonarr/Radarr/Prowlarr // store users in SQLite, so the config.xml preset alone doesn't pre-create // an account. PUT /api/v{N}/config/host with username/password does. - for (const { id, port, version } of [ - { id: "prowlarr", port: 9696, version: "v1" as const }, - { id: "sonarr", port: 8989, version: "v3" as const }, - { id: "radarr", port: 7878, version: "v3" as const }, - ]) { + for (const { id, port, apiVersion } of ARR_AUTH_TARGETS) { if (!has(id)) continue; await runStep(`Seeding ${id} admin credentials`, onStep, log, async () => { await seedArrAdmin({ @@ -581,7 +577,7 @@ export async function runInstall( username: state.admin.username, password: adminPassword, baseUrl: `http://localhost:${port}`, - apiVersion: version, + apiVersion, }); }); } diff --git a/src/usecase/update.ts b/src/usecase/update.ts index 053e418..a89fd44 100644 --- a/src/usecase/update.ts +++ b/src/usecase/update.ts @@ -1,6 +1,6 @@ import { existsSync, openSync, writeSync, closeSync, fsyncSync, writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { readState, writeState } from "../state/store.js"; +import { readState, writeState, readAdminCredentials } from "../state/store.js"; import { exec } from "../lib/exec.js"; import { getServicesByIds } from "../catalog/index.js"; import type { Service } from "../catalog/schema.js"; @@ -9,12 +9,19 @@ import { renderCaddyfile } from "../renderer/caddy.js"; import { renderCompose } from "../renderer/compose.js"; import { resolveVpnWireguardKey } from "../wiring/nordvpn.js"; import { configureBazarrProviderHub } from "../wiring/bazarr-providerhub.js"; +import { + seedArrAdmin, + ARR_AUTH_TARGETS, + type ArrAuthOptions, + type SeedArrAdminResult, +} from "../wiring/arr-auth.js"; export interface UpdateDeps { runStreaming: (argv: string[], onLine: (line: string) => void) => Promise<{ ok: boolean; code: number | null }>; captureImages: (composeFile: string) => Promise>; pruneImages: () => Promise<{ ok: boolean; message: string }>; checkHealth: (url: string, timeoutMs: number) => Promise; + seedArrAdmin: (opts: ArrAuthOptions) => Promise; now: () => number; } @@ -41,6 +48,7 @@ export async function runUpdate(installDir: string, deps?: Partial): captureImages: deps?.captureImages ?? defaultCaptureImages, pruneImages: deps?.pruneImages ?? defaultPruneImages, checkHealth: deps?.checkHealth ?? defaultCheckHealth, + seedArrAdmin: deps?.seedArrAdmin ?? seedArrAdmin, now: deps?.now ?? Date.now, }; @@ -126,6 +134,14 @@ export async function runUpdate(installDir: string, deps?: Partial): await runHealthChecks(services, d.checkHealth, logAndEcho); phaseTimes["health"] = d.now() - healthStart; + // Recovery: an install that failed before the wiring steps (image pull + // error, health gate timeout) leaves the arr containers running but with no + // admin user, so the password in admin.txt cannot log in. Seed any arr that + // has no user yet. Best-effort: never fail an update over this. + const seedStart = d.now(); + await reseedArrAdmins(installDir, state, d.seedArrAdmin, logAndEcho); + phaseTimes["seed-arr-admins"] = d.now() - seedStart; + // Provider Hub: keep the default subtitle providers installed + enabled on // existing installs too (the built-in providers are being retired). // Best-effort: a failure here logs a warning and never fails the update. @@ -289,6 +305,43 @@ async function runHealthChecks( ); } +async function reseedArrAdmins( + installDir: string, + state: State, + seed: UpdateDeps["seedArrAdmin"], + log: (line: string) => void, +): Promise { + const targets = ARR_AUTH_TARGETS.filter((t) => state.services_enabled.includes(t.id)); + if (targets.length === 0) return; + + const creds = readAdminCredentials(installDir); + if (!creds) { + log("[update] admin.txt missing or unreadable; skipping arr admin recovery"); + return; + } + + for (const target of targets) { + try { + const result = await seed({ + apiKey: state.api_keys[target.id] ?? "", + username: state.admin.username, + password: creds.password, + baseUrl: `http://localhost:${target.port}`, + apiVersion: target.apiVersion, + }); + log( + `[update] ${target.id}: ${ + result === "seeded" ? "seeded admin credentials" : "admin login already configured" + }`, + ); + } catch (err) { + log( + `[update] warning: could not verify ${target.id} admin login: ${(err as Error).message}`, + ); + } + } +} + function diffImages(before: Map, after: Map): UpdateSummary[] { const ids = new Set([...before.keys(), ...after.keys()]); const out: UpdateSummary[] = []; diff --git a/src/version.ts b/src/version.ts index a3a25fb..f92f80d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.0"; +export const VERSION = "1.2.1"; diff --git a/src/wiring/arr-auth.ts b/src/wiring/arr-auth.ts index dd4da40..6446367 100644 --- a/src/wiring/arr-auth.ts +++ b/src/wiring/arr-auth.ts @@ -16,11 +16,23 @@ export interface ArrAuthOptions { apiVersion: "v1" | "v3"; } +// Shared by install and update so both know every arr app whose login is +// seeded through the API. Ports match the catalog's adminPort values. +export const ARR_AUTH_TARGETS = [ + { id: "prowlarr", port: 9696, apiVersion: "v1" }, + { id: "sonarr", port: 8989, apiVersion: "v3" }, + { id: "radarr", port: 7878, apiVersion: "v3" }, +] as const; + +export type ArrAuthTarget = (typeof ARR_AUTH_TARGETS)[number]; + async function readBody(res: Response): Promise { try { return (await res.text()).slice(0, 400); } catch { return ""; } } -export async function seedArrAdmin(opts: ArrAuthOptions): Promise { +export type SeedArrAdminResult = "seeded" | "exists"; + +export async function seedArrAdmin(opts: ArrAuthOptions): Promise { const hostUrl = `${opts.baseUrl}/api/${opts.apiVersion}/config/host`; const getRes = await fetchWithRetry(hostUrl, { @@ -31,10 +43,12 @@ export async function seedArrAdmin(opts: ArrAuthOptions): Promise { } const current = (await getRes.json()) as Record; - // If the admin user is already set, don't re-PUT — some arr versions reject - // identical password updates as "Must match" validation noise on reconfigure. - if (current.username === opts.username && current.password) { - return; + // Any existing user wins. Do not overwrite a username/password the user + // changed in the UI, even when it differs from the installer default. Only a + // genuinely unseeded instance (the state left behind when an install aborts + // before the wiring steps) gets credentials written. + if (current.username && current.password) { + return "exists"; } const updated = { @@ -58,4 +72,5 @@ export async function seedArrAdmin(opts: ArrAuthOptions): Promise { `PUT ${hostUrl} failed: HTTP ${putRes.status}\n${await readBody(putRes)}`, ); } + return "seeded"; } diff --git a/tests/state/store.test.ts b/tests/state/store.test.ts index c2f6c65..5b12a47 100644 --- a/tests/state/store.test.ts +++ b/tests/state/store.test.ts @@ -7,6 +7,7 @@ import { writeState, adminTxtUnreadable, adminTxtGuardMessage, + readAdminCredentials, } from "../../src/state/store.js"; import type { State } from "../../src/state/schema.js"; @@ -102,6 +103,25 @@ test("adminTxtUnreadable: true when admin.txt exists but can't be read", () => { chmodSync(p, 0o600); // let afterEach clean up }); +test("readAdminCredentials parses username and password from admin.txt", () => { + mkdirSync(TMP, { recursive: true }); + writeFileSync( + join(TMP, "admin.txt"), + "username: admin\npassword: UXsr-v3H8-JpMZ-gqCm\ngenerated: 2026-06-24T23:31:10.664Z\n", + ); + expect(readAdminCredentials(TMP)).toEqual({ + username: "admin", + password: "UXsr-v3H8-JpMZ-gqCm", + }); +}); + +test("readAdminCredentials returns null when admin.txt is absent or incomplete", () => { + mkdirSync(TMP, { recursive: true }); + expect(readAdminCredentials(TMP)).toBeNull(); + writeFileSync(join(TMP, "admin.txt"), "username: admin\n"); + expect(readAdminCredentials(TMP)).toBeNull(); +}); + test("adminTxtGuardMessage: null when admin.txt is absent or readable", () => { mkdirSync(TMP, { recursive: true }); expect(adminTxtGuardMessage(TMP)).toBeNull(); diff --git a/tests/usecase/update.test.ts b/tests/usecase/update.test.ts index c9f1df9..c065798 100644 --- a/tests/usecase/update.test.ts +++ b/tests/usecase/update.test.ts @@ -72,6 +72,7 @@ function makeDeps(overrides: Partial = {}): { deps: UpdateDeps; call return { ok: true, message: "Total reclaimed space: 0B" }; }, checkHealth: async () => true, + seedArrAdmin: async () => "exists", now: Date.now, ...overrides, }; @@ -212,6 +213,64 @@ test("runUpdate attempts the Bazarr Provider Hub wiring when bazarr is enabled", expect(logTxt.toLowerCase()).toContain("provider hub"); }); +test("runUpdate seeds arr admin credentials from admin.txt after a failed install", async () => { + // Regression for #6: an install that aborts at docker compose pull leaves the + // arr containers running with no user in SQLite, so the admin.txt password is + // rejected. update must seed it on the next run. + const dir = makeInstallDir(); + writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: s3cret-pw\ngenerated: now\n"); + const calls: any[] = []; + const { deps } = makeDeps({ + seedArrAdmin: async (opts) => { + calls.push(opts); + return "seeded"; + }, + }); + + await silently(() => runUpdate(dir, deps)); + + expect(calls).toHaveLength(1); + expect(calls[0].username).toBe("admin"); + expect(calls[0].password).toBe("s3cret-pw"); + expect(calls[0].baseUrl).toBe("http://localhost:8989"); + expect(calls[0].apiVersion).toBe("v3"); + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt).toContain("sonarr: seeded admin credentials"); +}); + +test("runUpdate skips arr admin seeding when admin.txt is missing", async () => { + const dir = makeInstallDir(); + let called = false; + const { deps } = makeDeps({ + seedArrAdmin: async () => { + called = true; + return "seeded"; + }, + }); + + await silently(() => runUpdate(dir, deps)); + + expect(called).toBe(false); + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt).toContain("admin.txt missing or unreadable"); +}); + +test("runUpdate logs a warning and continues when arr admin seeding fails", async () => { + const dir = makeInstallDir(); + writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: pw\ngenerated: now\n"); + const { deps } = makeDeps({ + seedArrAdmin: async () => { + throw new Error("HTTP 500"); + }, + }); + + await silently(() => runUpdate(dir, deps)); + + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt).toContain("warning: could not verify sonarr admin login"); + expect(logTxt).toContain("done."); +}); + test("runUpdate summary reports changed service images", async () => { const dir = makeInstallDir(); let captureCount = 0; diff --git a/tests/wiring/arr-auth.test.ts b/tests/wiring/arr-auth.test.ts new file mode 100644 index 0000000..3d97240 --- /dev/null +++ b/tests/wiring/arr-auth.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { seedArrAdmin, ARR_AUTH_TARGETS, type ArrAuthOptions } from "../../src/wiring/arr-auth"; + +const BASE_OPTS: ArrAuthOptions = { + apiKey: "test-api-key", + username: "admin", + password: "test-password", + baseUrl: "http://arr.test", + apiVersion: "v3", +}; + +const origFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = origFetch; +}); + +interface RecordedCall { + url: string; + method: string; + body?: any; +} + +function mockHostConfig( + current: Record, + putStatus = 200, +): RecordedCall[] { + const calls: RecordedCall[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push({ url: u, method, body: init?.body ? JSON.parse(init.body) : undefined }); + if (method === "GET") { + return new Response(JSON.stringify(current), { status: 200 }); + } + return new Response(JSON.stringify({ ...current, ...JSON.parse(init.body) }), { + status: putStatus, + }); + }) as any; + return calls; +} + +describe("seedArrAdmin", () => { + test("writes forms auth credentials to an unseeded instance", async () => { + const calls = mockHostConfig({ username: "", password: null }); + + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("seeded"); + + expect(calls).toHaveLength(2); + expect(calls[0].url).toBe("http://arr.test/api/v3/config/host"); + expect(calls[0].method).toBe("GET"); + expect(calls[1].method).toBe("PUT"); + expect(calls[1].body.authenticationMethod).toBe("forms"); + expect(calls[1].body.authenticationRequired).toBe("enabled"); + expect(calls[1].body.username).toBe("admin"); + expect(calls[1].body.password).toBe("test-password"); + expect(calls[1].body.passwordConfirmation).toBe("test-password"); + }); + + test("accepts 202 Accepted from Sonarr and Radarr", async () => { + mockHostConfig({ username: "", password: null }, 202); + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("seeded"); + }); + + test("does not overwrite an existing admin", async () => { + const calls = mockHostConfig({ username: "admin", password: "existing-hash" }); + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("exists"); + expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0); + }); + + test("does not overwrite credentials the user changed in the UI", async () => { + // Regression guard for update-time recovery: a non-default username means + // the user owns the account now, so the installer password must not reset it. + const calls = mockHostConfig({ username: "bob", password: "existing-hash" }); + await expect(seedArrAdmin(BASE_OPTS)).resolves.toBe("exists"); + expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0); + }); + + test("rejects when the PUT fails", async () => { + mockHostConfig({ username: "", password: null }, 400); + await expect(seedArrAdmin(BASE_OPTS)).rejects.toThrow(/PUT .* failed: HTTP 400/); + }); + + test("targets are the three arr apps with their API versions", () => { + expect(ARR_AUTH_TARGETS).toEqual([ + { id: "prowlarr", port: 9696, apiVersion: "v1" }, + { id: "sonarr", port: 8989, apiVersion: "v3" }, + { id: "radarr", port: 7878, apiVersion: "v3" }, + ]); + }); +}); diff --git a/tests/wiring/qbittorrent.test.ts b/tests/wiring/qbittorrent.test.ts index 894d4da..7b4f859 100644 --- a/tests/wiring/qbittorrent.test.ts +++ b/tests/wiring/qbittorrent.test.ts @@ -1,36 +1,33 @@ import { describe, expect, test, afterEach } from "bun:test"; import { configureQbit } from "../../src/wiring/qbittorrent"; -// NEVER call configureQbit() without an explicit base URL in these tests. -// The default is http://localhost:8080 — on any developer host with an -// arrstack install running, hitting that repeatedly will exceed qBittorrent's -// failed-login threshold (5 attempts) and get the host IP banned until the -// container is restarted. The ban is shared across all future login attempts -// including the installer's own, so the *next* `arrstack install` run fails -// with "qBittorrent login failed: 403". Always point tests at an unreachable -// port so they only exercise the connection-refused path. -const UNREACHABLE = "http://127.0.0.1:19999"; - +// NEVER call configureQbit() against the default localhost:8080 in these +// tests. On any developer host with an arrstack install running, repeated +// failed logins exceed qBittorrent's threshold and ban the host IP until the +// container restarts. Every test here mocks fetch or supplies a fake base URL. describe("configureQbit", () => { - test("is an async function", () => { - expect(configureQbit).toBeInstanceOf(Function); - const result = configureQbit("admin", "pass", UNREACHABLE); - expect(result).toBeInstanceOf(Promise); - result.catch(() => {}); + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; }); - test("accepts a custom base URL", () => { - const result = configureQbit("admin", "pass", "http://127.0.0.1:19998"); - expect(result).toBeInstanceOf(Promise); - result.catch(() => {}); + test("is an async function", () => { + expect(configureQbit).toBeInstanceOf(Function); + expect(configureQbit.constructor.name).toBe("AsyncFunction"); }); - test("rejects when the API is unreachable", async () => { - await expect(configureQbit("admin", "pass", UNREACHABLE)).rejects.toThrow(); + test("rejects when the request layer fails", async () => { + globalThis.fetch = (async () => { + throw new Error("boom"); + }) as any; + await expect(configureQbit("admin", "pass", "http://qbit.test")).rejects.toThrow("boom"); }); test("rejects when login is rejected", async () => { - await expect(configureQbit("baduser", "badpass", UNREACHABLE)).rejects.toThrow(); + globalThis.fetch = (async () => new Response("Forbidden", { status: 403 })) as any; + await expect(configureQbit("baduser", "badpass", "http://qbit.test")).rejects.toThrow( + /login failed: 403/, + ); }); }); diff --git a/tests/wiring/sonarr-radarr.test.ts b/tests/wiring/sonarr-radarr.test.ts index 7994b48..a1f2afb 100644 --- a/tests/wiring/sonarr-radarr.test.ts +++ b/tests/wiring/sonarr-radarr.test.ts @@ -2,55 +2,43 @@ import { describe, expect, test, mock, afterEach } from "bun:test"; import { configureArr } from "../../src/wiring/sonarr-radarr"; describe("configureArr", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + }); + + const optsFor = (category: "tv" | "movies") => ({ + rootFolder: category === "tv" ? "/data/media/tv" : "/data/media/movies", + extraFolders: [], + qbitUser: "admin", + qbitPass: "pass", + category, + }); + test("is an async function", () => { expect(configureArr).toBeInstanceOf(Function); - // Calling with bad args should return a Promise (even if it rejects) - const result = configureArr("sonarr", "key", { - rootFolder: "/data/media/tv", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "tv", - }); - expect(result).toBeInstanceOf(Promise); - // Suppress unhandled rejection for this test - result.catch(() => {}); + expect(configureArr.constructor.name).toBe("AsyncFunction"); }); - test("uses port 8989 for sonarr and 7878 for radarr", () => { - // We verify the port selection logic indirectly: both calls return a - // Promise, proving the function runs and doesn't throw synchronously. - const sonarrPromise = configureArr("sonarr", "key", { - rootFolder: "/data/media/tv", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "tv", - }); - sonarrPromise.catch(() => {}); - expect(sonarrPromise).toBeInstanceOf(Promise); + test("uses port 8989 for sonarr and 7878 for radarr", async () => { + const urls: string[] = []; + globalThis.fetch = (async (url: any) => { + urls.push(String(url)); + return new Response("[]", { status: 200 }); + }) as any; + + await configureArr("sonarr", "key", optsFor("tv")); + await configureArr("radarr", "key", optsFor("movies")); - const radarrPromise = configureArr("radarr", "key", { - rootFolder: "/data/media/movies", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "movies", - }); - radarrPromise.catch(() => {}); - expect(radarrPromise).toBeInstanceOf(Promise); + expect(urls.some((u) => u.startsWith("http://localhost:8989/"))).toBe(true); + expect(urls.some((u) => u.startsWith("http://localhost:7878/"))).toBe(true); }); - test("rejects when the API is unreachable", async () => { - await expect( - configureArr("sonarr", "key", { - rootFolder: "/data/media/tv", - extraFolders: [], - qbitUser: "admin", - qbitPass: "pass", - category: "tv", - }) - ).rejects.toThrow(); + test("rejects when the request layer fails", async () => { + globalThis.fetch = (async () => { + throw new Error("boom"); + }) as any; + await expect(configureArr("sonarr", "key", optsFor("tv"))).rejects.toThrow("boom"); }); });