Files
profilarr/tests/unit/rename/processor.test.ts
2026-03-08 20:46:01 +10:30

941 lines
30 KiB
TypeScript

/**
* Tests for rename processor
* Uses hardcoded data from Bruno API testing against real Radarr/Sonarr instances.
* Tests the processing logic (dry run, live run, error handling) without a DB or API server.
*/
import { BaseTest } from '../base/BaseTest.ts';
import { assertEquals } from '@std/assert';
import type { ArrCommand, ArrTag, RenamePreviewItem } from '$lib/server/utils/arr/types.ts';
import type { RenameSettings } from '$db/queries/arrRenameSettings.ts';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
import type { LibrarySnapshot } from '$lib/server/rename/types.ts';
import {
parseRenamedCount,
groupByRootFolder,
createLog,
processDryRun,
processFileRename,
processFolderRename,
diffSnapshots
} from '$lib/server/rename/core.ts';
import type { RenameAdapter, LibraryItem } from '$lib/server/rename/core.ts';
// =========================================================================
// Test fixtures — hardcoded from Bruno API testing
// =========================================================================
/** Radarr movie 1422 "28 Years Later" */
const RADARR_MOVIE: LibraryItem = {
id: 1422,
title: '28 Years Later',
tags: [],
rootFolderPath: '/data/media/movies'
};
/** Sonarr series 77 "A Knight of the Seven Kingdoms" */
const SONARR_SERIES: LibraryItem = {
id: 77,
title: 'A Knight of the Seven Kingdoms',
tags: [],
rootFolderPath: '/data/media/tv'
};
/** Tagged item that should be filtered out */
const TAGGED_MOVIE: LibraryItem = {
id: 999,
title: 'Tagged Movie',
tags: [1],
rootFolderPath: '/data/media/movies'
};
/** Item in a different root folder */
const ALT_ROOT_MOVIE: LibraryItem = {
id: 500,
title: 'Alt Root Movie',
tags: [],
rootFolderPath: '/data/media/movies-4k'
};
const TAGS: ArrTag[] = [{ id: 1, label: 'profilarr-ignore' }];
/** Radarr rename preview — 1 file needs renaming */
const RADARR_PREVIEW: RenamePreviewItem[] = [
{
movieId: 1422,
movieFileId: 1399,
existingPath: '28 Years Later (2025)/28 Years Later (2025).mkv',
newPath: '28 Years Later (2025)/28 Years Latera (2025).mkv'
}
];
/** Sonarr rename preview — 6 episodes need renaming */
const SONARR_PREVIEW: RenamePreviewItem[] = [
{
seriesId: 77,
seasonNumber: 1,
episodeNumbers: [1],
episodeFileId: 1001,
existingPath: 'Season 01/A Knight of the Seven Kingdoms - S01E01 - The Hedge Knight.mkv',
newPath: 'Season 01a/A Knight of the Seven Kingdomsa - S01E01 - The Hedge Knight.mkv'
},
{
seriesId: 77,
seasonNumber: 1,
episodeNumbers: [2],
episodeFileId: 1002,
existingPath: 'Season 01/A Knight of the Seven Kingdoms - S01E02 - The Plea.mkv',
newPath: 'Season 01a/A Knight of the Seven Kingdomsa - S01E02 - The Plea.mkv'
},
{
seriesId: 77,
seasonNumber: 1,
episodeNumbers: [3],
episodeFileId: 1003,
existingPath: 'Season 01/A Knight of the Seven Kingdoms - S01E03 - The Pupil.mkv',
newPath: 'Season 01a/A Knight of the Seven Kingdomsa - S01E03 - The Pupil.mkv'
},
{
seriesId: 77,
seasonNumber: 1,
episodeNumbers: [4],
episodeFileId: 1004,
existingPath: 'Season 01/A Knight of the Seven Kingdoms - S01E04 - The Trial.mkv',
newPath: 'Season 01a/A Knight of the Seven Kingdomsa - S01E04 - The Trial.mkv'
},
{
seriesId: 77,
seasonNumber: 1,
episodeNumbers: [5],
episodeFileId: 1005,
existingPath: 'Season 01/A Knight of the Seven Kingdoms - S01E05 - The Tourney.mkv',
newPath: 'Season 01a/A Knight of the Seven Kingdomsa - S01E05 - The Tourney.mkv'
},
{
seriesId: 77,
seasonNumber: 1,
episodeNumbers: [6],
episodeFileId: 1006,
existingPath: 'Season 01/A Knight of the Seven Kingdoms - S01E06 - The Mystery Knight.mkv',
newPath: 'Season 01a/A Knight of the Seven Kingdomsa - S01E06 - The Mystery Knight.mkv'
}
];
/** Completed rename command for Radarr (1 file renamed) */
const RADARR_COMMAND_RESULT: ArrCommand = {
id: 12345,
name: 'RenameMovie',
commandName: 'RenameMovie',
status: 'completed',
message: '1 movie files renamed for 28 Years Later'
};
/** Completed rename command for Sonarr (6 files renamed) */
const SONARR_COMMAND_RESULT: ArrCommand = {
id: 67890,
name: 'RenameSeries',
commandName: 'RenameSeries',
status: 'completed',
message: '6 episode files renamed for A Knight of the Seven Kingdoms'
};
/** No-op command (already renamed) */
const NOOP_COMMAND_RESULT: ArrCommand = {
id: 11111,
name: 'RenameMovie',
commandName: 'RenameMovie',
status: 'completed',
message: '0 movie files renamed'
};
// =========================================================================
// Mock settings and instance
// =========================================================================
function makeSettings(overrides: Partial<RenameSettings> = {}): RenameSettings {
return {
id: 1,
arrInstanceId: 1,
renameFolders: false,
ignoreTag: null,
summaryNotifications: false,
enabled: true,
cron: '0 0 * * *',
nextRunAt: null,
lastRunAt: null,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
...overrides
};
}
function makeInstance(overrides: Partial<ArrInstance> = {}): ArrInstance {
return {
id: 1,
name: 'Test Radarr',
type: 'radarr',
url: 'http://localhost:7878',
api_key: 'test-key',
tags: null,
enabled: 1,
library_refresh_interval: 60,
library_last_refreshed_at: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides
};
}
// =========================================================================
// Mock adapter factory
// =========================================================================
interface MockAdapterOptions {
items?: LibraryItem[];
tags?: ArrTag[];
previews?: Map<number, RenamePreviewItem[]>;
renameResult?: ArrCommand;
waitResult?: ArrCommand;
renameThrows?: Error;
folderRenameThrows?: Error;
previewThrows?: Map<number, Error>;
beforeSnapshot?: LibrarySnapshot;
afterSnapshot?: LibrarySnapshot;
}
function createMockAdapter(opts: MockAdapterOptions = {}): RenameAdapter & {
_folderRenameCalls: { ids: number[]; rootFolderPath: string }[];
} {
const folderRenameCalls: { ids: number[]; rootFolderPath: string }[] = [];
let snapshotCallCount = 0;
return {
getLibraryItems: () => Promise.resolve(opts.items ?? []),
getTags: () => Promise.resolve(opts.tags ?? TAGS),
getRenamePreview: (id: number) => {
if (opts.previewThrows?.has(id)) {
return Promise.reject(opts.previewThrows.get(id));
}
return Promise.resolve(opts.previews?.get(id) ?? []);
},
renameFiles: (_ids: number[]) => {
if (opts.renameThrows) return Promise.reject(opts.renameThrows);
return Promise.resolve(
opts.renameResult ?? { id: 1, name: 'Rename', commandName: 'Rename', status: 'queued' }
);
},
renameFolders: (ids: number[], rootFolderPath: string) => {
folderRenameCalls.push({ ids, rootFolderPath });
if (opts.folderRenameThrows) return Promise.reject(opts.folderRenameThrows);
return Promise.resolve();
},
waitForCommand: (_id: number) => {
return Promise.resolve(opts.waitResult ?? opts.renameResult ?? NOOP_COMMAND_RESULT);
},
getSnapshot: (_entityIds: number[]) => {
snapshotCallCount++;
if (snapshotCallCount === 1) {
return Promise.resolve(opts.beforeSnapshot ?? new Map());
}
return Promise.resolve(opts.afterSnapshot ?? new Map());
},
_folderRenameCalls: folderRenameCalls
};
}
// =========================================================================
// Tests
// =========================================================================
class RenameProcessorTest extends BaseTest {
// ---------------------------------------------------------------------------
// parseRenamedCount
// ---------------------------------------------------------------------------
testParseRenamedCount(): void {
this.test('parseRenamedCount: extracts count from Radarr message', () => {
assertEquals(parseRenamedCount('1 movie files renamed for 28 Years Later'), 1);
});
this.test('parseRenamedCount: extracts count from Sonarr message', () => {
assertEquals(
parseRenamedCount('6 episode files renamed for A Knight of the Seven Kingdoms'),
6
);
});
this.test('parseRenamedCount: returns 0 for no-op', () => {
assertEquals(parseRenamedCount('0 movie files renamed'), 0);
});
this.test('parseRenamedCount: returns 0 for undefined', () => {
assertEquals(parseRenamedCount(undefined), 0);
});
this.test('parseRenamedCount: returns 0 for non-numeric message', () => {
assertEquals(parseRenamedCount('no numbers here'), 0);
});
}
// ---------------------------------------------------------------------------
// groupByRootFolder
// ---------------------------------------------------------------------------
testGroupByRootFolder(): void {
this.test('groupByRootFolder: single root folder', () => {
const items: LibraryItem[] = [RADARR_MOVIE, { ...TAGGED_MOVIE, tags: [] }];
const groups = groupByRootFolder(items);
assertEquals(groups.size, 1);
assertEquals(groups.get('/data/media/movies')?.length, 2);
assertEquals(groups.get('/data/media/movies')?.includes(1422), true);
assertEquals(groups.get('/data/media/movies')?.includes(999), true);
});
this.test('groupByRootFolder: multiple root folders', () => {
const items: LibraryItem[] = [RADARR_MOVIE, ALT_ROOT_MOVIE];
const groups = groupByRootFolder(items);
assertEquals(groups.size, 2);
assertEquals(groups.get('/data/media/movies'), [1422]);
assertEquals(groups.get('/data/media/movies-4k'), [500]);
});
this.test('groupByRootFolder: empty list', () => {
const groups = groupByRootFolder([]);
assertEquals(groups.size, 0);
});
}
// ---------------------------------------------------------------------------
// createLog
// ---------------------------------------------------------------------------
testCreateLog(): void {
this.test('createLog: creates correct log structure', () => {
const instance = makeInstance();
const settings = makeSettings({ renameFolders: true, ignoreTag: 'profilarr-ignore' });
const log = createLog(instance, settings, true, false);
assertEquals(log.instanceId, 1);
assertEquals(log.instanceName, 'Test Radarr');
assertEquals(log.instanceType, 'radarr');
assertEquals(log.status, 'success');
assertEquals(log.config.dryRun, false);
assertEquals(log.config.renameFolders, true);
assertEquals(log.config.ignoreTag, 'profilarr-ignore');
assertEquals(log.config.manual, true);
assertEquals(log.results.filesRenamed, 0);
assertEquals(log.results.errors.length, 0);
assertEquals(log.renamedItems.length, 0);
assertEquals(typeof log.id, 'string');
assertEquals(log.id.length > 0, true);
});
}
// ---------------------------------------------------------------------------
// Dry run — Radarr
// ---------------------------------------------------------------------------
testDryRunRadarr(): void {
this.test('dry run: Radarr movie with 1 file needing rename', async () => {
const previews = new Map<number, RenamePreviewItem[]>();
previews.set(1422, RADARR_PREVIEW);
const adapter = createMockAdapter({ previews });
const log = createLog(makeInstance(), makeSettings(), false, true);
await processDryRun(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.filesNeedingRename, 1);
assertEquals(log.results.filesRenamed, 0);
assertEquals(log.renamedItems.length, 1);
assertEquals(log.renamedItems[0].id, 1422);
assertEquals(log.renamedItems[0].title, '28 Years Later');
assertEquals(log.renamedItems[0].files.length, 1);
assertEquals(
log.renamedItems[0].files[0].existingPath,
'28 Years Later (2025)/28 Years Later (2025).mkv'
);
});
this.test('dry run: Radarr movie with no files needing rename', async () => {
const adapter = createMockAdapter({}); // No previews = nothing to rename
const log = createLog(makeInstance(), makeSettings(), false, true);
await processDryRun(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.filesNeedingRename, 0);
assertEquals(log.renamedItems.length, 0);
});
}
// ---------------------------------------------------------------------------
// Dry run — Sonarr
// ---------------------------------------------------------------------------
testDryRunSonarr(): void {
this.test('dry run: Sonarr series with 6 episodes needing rename', async () => {
const previews = new Map<number, RenamePreviewItem[]>();
previews.set(77, SONARR_PREVIEW);
const adapter = createMockAdapter({ previews });
const instance = makeInstance({ name: 'Test Sonarr', type: 'sonarr' });
const log = createLog(instance, makeSettings(), false, true);
await processDryRun(adapter, [SONARR_SERIES], log);
assertEquals(log.results.filesNeedingRename, 6);
assertEquals(log.renamedItems.length, 1);
assertEquals(log.renamedItems[0].id, 77);
assertEquals(log.renamedItems[0].title, 'A Knight of the Seven Kingdoms');
assertEquals(log.renamedItems[0].files.length, 6);
});
}
// ---------------------------------------------------------------------------
// Dry run — preview error
// ---------------------------------------------------------------------------
testDryRunPreviewError(): void {
this.test('dry run: preview error for one item, other items still processed', async () => {
const previews = new Map<number, RenamePreviewItem[]>();
previews.set(1422, RADARR_PREVIEW);
const previewThrows = new Map<number, Error>();
previewThrows.set(500, new Error('Connection refused'));
const adapter = createMockAdapter({ previews, previewThrows });
const log = createLog(makeInstance(), makeSettings(), false, true);
await processDryRun(adapter, [RADARR_MOVIE, ALT_ROOT_MOVIE], log);
assertEquals(log.results.filesNeedingRename, 1);
assertEquals(log.renamedItems.length, 1);
assertEquals(log.results.errors.length, 1);
assertEquals(log.results.errors[0].includes('Alt Root Movie'), true);
assertEquals(log.results.errors[0].includes('Connection refused'), true);
});
}
// ---------------------------------------------------------------------------
// Dry run — empty items list
// ---------------------------------------------------------------------------
testDryRunEmpty(): void {
this.test('dry run: empty items list produces zero counts', async () => {
const adapter = createMockAdapter();
const log = createLog(makeInstance(), makeSettings(), false, true);
await processDryRun(adapter, [], log);
assertEquals(log.results.filesNeedingRename, 0);
assertEquals(log.renamedItems.length, 0);
assertEquals(log.results.errors.length, 0);
});
}
// ---------------------------------------------------------------------------
// File rename — Radarr
// ---------------------------------------------------------------------------
testFileRenameRadarr(): void {
this.test('file rename: Radarr renames 1 file', async () => {
const adapter = createMockAdapter({
renameResult: {
id: 12345,
name: 'RenameMovie',
commandName: 'RenameMovie',
status: 'queued'
},
waitResult: RADARR_COMMAND_RESULT
});
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFileRename(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.filesRenamed, 1);
assertEquals(log.results.commandsTriggered, 1);
assertEquals(log.results.commandsCompleted, 1);
assertEquals(log.results.commandsFailed, 0);
assertEquals(log.results.errors.length, 0);
});
}
// ---------------------------------------------------------------------------
// File rename — Sonarr
// ---------------------------------------------------------------------------
testFileRenameSonarr(): void {
this.test('file rename: Sonarr renames 6 episode files', async () => {
const adapter = createMockAdapter({
renameResult: {
id: 67890,
name: 'RenameSeries',
commandName: 'RenameSeries',
status: 'queued'
},
waitResult: SONARR_COMMAND_RESULT
});
const instance = makeInstance({ name: 'Test Sonarr', type: 'sonarr' });
const log = createLog(instance, makeSettings(), false, false);
await processFileRename(adapter, [SONARR_SERIES], log);
assertEquals(log.results.filesRenamed, 6);
assertEquals(log.results.commandsTriggered, 1);
assertEquals(log.results.commandsCompleted, 1);
});
}
// ---------------------------------------------------------------------------
// File rename — no-op (already renamed)
// ---------------------------------------------------------------------------
testFileRenameNoop(): void {
this.test('file rename: no-op when files already match naming', async () => {
const adapter = createMockAdapter({
renameResult: {
id: 11111,
name: 'RenameMovie',
commandName: 'RenameMovie',
status: 'queued'
},
waitResult: NOOP_COMMAND_RESULT
});
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFileRename(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.filesRenamed, 0);
assertEquals(log.results.commandsTriggered, 1);
assertEquals(log.results.commandsCompleted, 1);
assertEquals(log.results.errors.length, 0);
});
}
// ---------------------------------------------------------------------------
// File rename — error
// ---------------------------------------------------------------------------
testFileRenameError(): void {
this.test('file rename: command fails', async () => {
const adapter = createMockAdapter({
renameThrows: new Error('API connection timeout')
});
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFileRename(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.commandsFailed, 1);
assertEquals(log.results.filesRenamed, 0);
assertEquals(log.results.errors.length, 1);
assertEquals(log.results.errors[0].includes('API connection timeout'), true);
});
}
// ---------------------------------------------------------------------------
// Folder rename — single root
// ---------------------------------------------------------------------------
testFolderRenameSingleRoot(): void {
this.test('folder rename: single root folder', async () => {
const adapter = createMockAdapter();
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFolderRename(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.commandsTriggered, 1);
assertEquals(log.results.commandsCompleted, 1);
assertEquals(adapter._folderRenameCalls.length, 1);
assertEquals(adapter._folderRenameCalls[0].ids, [1422]);
assertEquals(adapter._folderRenameCalls[0].rootFolderPath, '/data/media/movies');
});
}
// ---------------------------------------------------------------------------
// Folder rename — multiple roots
// ---------------------------------------------------------------------------
testFolderRenameMultipleRoots(): void {
this.test('folder rename: groups by root folder path', async () => {
const adapter = createMockAdapter();
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFolderRename(adapter, [RADARR_MOVIE, ALT_ROOT_MOVIE], log);
assertEquals(adapter._folderRenameCalls.length, 2);
const movieCall = adapter._folderRenameCalls.find(
(c) => c.rootFolderPath === '/data/media/movies'
);
const altCall = adapter._folderRenameCalls.find(
(c) => c.rootFolderPath === '/data/media/movies-4k'
);
assertEquals(movieCall?.ids, [1422]);
assertEquals(altCall?.ids, [500]);
assertEquals(log.results.commandsTriggered, 2);
});
}
// ---------------------------------------------------------------------------
// Folder rename — error
// ---------------------------------------------------------------------------
testFolderRenameError(): void {
this.test('folder rename: fails with error', async () => {
const adapter = createMockAdapter({
folderRenameThrows: new Error('Permission denied')
});
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFolderRename(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.commandsFailed, 1);
assertEquals(log.results.errors.length, 1);
assertEquals(log.results.errors[0].includes('Permission denied'), true);
assertEquals(log.results.errors[0].includes('/data/media/movies'), true);
});
}
// ---------------------------------------------------------------------------
// diffSnapshots
// ---------------------------------------------------------------------------
testDiffSnapshotsNoChanges(): void {
this.test('diffSnapshots: no changes returns empty array', () => {
const snapshot: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: [{ id: 1399, relativePath: '28 Years Later (2025).mkv' }]
}
]
]);
const diff = diffSnapshots(snapshot, snapshot);
assertEquals(diff.length, 0);
});
}
testDiffSnapshotsFileRename(): void {
this.test('diffSnapshots: detects file rename', () => {
const before: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: [{ id: 1399, relativePath: '28 Years Latera (2025).mkv' }]
}
]
]);
const after: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: [{ id: 1399, relativePath: '28 Years Later (2025).mkv' }]
}
]
]);
const diff = diffSnapshots(before, after);
assertEquals(diff.length, 1);
assertEquals(diff[0].id, 1422);
assertEquals(diff[0].folder, undefined);
assertEquals(diff[0].files.length, 1);
assertEquals(diff[0].files[0].oldPath, '28 Years Latera (2025).mkv');
assertEquals(diff[0].files[0].newPath, '28 Years Later (2025).mkv');
});
}
testDiffSnapshotsFolderRename(): void {
this.test('diffSnapshots: detects folder rename', () => {
const before: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Latera (2025)',
files: [{ id: 1399, relativePath: '28 Years Later (2025).mkv' }]
}
]
]);
const after: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: [{ id: 1399, relativePath: '28 Years Later (2025).mkv' }]
}
]
]);
const diff = diffSnapshots(before, after);
assertEquals(diff.length, 1);
assertEquals(diff[0].folder?.oldPath, '/data/media/movies/28 Years Latera (2025)');
assertEquals(diff[0].folder?.newPath, '/data/media/movies/28 Years Later (2025)');
assertEquals(diff[0].files.length, 0);
});
}
testDiffSnapshotsBothFileAndFolder(): void {
this.test('diffSnapshots: detects both file and folder rename', () => {
const before: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Latera (2025)',
files: [{ id: 1399, relativePath: '28 Years Latera (2025).mkv' }]
}
]
]);
const after: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: [{ id: 1399, relativePath: '28 Years Later (2025).mkv' }]
}
]
]);
const diff = diffSnapshots(before, after);
assertEquals(diff.length, 1);
assertEquals(diff[0].folder !== undefined, true);
assertEquals(diff[0].files.length, 1);
});
}
testDiffSnapshotsMixedChangedUnchanged(): void {
this.test('diffSnapshots: ignores unchanged entities', () => {
const before: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Latera (2025)',
files: [{ id: 1399, relativePath: '28 Years Latera (2025).mkv' }]
}
],
[
500,
{
id: 500,
title: 'Alt Root Movie',
folderPath: '/data/media/movies-4k/Alt Root Movie (2024)',
files: [{ id: 501, relativePath: 'Alt Root Movie (2024).mkv' }]
}
]
]);
const after: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: [{ id: 1399, relativePath: '28 Years Later (2025).mkv' }]
}
],
[
500,
{
id: 500,
title: 'Alt Root Movie',
folderPath: '/data/media/movies-4k/Alt Root Movie (2024)',
files: [{ id: 501, relativePath: 'Alt Root Movie (2024).mkv' }]
}
]
]);
const diff = diffSnapshots(before, after);
assertEquals(diff.length, 1);
assertEquals(diff[0].id, 1422);
});
}
testDiffSnapshotsEntityNoFiles(): void {
this.test('diffSnapshots: handles entity with no files (folder only)', () => {
const before: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Latera (2025)',
files: []
}
]
]);
const after: LibrarySnapshot = new Map([
[
1422,
{
id: 1422,
title: '28 Years Later',
folderPath: '/data/media/movies/28 Years Later (2025)',
files: []
}
]
]);
const diff = diffSnapshots(before, after);
assertEquals(diff.length, 1);
assertEquals(diff[0].folder !== undefined, true);
assertEquals(diff[0].files.length, 0);
});
}
testDiffSnapshotsSonarrMultipleFiles(): void {
this.test('diffSnapshots: Sonarr series with 6 episode file renames', () => {
const before: LibrarySnapshot = new Map([
[
77,
{
id: 77,
title: 'A Knight of the Seven Kingdoms',
folderPath: '/data/media/tv/A Knight of the Seven Kingdoms',
files: [
{ id: 1001, relativePath: 'Season 01/S01E01 - The Hedge Knight.mkv' },
{ id: 1002, relativePath: 'Season 01/S01E02 - The Plea.mkv' },
{ id: 1003, relativePath: 'Season 01/S01E03 - The Pupil.mkv' },
{ id: 1004, relativePath: 'Season 01/S01E04 - The Trial.mkv' },
{ id: 1005, relativePath: 'Season 01/S01E05 - The Tourney.mkv' },
{ id: 1006, relativePath: 'Season 01/S01E06 - The Mystery Knight.mkv' }
]
}
]
]);
const after: LibrarySnapshot = new Map([
[
77,
{
id: 77,
title: 'A Knight of the Seven Kingdoms',
folderPath: '/data/media/tv/A Knight of the Seven Kingdoms',
files: [
{ id: 1001, relativePath: 'Season 01a/S01E01 - The Hedge Knighta.mkv' },
{ id: 1002, relativePath: 'Season 01a/S01E02 - The Pleaa.mkv' },
{ id: 1003, relativePath: 'Season 01a/S01E03 - The Pupila.mkv' },
{ id: 1004, relativePath: 'Season 01a/S01E04 - The Triala.mkv' },
{ id: 1005, relativePath: 'Season 01a/S01E05 - The Tourneya.mkv' },
{ id: 1006, relativePath: 'Season 01a/S01E06 - The Mystery Knighta.mkv' }
]
}
]
]);
const diff = diffSnapshots(before, after);
assertEquals(diff.length, 1);
assertEquals(diff[0].id, 77);
assertEquals(diff[0].files.length, 6);
assertEquals(diff[0].folder, undefined);
});
}
// ---------------------------------------------------------------------------
// Combined — file + folder rename
// ---------------------------------------------------------------------------
testCombinedFileAndFolderRename(): void {
this.test('combined: file rename then folder rename', async () => {
const adapter = createMockAdapter({
renameResult: {
id: 12345,
name: 'RenameMovie',
commandName: 'RenameMovie',
status: 'queued'
},
waitResult: RADARR_COMMAND_RESULT
});
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFileRename(adapter, [RADARR_MOVIE, ALT_ROOT_MOVIE], log);
await processFolderRename(adapter, [RADARR_MOVIE, ALT_ROOT_MOVIE], log);
// File rename
assertEquals(log.results.filesRenamed, 1);
assertEquals(log.results.commandsCompleted, 3); // 1 file + 2 folder
assertEquals(log.results.commandsTriggered, 3);
assertEquals(log.results.errors.length, 0);
});
}
// ---------------------------------------------------------------------------
// Combined — file fails, folder still runs
// ---------------------------------------------------------------------------
testCombinedFileFailsFolderSucceeds(): void {
this.test('combined: file rename fails, folder rename still succeeds', async () => {
const adapter = createMockAdapter({
renameThrows: new Error('API timeout')
});
const log = createLog(makeInstance(), makeSettings(), false, false);
await processFileRename(adapter, [RADARR_MOVIE], log);
await processFolderRename(adapter, [RADARR_MOVIE], log);
assertEquals(log.results.filesRenamed, 0);
assertEquals(log.results.commandsFailed, 1);
assertEquals(log.results.commandsCompleted, 1);
assertEquals(log.results.errors.length, 1);
});
}
// ---------------------------------------------------------------------------
// Run all tests
// ---------------------------------------------------------------------------
runTests(): void {
this.testParseRenamedCount();
this.testGroupByRootFolder();
this.testCreateLog();
this.testDryRunRadarr();
this.testDryRunSonarr();
this.testDryRunPreviewError();
this.testDryRunEmpty();
this.testFileRenameRadarr();
this.testFileRenameSonarr();
this.testFileRenameNoop();
this.testFileRenameError();
this.testFolderRenameSingleRoot();
this.testFolderRenameMultipleRoots();
this.testFolderRenameError();
this.testDiffSnapshotsNoChanges();
this.testDiffSnapshotsFileRename();
this.testDiffSnapshotsFolderRename();
this.testDiffSnapshotsBothFileAndFolder();
this.testDiffSnapshotsMixedChangedUnchanged();
this.testDiffSnapshotsEntityNoFiles();
this.testDiffSnapshotsSonarrMultipleFiles();
this.testCombinedFileAndFolderRename();
this.testCombinedFileFailsFolderSucceeds();
}
}
const test = new RenameProcessorTest();
await test.runTests();