// 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([]); }); });