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>
84 lines
4.2 KiB
JavaScript
84 lines
4.2 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
|
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
|
|
|
function makeJobs(n, hoster, status, offset = 0) {
|
|
const jobs = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const fileName = `clip_${String(i + offset).padStart(4, '0')}.mp4`;
|
|
jobs.push({ id: `j-${hoster}-${i + offset}`, file: `D:/inbox/${fileName}`, fileName, hoster, status });
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
test('user report: 300 queued, ~200 finished mid-session before a hard kill — only the finished drop', () => {
|
|
const hoster = 'byse.sx';
|
|
const snapshot = new Date(2026, 5, 19, 22, 0, 0);
|
|
const savedAt = snapshot.getTime();
|
|
const restoredJobs = makeJobs(300, hoster, 'preview');
|
|
|
|
const completionBase = new Date(2026, 5, 19, 22, 5, 0).getTime();
|
|
const logEntries = [];
|
|
for (let i = 0; i < 200; i++) {
|
|
const d = new Date(completionBase + i * 1000);
|
|
logEntries.push(parseUploadLogLine(
|
|
formatUploadLogLine(d, hoster, `https://byse.sx/d/x${i}`, `clip_${String(i).padStart(4, '0')}.mp4`)
|
|
));
|
|
}
|
|
|
|
const { kept, removed } = partitionRestoredJobsByLog(restoredJobs, logEntries, savedAt);
|
|
assert.equal(removed.length, 200, 'the 200 completed-after-snapshot files are dropped as ghosts');
|
|
assert.equal(kept.length, 100, 'the 100 never-finished files stay queued');
|
|
assert.ok(kept.every(j => Number(j.fileName.slice(5, 9)) >= 200), 'kept are exactly indices 200..299');
|
|
const keptNames = new Set(kept.map(j => j.fileName));
|
|
assert.ok(removed.every(j => !keptNames.has(j.fileName)));
|
|
});
|
|
|
|
test('multi-hoster batch: per-hoster completion is independent (a file done on voe but not byse keeps byse)', () => {
|
|
const savedAt = new Date(2026, 5, 19, 22, 0, 0).getTime();
|
|
const done = new Date(2026, 5, 19, 22, 3, 0);
|
|
const jobs = [
|
|
...makeJobs(3, 'voe.sx', 'preview'),
|
|
...makeJobs(3, 'byse.sx', 'preview')
|
|
];
|
|
const logEntries = [
|
|
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0000.mp4')),
|
|
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0001.mp4')),
|
|
parseUploadLogLine(formatUploadLogLine(done, 'byse.sx', 'l', 'clip_0000.mp4'))
|
|
];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
|
|
assert.equal(removed.length, 3);
|
|
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0000.mp4'));
|
|
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0001.mp4'));
|
|
assert.ok(removed.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0000.mp4'));
|
|
assert.ok(kept.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0001.mp4'), 'byse clip_0001 not logged -> kept');
|
|
});
|
|
|
|
test('clean idle close (snapshot AFTER completion) keeps an intentional re-queue of an old file', () => {
|
|
const hoster = 'voe.sx';
|
|
const yesterday = new Date(2026, 5, 18, 12, 0, 0);
|
|
const logEntries = [parseUploadLogLine(formatUploadLogLine(yesterday, hoster, 'link', 'reupload_me.mp4'))];
|
|
const savedAt = new Date(2026, 5, 19, 9, 0, 0).getTime();
|
|
const jobs = [{ id: 'r1', file: 'D:/x/reupload_me.mp4', fileName: 'reupload_me.mp4', hoster, status: 'preview' }];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
|
|
assert.equal(removed.length, 0, 'an upload older than the snapshot is a deliberate re-queue and survives');
|
|
assert.equal(kept.length, 1);
|
|
});
|
|
|
|
test('legacy snapshot without savedAt (pre-v3.3.80 config) falls back to done-only dedup', () => {
|
|
const hoster = 'voe.sx';
|
|
const logEntries = [
|
|
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), hoster, 'l', 'done.mp4')),
|
|
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 1, 0), hoster, 'l', 'preview.mp4'))
|
|
];
|
|
const jobs = [
|
|
{ id: 'a', file: 'D:/x/done.mp4', fileName: 'done.mp4', hoster, status: 'done' },
|
|
{ id: 'b', file: 'D:/x/preview.mp4', fileName: 'preview.mp4', hoster, status: 'preview' }
|
|
];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries);
|
|
assert.equal(removed.length, 1, 'only the done job is decluttered when no savedAt is available');
|
|
assert.equal(removed[0].id, 'a');
|
|
assert.ok(kept.some(j => j.id === 'b'), 'the preview survives the legacy path');
|
|
});
|