4a5dc70548
Docs Check / Markdown lint (push) Failing after 46s
Build and Push Docker Image / build (push) Successful in 1m4s
CI / Security audit (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Mermaid diagram parse check (push) Successful in 1m59s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m8s
205 lines
6.3 KiB
JavaScript
205 lines
6.3 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
//
|
|
// Integration tests for the torrent matcher's hash-first matching and
|
|
// arrQueueId deduplication paths (Issue #65). These exercise `matchTorrents`
|
|
// end-to-end against minimal but realistic queue/history record fixtures.
|
|
import { describe, it, expect } from 'vitest';
|
|
import { createRequire } from 'module';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const DownloadMatcher = require('../../server/services/DownloadMatcher');
|
|
|
|
// Build a minimal context. `showAll: true` bypasses per-user tag filtering so
|
|
// these tests can assert matching behaviour without setting up the Emby user
|
|
// tag plumbing.
|
|
function makeContext({
|
|
sonarrQueueRecords = [],
|
|
sonarrHistoryRecords = [],
|
|
radarrQueueRecords = [],
|
|
radarrHistoryRecords = []
|
|
} = {}) {
|
|
const seriesMap = new Map();
|
|
const moviesMap = new Map();
|
|
for (const r of sonarrQueueRecords.concat(sonarrHistoryRecords)) {
|
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
for (const r of radarrQueueRecords.concat(radarrHistoryRecords)) {
|
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
return {
|
|
sonarrQueueRecords,
|
|
sonarrHistoryRecords,
|
|
radarrQueueRecords,
|
|
radarrHistoryRecords,
|
|
seriesMap,
|
|
moviesMap,
|
|
// null tagMaps so extractAllTags uses the object `label` shape from the
|
|
// Sonarr fixture (series.tags = [{ label: '...' }]). An empty Map is
|
|
// truthy and would cause every id-lookup to return undefined.
|
|
sonarrTagMap: null,
|
|
radarrTagMap: null,
|
|
username: 'tester',
|
|
isAdmin: false,
|
|
// showAll bypasses per-user tag filtering — we only need it to be truthy
|
|
// *together* with non-empty allTags. We seed series/movie tags as non-empty
|
|
// strings (Sonarr tag shape) so `extractAllTags` yields entries.
|
|
showAll: true,
|
|
embyUserMap: new Map(),
|
|
ombiBaseUrl: null
|
|
};
|
|
}
|
|
|
|
const seriesShowA = {
|
|
id: 100,
|
|
title: 'Show A',
|
|
tags: [{ label: 'tester' }],
|
|
images: []
|
|
};
|
|
|
|
const movieFilmB = {
|
|
id: 200,
|
|
title: 'Film B',
|
|
tags: [{ label: 'tester' }],
|
|
images: []
|
|
};
|
|
|
|
describe('matchTorrents — hash-first matching (#65)', () => {
|
|
it('matches a torrent to a Sonarr queue record by hash even when the title differs', async () => {
|
|
const hash = 'ABC123HASHsonarr';
|
|
const context = makeContext({
|
|
sonarrQueueRecords: [
|
|
{
|
|
id: 9001,
|
|
// Title intentionally bears no resemblance to the torrent name to
|
|
// prove the match is via hash (downloadId), not title fallback.
|
|
title: 'totally.unrelated.queue.record',
|
|
sourceTitle: '',
|
|
seriesId: 100,
|
|
series: seriesShowA,
|
|
downloadId: hash,
|
|
episodeId: 555
|
|
}
|
|
]
|
|
});
|
|
|
|
const torrents = [
|
|
{
|
|
hash,
|
|
name: 'Show.A.S01.2160p.WEB.x265-RELEASE',
|
|
progress: 0.5,
|
|
dlspeed: 1000
|
|
}
|
|
];
|
|
|
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].arrType).toBe('sonarr');
|
|
expect(out[0].arrQueueId).toBe(9001);
|
|
});
|
|
|
|
it('matches a Transmission torrent via hashString', async () => {
|
|
const hashString = 'TRANSMISSIONHASH456';
|
|
const context = makeContext({
|
|
radarrQueueRecords: [
|
|
{
|
|
id: 7777,
|
|
title: 'unrelated',
|
|
sourceTitle: '',
|
|
movieId: 200,
|
|
movie: movieFilmB,
|
|
downloadId: hashString
|
|
}
|
|
]
|
|
});
|
|
|
|
const torrents = [
|
|
{
|
|
hashString,
|
|
name: 'Film.B.2160p.WEB.x265-RELEASE',
|
|
progress: 0.25,
|
|
dlspeed: 0
|
|
}
|
|
];
|
|
|
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].arrType).toBe('radarr');
|
|
expect(out[0].arrQueueId).toBe(7777);
|
|
});
|
|
|
|
it('falls back to title-substring matching when no hash is present on the torrent', async () => {
|
|
const context = makeContext({
|
|
sonarrQueueRecords: [
|
|
{
|
|
id: 555,
|
|
title: 'Show A',
|
|
sourceTitle: '',
|
|
seriesId: 100,
|
|
series: seriesShowA,
|
|
downloadId: null
|
|
}
|
|
]
|
|
});
|
|
|
|
const torrents = [
|
|
{
|
|
// No hash / hashString — title fallback must engage.
|
|
name: 'Show A — S01E02',
|
|
progress: 0.7,
|
|
dlspeed: 5000
|
|
}
|
|
];
|
|
|
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].arrType).toBe('sonarr');
|
|
expect(out[0].arrQueueId).toBe(555);
|
|
});
|
|
});
|
|
|
|
describe('matchTorrents — arrQueueId deduplication (#65)', () => {
|
|
it('deduplicates two torrents matching distinct queue records sharing one arrQueueId via the same hash', async () => {
|
|
// Construct the pathological case the dedup step is designed for: two
|
|
// torrents (post-hash-match) both end up mapped to the same arrQueueId.
|
|
// In real life this happens when *arr exposes multiple queue rows under
|
|
// one logical download. The first matched download wins; subsequent ones
|
|
// are dropped.
|
|
const hash = 'PACKHASH001';
|
|
const sharedQueueRow = {
|
|
id: 4242, // same arrQueueId
|
|
title: 'unrelated',
|
|
sourceTitle: '',
|
|
seriesId: 100,
|
|
series: seriesShowA,
|
|
downloadId: hash
|
|
};
|
|
|
|
const context = makeContext({
|
|
sonarrQueueRecords: [sharedQueueRow]
|
|
});
|
|
|
|
const torrents = [
|
|
{ hash, name: 'Show.A.S02E01', progress: 0.1, dlspeed: 0 },
|
|
{ hash, name: 'Show.A.S02E02', progress: 0.2, dlspeed: 0 }
|
|
];
|
|
|
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].arrQueueId).toBe(4242);
|
|
});
|
|
|
|
it('does not deduplicate torrents that lack arrQueueId (no matched *arr record)', async () => {
|
|
const context = makeContext();
|
|
const torrents = [
|
|
{ hash: 'no-match-A', name: 'unmatched-A', progress: 0, dlspeed: 0 },
|
|
{ hash: 'no-match-B', name: 'unmatched-B', progress: 0, dlspeed: 0 }
|
|
];
|
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
|
// Both unmatched torrents are filtered out by the matcher entirely because
|
|
// there is nothing to match against — so the deduplicator never sees them.
|
|
// This test simply asserts the dedup step itself does not collapse
|
|
// non-arr entries into a single bucket when no key is present.
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|