Files
Multi-Hoster-Upload/tests/source-file-cleanup.test.js
T
Sucukdeluxe d7c9f287e4
CI / verify (push) Has been cancelled
release: restore v2.1.19 baseline for v2.1.24
Restore the v2.1.19 application baseline and retain only the focused import preflight summary with duplicate, unavailable, destination, job, and size-limit visibility.
2026-08-17 04:25:22 +02:00

470 lines
18 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createSourceFileCleanup } = require('../lib/source-file-cleanup');
async function makeSource(t, name = 'source.bin') {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-source-cleanup-'));
const file = path.join(directory, name);
await fs.promises.writeFile(file, Buffer.from('original source data'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
return { directory, file };
}
function group(file, overrides = {}) {
return {
token: 'cleanup-1',
file,
requiredHosters: ['voe.sx', 'byse.sx'],
completedHosters: [],
jobs: [
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' },
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' }
],
...overrides
};
}
function makeCleanup(overrides = {}) {
const audits = [];
const waits = [];
const cleanup = createSourceFileCleanup({
fs,
path,
platform: process.platform,
isEnabled: () => true,
audit: (event) => audits.push(event),
journal: { plan: async () => {}, clear: async () => {} },
wait: async (milliseconds) => waits.push(milliseconds),
...overrides
});
return { cleanup, audits, waits };
}
async function settleDone(cleanup, manifest) {
for (const job of manifest.jobs) {
await cleanup.settle({
token: manifest.token,
jobId: job.jobId,
file: manifest.file,
hoster: job.hoster,
status: 'done'
});
}
}
async function exists(file) {
try {
await fs.promises.lstat(file);
return true;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
test('fingerprints a regular file and keeps the registered manifest immutable', async (t) => {
const { file } = await makeSource(t);
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
const fingerprints = await cleanup.registerGroups([manifest]);
assert.equal(fingerprints['cleanup-1'].type, 'file');
assert.equal(fingerprints['cleanup-1'].size, Buffer.byteLength('original source data'));
assert.equal(typeof fingerprints['cleanup-1'].mtimeMs, 'number');
assert.equal(typeof fingerprints['cleanup-1'].birthtimeMs, 'number');
assert.equal(typeof fingerprints['cleanup-1'].dev, 'number');
assert.equal(typeof fingerprints['cleanup-1'].ino, 'number');
manifest.requiredHosters.splice(1, 1);
manifest.completedHosters.push('byse.sx');
manifest.jobs[1].hoster = 'voe.sx';
await cleanup.settle({
token: 'cleanup-1',
jobId: 'job-voe',
file,
hoster: 'voe.sx',
status: 'done'
});
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'blocked');
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: 'pending' }]);
});
test('extends a registered group before finalization', async (t) => {
const { file } = await makeSource(t);
const { cleanup, audits } = makeCleanup();
await cleanup.registerGroups([group(file, {
requiredHosters: ['voe.sx'],
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' }]
})]);
await cleanup.registerGroups([group(file)]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
assert.equal(audits[0].blockingStatuses[0].hoster, 'byse.sx');
});
test('deletes only when enabled, history persisted, queue persisted, and every required hoster is done', async (t) => {
const cases = [
{ name: 'setting disabled', enabled: false, historyPersisted: true, queuePersisted: true, outcome: 'setting-disabled' },
{ name: 'history missing', enabled: true, historyPersisted: false, queuePersisted: true, outcome: 'blocked' },
{ name: 'queue missing', enabled: true, historyPersisted: true, queuePersisted: false, outcome: 'blocked' }
];
for (const entry of cases) {
await t.test(entry.name, async (subtest) => {
const { file } = await makeSource(subtest, `${entry.name}.bin`);
const { cleanup, audits } = makeCleanup({ isEnabled: () => entry.enabled });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({
historyPersisted: entry.historyPersisted,
queuePersisted: entry.queuePersisted
});
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, entry.outcome);
});
}
const { file } = await makeSource(t, 'all-barriers.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), false);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
});
test('collects terminal states and blocks error, aborted, skipped, and pending jobs', async (t) => {
const cases = [
{ status: 'error', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'error' }) },
{ status: 'aborted', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'aborted' }) },
{ status: 'skipped', apply: (cleanup) => cleanup.markSkipped('job-byse', 'missing-account') },
{ status: 'pending', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'uploading' }) }
];
for (const entry of cases) {
await t.test(entry.status, async (subtest) => {
const { file } = await makeSource(subtest, `${entry.status}.bin`);
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
await entry.apply(cleanup, file);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'blocked');
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: entry.status }]);
});
}
});
test('combines previous successes with a successful retry without relaxing other requirements', async (t) => {
await t.test('last retry completes the immutable group', async (subtest) => {
const { file } = await makeSource(subtest, 'retry-completes.bin');
const { cleanup } = makeCleanup();
const manifest = group(file, { completedHosters: ['voe.sx'] });
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), false);
});
await t.test('an unselected failed requirement still blocks deletion', async (subtest) => {
const { file } = await makeSource(subtest, 'retry-partial.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file, {
requiredHosters: ['voe.sx', 'byse.sx', 'vidmoly.me'],
completedHosters: ['voe.sx'],
jobs: [
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' },
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' },
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error' }
]
});
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'vidmoly.me', status: 'error' }]);
});
});
test('audits a changed or missing source without deleting a replacement', async (t) => {
await t.test('source changed', async (subtest) => {
const { file } = await makeSource(subtest, 'changed.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await fs.promises.writeFile(file, Buffer.from('replacement source data with a different size'));
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'source-changed');
});
await t.test('source missing', async (subtest) => {
const { file } = await makeSource(subtest, 'missing.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await fs.promises.unlink(file);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'source-missing');
});
});
test('rejects non-regular sources and audits the unsafe type', async (t) => {
const { directory } = await makeSource(t);
const unsafePath = path.join(directory, 'folder-source');
await fs.promises.mkdir(unsafePath);
const { cleanup, audits } = makeCleanup();
const manifest = group(unsafePath);
const fingerprints = await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(fingerprints['cleanup-1'], null);
assert.equal((await fs.promises.lstat(unsafePath)).isDirectory(), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'unsafe-source-type');
});
test('runs unlink exactly once across repeated and concurrent finalization', async (t) => {
const { file } = await makeSource(t);
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async (...args) => {
unlinkCalls += 1;
await fs.promises.unlink(...args);
}
}
};
const { cleanup, audits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await Promise.all([
cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }),
cleanup.finishBatch({ historyPersisted: true, queuePersisted: true })
]);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 1);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
});
test('retries EBUSY and EPERM with the bounded Windows delay schedule', async (t) => {
const { file } = await makeSource(t);
const failures = ['EBUSY', 'EPERM', 'EBUSY', 'EPERM', 'EBUSY'];
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async (...args) => {
const code = failures[unlinkCalls];
unlinkCalls += 1;
if (code) throw Object.assign(new Error(code), { code });
await fs.promises.unlink(...args);
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 6);
assert.deepEqual(waits, [100, 250, 500, 1000, 2000]);
assert.equal(await exists(file), false);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
assert.equal(audits[2].attempts, 6);
});
test('swallows cleanup failures, preserves the source, and audits one final failure', async (t) => {
await t.test('retryable failure exhausts all delays', async (subtest) => {
const { file } = await makeSource(subtest, 'locked.bin');
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async () => {
unlinkCalls += 1;
throw Object.assign(new Error('locked'), { code: 'EBUSY' });
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }));
assert.equal(unlinkCalls, 6);
assert.deepEqual(waits, [100, 250, 500, 1000, 2000]);
assert.equal(await exists(file), true);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']);
assert.equal(audits[2].attempts, 6);
});
await t.test('non-retryable failure stops immediately', async (subtest) => {
const { file } = await makeSource(subtest, 'denied.bin');
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async () => {
unlinkCalls += 1;
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }));
assert.equal(unlinkCalls, 1);
assert.deepEqual(waits, []);
assert.equal(await exists(file), true);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']);
assert.equal(audits[2].attempts, 1);
});
});
test('preserves the original Windows path casing for filesystem operations', async (t) => {
const { file } = await makeSource(t, 'SourceCase.BIN');
const observed = [];
const injectedFs = {
promises: {
lstat: async (target) => {
observed.push(target);
return fs.promises.lstat(target);
},
rename: (...args) => fs.promises.rename(...args),
unlink: (...args) => fs.promises.unlink(...args)
}
};
const { cleanup } = makeCleanup({ fs: injectedFs, platform: 'win32' });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(observed[0], file);
assert.equal(await exists(file), false);
});
test('blocks both case-colliding Windows source groups', async (t) => {
const { directory, file } = await makeSource(t, 'Movie.mkv');
const secondFile = path.join(directory, 'movie.mkv');
await fs.promises.writeFile(secondFile, Buffer.from('other source data'));
const { cleanup } = makeCleanup({ platform: 'win32' });
const first = group(file, { token: 'cleanup-upper' });
const second = group(secondFile, { token: 'cleanup-lower' });
await cleanup.registerGroups([first, second]);
await settleDone(cleanup, first);
await settleDone(cleanup, second);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked', 'blocked']);
assert.equal(await exists(file), true);
assert.equal(await exists(secondFile), true);
});
test('blocks deletion when the audit approval cannot be persisted', async (t) => {
const { file } = await makeSource(t, 'audit-failure.bin');
const { cleanup } = makeCleanup({ audit: async () => false });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
});
test('restores the staged source when the deletion commit audit fails', async (t) => {
const { file } = await makeSource(t, 'audit-commit-failure.bin');
let auditCalls = 0;
const { cleanup } = makeCleanup({ audit: async () => ++auditCalls !== 2 });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
});
test('rechecks the staged file and restores a replacement without deleting it', async (t) => {
const { file } = await makeSource(t, 'stage-race.bin');
let unlinkCalls = 0;
let firstRename = true;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: async (from, to) => {
await fs.promises.rename(from, to);
if (firstRename) {
firstRename = false;
await fs.promises.writeFile(to, Buffer.from('replacement data'));
}
},
unlink: async (...args) => {
unlinkCalls += 1;
return fs.promises.unlink(...args);
}
}
};
const { cleanup, audits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 0);
assert.equal((await fs.promises.readFile(file, 'utf-8')), 'replacement data');
assert.equal(audits.at(-1).outcome, 'source-changed');
});