const { test } = require('node:test'); const assert = require('node:assert'); const { partitionRestoredJobsByLog } = require('../lib/queue-dedup'); function job(status, fileName, hoster) { return { status, fileName, hoster, file: `C:/dl/${fileName}` }; } test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => { // Exact shape of the reproduced bug: 4 preview jobs for one file across 4 // hosters, every fileName|hoster present in the lifetime upload log. const jobs = [ job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'), job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'), job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'), job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx') ]; const log = [ { fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' }, { fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' }, { fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' }, { fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' } ]; const { kept, removed } = partitionRestoredJobsByLog(jobs, log); assert.equal(removed.length, 0, 'no pending job may be removed'); assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update'); }); test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => { const jobs = [ job('done', 'a.mkv', 'doodstream.com'), job('preview', 'a.mkv', 'voe.sx'), job('error', 'b.mkv', 'doodstream.com'), job('aborted', 'c.mkv', 'doodstream.com') ]; const log = [ { fileName: 'a.mkv', hoster: 'doodstream.com' }, { fileName: 'a.mkv', hoster: 'voe.sx' }, { fileName: 'b.mkv', hoster: 'doodstream.com' }, { fileName: 'c.mkv', hoster: 'doodstream.com' } ]; const { kept, removed } = partitionRestoredJobsByLog(jobs, log); assert.equal(removed.length, 1); assert.equal(removed[0].status, 'done'); assert.equal(removed[0].hoster, 'doodstream.com'); // The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive. assert.equal(kept.length, 3); assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx')); assert.ok(kept.some(j => j.status === 'error')); assert.ok(kept.some(j => j.status === 'aborted')); }); test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => { const jobs = [job('done', 'd.mkv', 'doodstream.com')]; const { kept, removed } = partitionRestoredJobsByLog(jobs, []); assert.equal(removed.length, 0); assert.equal(kept.length, 1); }); test('case-insensitive match on fileName and hoster', () => { const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')]; const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }]; const { removed } = partitionRestoredJobsByLog(jobs, log); assert.equal(removed.length, 1); }); test('empty/missing inputs do not throw', () => { assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] }); assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] }); const jobs = [job('done', 'x.mkv', 'voe.sx')]; assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1); }); const T = (s) => Date.parse(s.replace(' ', 'T')); test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => { const jobs = [job('preview', 'a.mkv', 'voe.sx')]; const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }]; const savedAt = T('2026-06-19 12:00:00'); const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop'); assert.equal(kept.length, 0); }); test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => { const jobs = [job('preview', 'a.mkv', 'voe.sx')]; const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }]; const savedAt = T('2026-06-19 12:00:00'); const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive'); assert.equal(kept.length, 1); }); test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => { const jobs = [job('preview', 'a.mkv', 'voe.sx')]; const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }]; const savedAt = T('2026-06-19 12:00:00') + 800; const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through'); }); test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => { const jobs = [job('preview', 'a.mkv', 'voe.sx')]; const log = [ { fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }, { fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') } ]; const savedAt = T('2026-06-19 12:00:00'); const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 1, 'newest matching log entry decides'); }); test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => { const jobs = [job('preview', 'a.mkv', 'voe.sx')]; const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }]; const { kept, removed } = partitionRestoredJobsByLog(jobs, log); assert.equal(removed.length, 0); assert.equal(kept.length, 1); }); test('ts-gate inactive when log entry lacks ts → legacy behavior', () => { const jobs = [job('preview', 'a.mkv', 'voe.sx')]; const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }]; const savedAt = T('2026-06-19 12:00:00'); const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 0); assert.equal(kept.length, 1); }); test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => { const jobs = [job('done', 'a.mkv', 'voe.sx')]; const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }]; const savedAt = T('2026-06-19 12:00:00'); const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 1); }); test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => { // One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters // completed (logged), 2 never started. On restart all 4 restore as 'preview'. // Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also // pins per-hoster keying: a fileName-only gate would wrongly drop all 4. const f = 'Einfach mal die Fresse halten!!!.mp4'; const jobs = [ job('preview', f, 'doodstream.com'), job('preview', f, 'voe.sx'), job('preview', f, 'vidmoly.me'), job('preview', f, 'byse.sx') ]; const savedAt = T('2026-06-19 12:00:00'); const log = [ { fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') }, { fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') } ]; const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt); assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop'); assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx')); assert.equal(kept.length, 2, 'the 2 never-started hosters survive'); assert.ok(kept.some(j => j.hoster === 'vidmoly.me')); assert.ok(kept.some(j => j.hoster === 'byse.sx')); });