Multi-Hoster-Upload/tests/queue-dedup-property.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

115 lines
5.2 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function lcg(seed) {
let s = seed >>> 0;
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
}
function key(f, h) { return `${String(f).toLowerCase()}|${String(h).toLowerCase()}`; }
test('property: removed iff (done && key in log) OR (savedAt finite && key unambiguous && newest matching log ts >= floor(savedAt/1000)*1000)', () => {
const rnd = lcg(0x9e3779b1);
const statuses = ['preview', 'done', 'error', 'aborted', 'queued', 'skipped'];
const hosters = ['voe.sx', 'byse.sx', 'doodstream.com'];
const names = ['a.mkv', 'b.mp4', 'A.MKV', 'c.mov'];
const folders = ['C:/A/', 'C:/B/', 'D:/down/'];
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
for (let iter = 0; iter < 3000; iter++) {
const useSavedAt = rnd() < 0.7;
const savedAt = useSavedAt ? Math.floor(rnd() * 2_000_000_000_000) : undefined;
const jobs = [];
const nJobs = 1 + Math.floor(rnd() * 6);
for (let i = 0; i < nJobs; i++) {
const name = pick(names);
// Mix shared and distinct paths so ambiguous keys (same name+hoster,
// different folder) actually occur and exercise the guard.
jobs.push({ id: `j${i}`, fileName: name, hoster: pick(hosters), status: pick(statuses), file: `${pick(folders)}${name}` });
}
const log = [];
const nLog = Math.floor(rnd() * 5);
for (let i = 0; i < nLog; i++) {
const hasTs = rnd() < 0.8;
log.push({ fileName: pick(names), hoster: pick(hosters), ts: hasTs ? Math.floor(rnd() * 2_000_000_000_000) : undefined });
}
const logKeys = new Set();
const maxTs = new Map();
for (const e of log) {
const k = key(e.fileName, e.hoster);
logKeys.add(k);
if (typeof e.ts === 'number' && isFinite(e.ts)) {
const prev = maxTs.get(k);
if (prev === undefined || e.ts > prev) maxTs.set(k, e.ts);
}
}
const filesPerKey = new Map();
for (const job of jobs) {
const k = key(job.fileName, job.hoster);
if (!filesPerKey.has(k)) filesPerKey.set(k, new Set());
filesPerKey.get(k).add(job.file || '');
}
const floor = (typeof savedAt === 'number' && isFinite(savedAt)) ? Math.floor(savedAt / 1000) * 1000 : null;
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(kept.length + removed.length, jobs.length, `iter ${iter}: partition must cover every job exactly once`);
const keptIds = new Set(kept.map(j => j.id));
const removedIds = new Set(removed.map(j => j.id));
assert.equal(keptIds.size + removedIds.size, jobs.length, `iter ${iter}: no job in both partitions`);
for (const job of jobs) {
const k = key(job.fileName, job.hoster);
const doneInLog = job.status === 'done' && logKeys.has(k);
const unambiguous = filesPerKey.get(k).size <= 1;
const afterSnap = floor !== null && unambiguous && maxTs.has(k) && maxTs.get(k) >= floor;
const shouldRemove = doneInLog || afterSnap;
assert.equal(removedIds.has(job.id), shouldRemove,
`iter ${iter}: job ${job.id} (status=${job.status} key=${k} unambig=${unambiguous}) expected removed=${shouldRemove}`);
}
}
});
test('property: a genuinely-pending job is NEVER lost to a same-basename sibling completing after the snapshot', () => {
const rnd = lcg(0x1234abcd);
for (let iter = 0; iter < 500; iter++) {
const savedAt = 1_000_000_000_000 + Math.floor(rnd() * 1_000_000);
// X completed after the snapshot (logged); Y is a DIFFERENT file, same
// basename + hoster, genuinely pending. Y must survive.
const jobs = [
{ id: 'X', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/A/clip.mp4' },
{ id: 'Y', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/B/clip.mp4' }
];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: savedAt + 1000 + Math.floor(rnd() * 1000) }];
const { kept } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.ok(kept.some(j => j.id === 'Y'), `iter ${iter}: pending Y must never be silently dropped`);
}
});
test('property: 2-arg legacy call NEVER removes a non-done job (the v3.3.80 canary, fuzzed)', () => {
const rnd = lcg(0xdeadbeef);
const statuses = ['preview', 'error', 'aborted', 'queued', 'skipped'];
const hosters = ['voe.sx', 'byse.sx'];
const names = ['a.mkv', 'b.mp4'];
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
for (let iter = 0; iter < 1000; iter++) {
const jobs = [];
const nJobs = 1 + Math.floor(rnd() * 5);
for (let i = 0; i < nJobs; i++) {
jobs.push({ id: `j${i}`, fileName: pick(names), hoster: pick(hosters), status: pick(statuses), file: `C:/x/${i}` });
}
const log = [];
const nLog = Math.floor(rnd() * 4);
for (let i = 0; i < nLog; i++) {
log.push({ fileName: pick(names), hoster: pick(hosters), ts: Math.floor(rnd() * 2_000_000_000_000) });
}
const { removed } = partitionRestoredJobsByLog(jobs, log);
assert.ok(removed.every(j => j.status === 'done'), `iter ${iter}: legacy 2-arg call must never drop a non-done job`);
}
});