From bf7de2a37cc5e8e407f35d528298fe9333195916 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 12 Jul 2026 15:10:29 -0400 Subject: [PATCH 1/3] test: add scanner update rate override for testing --- server/lib/scanners/baseScanner.ts | 16 ++++++++++++++-- server/lib/scanners/jellyfin/jellyfin.test.ts | 2 ++ server/lib/scanners/radarr/radarr.test.ts | 1 + server/lib/scanners/sonarr/sonarr.test.ts | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 2547b7a1d8..4cfd278230 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -59,8 +59,8 @@ export interface ProcessableSeason { } class BaseScanner { - private bundleSize; - private updateRate; + protected bundleSize; + protected updateRate; protected progress = 0; protected items: T[] = []; protected totalSize?: number = 0; @@ -792,6 +792,18 @@ class BaseScanner { get protectedBundleSize(): number { return this.bundleSize; } + + /** + * Test-only override to reduce scan delay in unit tests. + * Sets updateRate and/or bundleSize for the next run. + */ + public setTestOverrides(opts: { + updateRate?: number; + bundleSize?: number; + }): void { + if (opts.updateRate !== undefined) this.updateRate = opts.updateRate; + if (opts.bundleSize !== undefined) this.bundleSize = opts.bundleSize; + } } export default BaseScanner; diff --git a/server/lib/scanners/jellyfin/jellyfin.test.ts b/server/lib/scanners/jellyfin/jellyfin.test.ts index 51f97d09a8..a0da51c959 100644 --- a/server/lib/scanners/jellyfin/jellyfin.test.ts +++ b/server/lib/scanners/jellyfin/jellyfin.test.ts @@ -223,6 +223,8 @@ function configureJellyfinWithLibrary( describe('Jellyfin Scanner', () => { beforeEach(async () => { + jellyfinFullScanner.setTestOverrides({ updateRate: 0 }); + getLibraryContentsImpl = async () => []; getItemDataImpl = async () => undefined; getSeasonsImpl = async () => []; diff --git a/server/lib/scanners/radarr/radarr.test.ts b/server/lib/scanners/radarr/radarr.test.ts index 7faeed97f1..e4c1fe63b2 100644 --- a/server/lib/scanners/radarr/radarr.test.ts +++ b/server/lib/scanners/radarr/radarr.test.ts @@ -75,6 +75,7 @@ function fakeRadarrMovie(overrides: Partial = {}): RadarrMovie { describe('Radarr Scanner', () => { beforeEach(() => { + radarrScanner.setTestOverrides({ updateRate: 0 }); getMoviesImpl = async () => []; }); diff --git a/server/lib/scanners/sonarr/sonarr.test.ts b/server/lib/scanners/sonarr/sonarr.test.ts index e012c979eb..e023c17405 100644 --- a/server/lib/scanners/sonarr/sonarr.test.ts +++ b/server/lib/scanners/sonarr/sonarr.test.ts @@ -163,6 +163,7 @@ function configureSonarr(overrides: Partial[] = [{}]): void { describe('Sonarr Scanner', () => { beforeEach(() => { + sonarrScanner.setTestOverrides({ updateRate: 0 }); getSeriesImpl = async () => []; getShowByTvdbIdImpl = async () => fakeTmdbShow(1); getTvShowImpl = async () => fakeTmdbShow(1); From 160f9319a787a88157cf981b0c939692cf869e03 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 15 Aug 2026 22:20:52 -0400 Subject: [PATCH 2/3] test: use mock timers instead of scanner test overrides Reverts the production change to baseScanner.ts and instead drives the scanner's update delay with node:test mock timers via a shared runWithMockTimers helper. Also reorders the sonarr scanner import after the getTvShow mock so the mock takes effect (the scanner's tmdb instance is created at module load, so the prototype mock was being shadowed by the instance field, causing real TMDB network calls). --- server/lib/scanners/baseScanner.ts | 16 +------- server/lib/scanners/jellyfin/jellyfin.test.ts | 9 ++--- server/lib/scanners/radarr/radarr.test.ts | 38 +++++++++---------- server/lib/scanners/sonarr/sonarr.test.ts | 33 ++++++++-------- server/test/runWithMockTimers.ts | 24 ++++++++++++ 5 files changed, 66 insertions(+), 54 deletions(-) create mode 100644 server/test/runWithMockTimers.ts diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 4cfd278230..2547b7a1d8 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -59,8 +59,8 @@ export interface ProcessableSeason { } class BaseScanner { - protected bundleSize; - protected updateRate; + private bundleSize; + private updateRate; protected progress = 0; protected items: T[] = []; protected totalSize?: number = 0; @@ -792,18 +792,6 @@ class BaseScanner { get protectedBundleSize(): number { return this.bundleSize; } - - /** - * Test-only override to reduce scan delay in unit tests. - * Sets updateRate and/or bundleSize for the next run. - */ - public setTestOverrides(opts: { - updateRate?: number; - bundleSize?: number; - }): void { - if (opts.updateRate !== undefined) this.updateRate = opts.updateRate; - if (opts.bundleSize !== undefined) this.bundleSize = opts.bundleSize; - } } export default BaseScanner; diff --git a/server/lib/scanners/jellyfin/jellyfin.test.ts b/server/lib/scanners/jellyfin/jellyfin.test.ts index a0da51c959..47c826637a 100644 --- a/server/lib/scanners/jellyfin/jellyfin.test.ts +++ b/server/lib/scanners/jellyfin/jellyfin.test.ts @@ -18,6 +18,7 @@ import { User } from '@server/entity/User'; import type { Library } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; +import { runWithMockTimers } from '@server/test/runWithMockTimers'; import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; @@ -223,8 +224,6 @@ function configureJellyfinWithLibrary( describe('Jellyfin Scanner', () => { beforeEach(async () => { - jellyfinFullScanner.setTestOverrides({ updateRate: 0 }); - getLibraryContentsImpl = async () => []; getItemDataImpl = async () => undefined; getSeasonsImpl = async () => []; @@ -335,7 +334,7 @@ describe('Jellyfin Scanner', () => { return []; }; - await jellyfinFullScanner.run(); + await runWithMockTimers(jellyfinFullScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 5000 }, @@ -413,7 +412,7 @@ describe('Jellyfin Scanner', () => { return []; }; - await jellyfinFullScanner.run(); + await runWithMockTimers(jellyfinFullScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 5001 }, @@ -490,7 +489,7 @@ describe('Jellyfin Scanner', () => { return []; }; - await jellyfinFullScanner.run(); + await runWithMockTimers(jellyfinFullScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 5002 }, diff --git a/server/lib/scanners/radarr/radarr.test.ts b/server/lib/scanners/radarr/radarr.test.ts index e4c1fe63b2..0f14c86de3 100644 --- a/server/lib/scanners/radarr/radarr.test.ts +++ b/server/lib/scanners/radarr/radarr.test.ts @@ -13,6 +13,7 @@ import { radarrScanner } from '@server/lib/scanners/radarr'; import type { RadarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; +import { runWithMockTimers } from '@server/test/runWithMockTimers'; import assert from 'node:assert/strict'; import { beforeEach, describe, it, mock } from 'node:test'; @@ -75,7 +76,6 @@ function fakeRadarrMovie(overrides: Partial = {}): RadarrMovie { describe('Radarr Scanner', () => { beforeEach(() => { - radarrScanner.setTestOverrides({ updateRate: 0 }); getMoviesImpl = async () => []; }); @@ -94,7 +94,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ monitored: false, hasFile: false }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 550 }, @@ -110,7 +110,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 777, monitored: false, hasFile: false }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const media = await mediaRepository.findOne({ where: { tmdbId: 777 }, @@ -132,7 +132,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 551, monitored: true, hasFile: true }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 551 }, @@ -154,7 +154,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 552, monitored: true, hasFile: false }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 552 }, @@ -176,7 +176,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 553, monitored: true, hasFile: false }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 553 }, @@ -198,7 +198,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 554, monitored: false, hasFile: true }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 554 }, @@ -224,7 +224,7 @@ describe('Radarr Scanner', () => { getMoviesImpl = async () => []; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 950 }, @@ -244,7 +244,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true }]); getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 1, id: 99 })]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 999 }, @@ -264,7 +264,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true }]); getMoviesImpl = async () => []; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 888 }, @@ -286,7 +286,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 700, monitored: true, hasFile: false }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 700 }, @@ -307,7 +307,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true }]); getMoviesImpl = async () => []; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 800 }, @@ -345,7 +345,7 @@ describe('Radarr Scanner', () => { return [fakeRadarrMovie({ tmdbId: 903, id: 11 })]; }; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updatedOrphan = await mediaRepository.findOneOrFail({ where: { tmdbId: 901 }, @@ -373,7 +373,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true, is4k: true }]); getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 1, id: 99 })]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 960 }, @@ -394,7 +394,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true, is4k: true }]); getMoviesImpl = async () => []; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 961 }, @@ -437,7 +437,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true }]); getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 1, id: 99 })]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updatedMedia = await mediaRepository.findOneOrFail({ where: { tmdbId: 1003596 }, @@ -485,7 +485,7 @@ describe('Radarr Scanner', () => { fakeRadarrMovie({ tmdbId: 700, monitored: true, hasFile: false }), ]; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updatedRequest = await requestRepository.findOneOrFail({ where: { id: request.id }, @@ -526,7 +526,7 @@ describe('Radarr Scanner', () => { configureRadarr([{ syncEnabled: true }]); getMoviesImpl = async () => []; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updatedMedia = await mediaRepository.findOneOrFail({ where: { tmdbId: 1234 }, @@ -622,7 +622,7 @@ describe('Radarr Scanner', () => { return [fakeRadarrMovie({ tmdbId: 2, id: 88 })]; }; - await radarrScanner.run(); + await runWithMockTimers(radarrScanner.run()); const updatedMedia = await mediaRepository.findOneOrFail({ where: { tmdbId: 1003598 }, diff --git a/server/lib/scanners/sonarr/sonarr.test.ts b/server/lib/scanners/sonarr/sonarr.test.ts index e023c17405..b226176c7b 100644 --- a/server/lib/scanners/sonarr/sonarr.test.ts +++ b/server/lib/scanners/sonarr/sonarr.test.ts @@ -15,10 +15,10 @@ import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; -import { sonarrScanner } from '@server/lib/scanners/sonarr'; import type { SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; +import { runWithMockTimers } from '@server/test/runWithMockTimers'; import assert from 'node:assert/strict'; import { beforeEach, describe, it, mock } from 'node:test'; @@ -103,6 +103,8 @@ Object.defineProperty(TheMovieDb.prototype, 'getTvShow', { configurable: true, }); +import { sonarrScanner } from '@server/lib/scanners/sonarr'; + mock.method(MediaRequest, 'sendNotification', async () => undefined); setupTestDb(); @@ -163,7 +165,6 @@ function configureSonarr(overrides: Partial[] = [{}]): void { describe('Sonarr Scanner', () => { beforeEach(() => { - sonarrScanner.setTestOverrides({ updateRate: 0 }); getSeriesImpl = async () => []; getShowByTvdbIdImpl = async () => fakeTmdbShow(1); getTvShowImpl = async () => fakeTmdbShow(1); @@ -194,7 +195,7 @@ describe('Sonarr Scanner', () => { getSeriesImpl = async () => []; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1050 }, @@ -224,7 +225,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })]; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1000 }, @@ -254,7 +255,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => []; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1001 }, @@ -304,7 +305,7 @@ describe('Sonarr Scanner', () => { getShowByTvdbIdImpl = async () => fakeTmdbShow(1); getTvShowImpl = async () => fakeTmdbShow(1); - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1 }, @@ -338,7 +339,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })]; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1003 }, @@ -364,7 +365,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => []; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1004 }, @@ -413,7 +414,7 @@ describe('Sonarr Scanner', () => { getShowByTvdbIdImpl = async () => fakeTmdbShow(2); getTvShowImpl = async () => fakeTmdbShow(2); - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updatedOrphan = await mediaRepository.findOneOrFail({ where: { tmdbId: 1010 }, @@ -441,7 +442,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })]; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1020 }, @@ -472,7 +473,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true, is4k: true }]); getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })]; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1030 }, @@ -508,7 +509,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true, is4k: true }]); getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })]; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1031 }, @@ -563,7 +564,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })]; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updatedMedia = await mediaRepository.findOneOrFail({ where: { tmdbId: 2000 }, @@ -637,7 +638,7 @@ describe('Sonarr Scanner', () => { getShowByTvdbIdImpl = async () => fakeTmdbShow(2001); getTvShowImpl = async () => fakeTmdbShow(2001); - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updatedRequest = await requestRepository.findOneOrFail({ where: { id: request.id }, @@ -686,7 +687,7 @@ describe('Sonarr Scanner', () => { configureSonarr([{ syncEnabled: true }]); getSeriesImpl = async () => []; - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updatedMedia = await mediaRepository.findOneOrFail({ where: { tmdbId: 2005 }, @@ -805,7 +806,7 @@ describe('Sonarr Scanner', () => { tvdbId === 666 ? fakeTmdbShow(2002) : fakeTmdbShow(997); getTvShowImpl = async ({ tvId }) => fakeTmdbShow(tvId); - await sonarrScanner.run(); + await runWithMockTimers(sonarrScanner.run()); const updatedMedia = await mediaRepository.findOneOrFail({ where: { tmdbId: 2002 }, diff --git a/server/test/runWithMockTimers.ts b/server/test/runWithMockTimers.ts new file mode 100644 index 0000000000..6cf8f8cfb9 --- /dev/null +++ b/server/test/runWithMockTimers.ts @@ -0,0 +1,24 @@ +import { mock } from 'node:test'; + +export async function runWithMockTimers( + runPromise: Promise, + tickMs = 4000 +): Promise { + mock.timers.enable({ apis: ['setTimeout'] }); + try { + let settled = false; + runPromise + .catch(() => undefined) + .finally(() => { + settled = true; + }); + let guard = 0; + while (!settled && guard++ < 100000) { + await new Promise((resolve) => setImmediate(resolve)); + mock.timers.tick(tickMs); + } + return await runPromise; + } finally { + mock.timers.reset(); + } +} From 2e98009934d2264bd1b1afa976d351a9f271cecb Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 18 Aug 2026 21:15:32 -0400 Subject: [PATCH 3/3] fix(runWithMockTimers): fail when timer guard is exhausted Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/test/runWithMockTimers.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/test/runWithMockTimers.ts b/server/test/runWithMockTimers.ts index 6cf8f8cfb9..2da3da8767 100644 --- a/server/test/runWithMockTimers.ts +++ b/server/test/runWithMockTimers.ts @@ -12,11 +12,16 @@ export async function runWithMockTimers( .finally(() => { settled = true; }); - let guard = 0; - while (!settled && guard++ < 100000) { + const maxTicks = 100000; + for (let i = 0; i < maxTicks && !settled; i++) { await new Promise((resolve) => setImmediate(resolve)); mock.timers.tick(tickMs); } + if (!settled) { + throw new Error( + `runWithMockTimers: promise did not settle after ${maxTicks} ticks` + ); + } return await runPromise; } finally { mock.timers.reset();