Multi-Hoster-Upload/tests/queue-dedup.test.js
Administrator 6b0c515a9b fix(queue): close two lost-work / sticky-ghost edge cases found by intensive testing
Follow-up hardening on the v3.3.80 queue-persistence fix after a deeper adversarial
sweep + reviewer pass over the WHOLE subsystem (not just the diff). Two real defects:

1. Basename-collision lost work (regression introduced by v3.3.80 FIX A).
   restoreQueueStateFromConfig collapses jobs on the FULL path while the ts-gate keys
   on basename|hoster. Two genuinely different files with the same basename queued to
   the same hoster from different folders therefore share a gate key: if one was logged
   after savedAt, the ts-rule dropped BOTH — silently losing the still-pending one.
   Pre-FIX-A only 'done' jobs were dropped, so a pending file was never at risk.
   Fix: an ambiguity guard in partitionRestoredJobsByLog — the ts-rule is suppressed
   when a basename|hoster key maps to more than one distinct file path (the log records
   only basenames, so it can't say which physical file completed). The done-in-log rule
   is unchanged. Fails safe: worst case a visible ghost survives, never silent data loss.

2. selectedFiles re-materialization with removeFromQueueOnDone=ON (second mechanism,
   independent of the stale snapshot). When that setting is on, a completed job is
   stripped from queueJobs but its path stays in selectedFiles (syncSelectedFilesFromQueue
   only runs at batch-done, never on a mid-upload close). On restart the ts-gate operates
   on queueJobs and never sees it, then the startup updateUploadView -> buildQueuePreview
   re-creates it as a preview ghost AFTER the gate ran, and it re-persists with a fresh
   savedAt — sticky. Fix: completedSelectionKeys() seeds _completedUploadKeys (the set
   buildQueuePreview already consults) from the log at startup, keyed on full path, with
   the same ambiguity guard. Log-based so it survives a hard kill, consistent with FIX A.

Also extracts the orphan-tmp sweep decision into lib/orphan-tmp.js (was untested inline
code in main.js; behavior-preserving) and adds executable coverage for the paths that
were previously only argued from logic: orphan-tmp sweep, config-store pendingQueue+savedAt
round-trip, an end-to-end scenario in the exact user-reported shape (300 queued / ~200
finished mid-session), and a 3000+500-iteration property fuzz of the gate invariant
including the lost-work guarantee. 359/359 green, ESLint clean, smoke-boot unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 06:51:48 +02:00

254 lines
13 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog, completedSelectionKeys } = 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 ambiguity guard: a pending same-basename file in a DIFFERENT folder is NOT lost when a sibling completes after the snapshot', () => {
// X (C:/A/clip.mp4) was uploaded after the snapshot and logged. Y is a
// genuinely-different file (C:/B/clip.mp4), same basename + hoster, still
// pending. The log records only basenames, so the ts-rule must not drop Y.
const jobs = [
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0, 'ambiguous key -> ts-rule suppressed, no pending file lost');
assert.equal(kept.length, 2);
});
test('ts-gate ambiguity guard: the done-in-log rule still applies on an ambiguous key', () => {
// Even when the key is ambiguous, a job that is actually 'done' and in the log
// is still decluttered (pre-existing rule, unchanged by the guard).
const jobs = [
{ status: 'done', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
];
const log = [{ fileName: 'clip.mp4', 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);
assert.equal(removed[0].status, 'done');
assert.equal(removed[0].file, 'C:/A/clip.mp4');
assert.ok(kept.some(j => j.file === 'C:/B/clip.mp4'), 'the distinct pending file survives');
});
test('ts-gate: a unique-path ghost still drops (guard does not weaken the common case)', () => {
const jobs = [{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' }];
const log = [{ fileName: 'clip.mp4', 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, 'single job for the key -> unambiguous -> ghost dropped as before');
});
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'));
});
test('completedSelectionKeys: a selectedFile that completed after the snapshot yields its full-path|hoster key', () => {
const selectedFiles = [{ path: 'C:/dl/done.mp4', name: 'done.mp4' }, { path: 'C:/dl/pending.mp4', name: 'pending.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'done.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, ['C:/dl/done.mp4|voe.sx'], 'only the completed file is seeded; pending is not');
});
test('completedSelectionKeys: per-hoster — a file done on voe but not byse only seeds the voe key', () => {
const selectedFiles = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
const hosters = ['voe.sx', 'byse.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, ['C:/dl/a.mp4|voe.sx'], 'the still-pending byse upload is NOT seeded');
});
test('completedSelectionKeys: ambiguous basename across folders seeds NOTHING (no lost re-preview)', () => {
const selectedFiles = [{ path: 'C:/A/clip.mp4', name: 'clip.mp4' }, { path: 'C:/B/clip.mp4', name: 'clip.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, [], 'ambiguous -> neither path is suppressed, both re-preview (safe direction)');
});
test('completedSelectionKeys: an OLDER completion (pre-snapshot re-queue) is NOT seeded', () => {
const selectedFiles = [{ path: 'C:/dl/reup.mp4', name: 'reup.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'reup.mp4', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), []);
});
test('completedSelectionKeys: no savedAt / junk inputs -> empty (legacy + robustness)', () => {
const sf = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
assert.deepEqual(completedSelectionKeys(sf, ['voe.sx'], log, undefined), []);
assert.deepEqual(completedSelectionKeys(null, ['voe.sx'], log, 1), []);
assert.deepEqual(completedSelectionKeys(sf, null, log, 1), []);
assert.deepEqual(completedSelectionKeys([], [], log, 1), []);
assert.deepEqual(completedSelectionKeys([{ name: 'x' }], ['voe.sx'], log, 1), [], 'entry without path is skipped');
});
test('completedSelectionKeys: derives basename from path when name is missing', () => {
const selectedFiles = [{ path: 'C:/dl/sub/movie.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'movie.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), ['C:/dl/sub/movie.mp4|voe.sx']);
});