Multi-Hoster-Upload/tests/queue-dedup.test.js
Administrator eeec1d150c fix(queue): completed files no longer reappear in the queue after restart
Closing the app (especially during an active upload or on a hard kill) and
reopening sometimes left already-uploaded files sitting in the queue as if
still pending. Root cause is three layers stacked:

1. Persist-starvation: persistQueueStateSoon() reset a 10s debounce on every
   progress event, so during an upload the on-disk queue snapshot was never
   rewritten and stayed frozen at the pre-upload state (all jobs "preview").
2. The beforeunload sync flush only covers a clean close; a hard kill / crash
   leaves that stale snapshot on disk.
3. Startup auto-dedup only dropped jobs with status "done". The completed
   files were stored as "preview" in the stale snapshot, so they survived and
   reappeared.

Fix (mechanism-independent — holds whether the stale snapshot came from
starvation, a mid-upload close race, or a hard kill):

FIX A (core, durable): the upload log is the source of truth. Each persisted
snapshot is now stamped with savedAt; on restart any restored job whose newest
matching log entry is timestamped at/after floor(savedAt) is dropped regardless
of status — it provably completed after the snapshot, so a "preview" row for it
is a ghost. lib/queue-dedup.js gains an additive 3rd savedAt param; without
savedAt or without log timestamps it behaves exactly as before (the 5 canary
tests stay green, so intentional re-uploads of older files still survive).

FIX B: new lib/throttle-timer.js with a max-wait. During uploads the snapshot
is now written at most ~20s into a continuous progress burst instead of never;
idle stays a pure debounce. The fallback shim honors max-wait too, so a missing
library can never silently reintroduce the starvation.

FIX C: the synchronous close-write retries renameSync on EBUSY/EPERM/EACCES and
uses a pid-unique tmp (de-conflicts it from config-store._atomicWrite's fixed
.tmp). A startup sweep reclaims orphaned <config>.<pid>.tmp files left by a hard
kill between write and rename.

lib/upload-log.js extracts formatUploadLogLine + parseUploadLogLine from main.js
so the real writer -> reader -> gate seam is unit-tested (a future log-format or
epoch-basis change can no longer pass green while breaking the fix).

Verified: 334/334 tests green (incl. throttle fake-clock starvation/maxWait,
ts-gate multi-hoster partial-completion, and the real-format seam tests), ESLint
clean, smoke-boot identical to baseline, and an adversarial multi-agent review
(15 findings, 14 refuted, 1 low — the tmp orphan, now swept) on the diff.

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

162 lines
7.5 KiB
JavaScript

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'));
});