Files
Multi-Hoster-Upload/tests/folder-monitor.test.js
T
Sucukdeluxe afd3b70cf3 fix: harden automation reconciliation and pause lifecycle
Keep full folder scans recoverable under duplicate protection and classify every discovered file once before automatic admission.

Separate manual previews from automatic capacity limits, serialize renderer intake, normalize automation counters and intervals, and make Main authoritative for runtime timestamps.

Enforce persistent pause across startup, import, close recovery, resume failures, and reconciliation while preserving read-only paused scans and exactly one activation reconciliation.

Restore failed asynchronous rotation chunks, complete automation localization and unlimited queue accessibility, and tighten hidden integration cleanup coverage.
2026-08-26 15:47:31 +02:00

771 lines
30 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const FolderMonitor = require('../lib/folder-monitor');
const { classifyProcessedCandidates, planAtomicAdmissions } = require('../lib/automation-control');
function createWatcherHarness() {
const calls = [];
const timers = createManualTimers();
const watch = (folderPath, options) => {
const watcher = new EventEmitter();
watcher.close = async () => {};
calls.push({ folderPath, options, watcher });
return watcher;
};
return { calls, monitor: new FolderMonitor({ watch, ...timers }) };
}
function createManualTimers() {
const intervals = new Set();
const timeouts = new Set();
const intervalDelays = [];
return {
setIntervalFn(callback, delay) {
intervals.add(callback);
intervalDelays.push(delay);
return callback;
},
clearIntervalFn(callback) {
intervals.delete(callback);
},
setTimeoutFn(callback) {
timeouts.add(callback);
return callback;
},
clearTimeoutFn(callback) {
timeouts.delete(callback);
},
async runInterval() {
for (const callback of [...intervals]) await callback();
},
async runTimeouts() {
for (const callback of [...timeouts]) {
timeouts.delete(callback);
await callback();
}
},
intervalDelays
};
}
function createSilentWatch() {
return () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
return watcher;
};
}
function createScanHarness({ files = [] } = {}) {
const events = { newFiles: [], statuses: [] };
const timers = createManualTimers();
const stats = new Map(files.map((file) => [file.path, { mtimeMs: file.mtimeMs }]));
const monitor = new FolderMonitor({
watch: createSilentWatch(),
walkFolder: async () => files.map(({ path: filePath, name, size }) => ({ path: filePath, name, size })),
access: async () => {},
stat: async (filePath) => stats.get(filePath),
now: () => 1234,
...timers
});
monitor.on('new-files', (paths) => events.newFiles.push(paths));
monitor.on('status', (status) => events.statuses.push(status));
return { monitor, events, runInterval: timers.runInterval };
}
function createDeferredScanHarness() {
let calls = 0;
let releaseFirstScan;
const timers = createManualTimers();
const firstScan = new Promise((resolve) => { releaseFirstScan = resolve; });
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
stat: async () => ({ mtimeMs: 1 }),
walkFolder: async () => {
calls++;
if (calls === 1) await firstScan;
return [];
},
...timers
});
monitor.start({ folderPath: 'C:\\incoming', reconcileIntervalMinutes: 5 });
return { monitor, releaseFirstScan, scanCalls: () => calls };
}
function createReachabilityHarness(initiallyReachable) {
let reachable = initiallyReachable;
let calls = 0;
const statusEvents = [];
const timers = createManualTimers();
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {
if (!reachable) throw new Error('unreachable');
},
walkFolder: async () => {
calls++;
return [];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statusEvents.push(status));
return {
monitor,
setReachable(value) { reachable = value; },
statusEvents,
scanCalls: () => calls,
runInterval: timers.runInterval
};
}
test('existing files are included only on the first start of the same watch scope', () => {
const { calls, monitor } = createWatcherHarness();
const settings = { folderPath: 'C:\\incoming', includeExisting: true, recursive: false };
monitor.start(settings);
monitor.start(settings);
assert.equal(calls[0].options.ignoreInitial, false);
assert.equal(calls[1].options.ignoreInitial, true);
});
test('existing files remain ignored unless the option is enabled', () => {
const { calls, monitor } = createWatcherHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: false, recursive: false });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
assert.equal(calls[0].options.ignoreInitial, true);
assert.equal(calls[1].options.ignoreInitial, false);
});
test('a changed folder or filter creates a new initial scope', () => {
const { calls, monitor } = createWatcherHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mkv' });
assert.deepEqual(calls.map(call => call.options.ignoreInitial), [false, false, false]);
});
test('initial scan completion is exposed so the one-time option can be persisted as consumed', () => {
const { calls, monitor } = createWatcherHarness();
let completed = 0;
monitor.on('initial-scan-complete', () => { completed++; });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
calls[0].watcher.emit('ready');
calls[0].watcher.emit('ready');
assert.equal(completed, 1);
});
test('dry scan returns every descriptor with a disjoint filter classification without emitting files', async () => {
const { monitor, events } = createScanHarness({
files: [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 }
]
});
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 });
const result = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.deepEqual(result.files, [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1, filterMatched: true, filterReason: 'matched' },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2, filterMatched: false, filterReason: 'extension' }
]);
assert.equal(result.files.length, 2);
assert.equal(result.files.filter(file => file.filterMatched).length, 1);
assert.equal(events.newFiles.length, 0);
});
test('productive full scans emit every classified descriptor without consuming watcher duplicate reservations', async () => {
const { monitor, events } = createScanHarness({
files: [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 }
]
});
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, skipDuplicates: true, reconcileIntervalMinutes: 5 });
await monitor.scan({ emitFiles: true, trigger: 'startup' });
await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.deepEqual(events.newFiles.map(files => files.map(file => ({ name: file.name, filterMatched: file.filterMatched }))), [
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }],
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }]
]);
assert.equal(monitor.status().seenCount, 0);
});
test('reconciliation intervals accept only finite numeric positive-list values', () => {
const cases = [
[1, 60000],
[5, 300000],
[15, 900000],
[30, 1800000],
[60, 3600000],
[-1, 300000],
[Number.POSITIVE_INFINITY, 300000],
['1', 300000],
['15', 300000],
[2, 300000],
[undefined, 300000]
];
for (const [reconcileIntervalMinutes, expectedDelay] of cases) {
const timers = createManualTimers();
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes });
assert.equal(timers.intervalDelays[0], expectedDelay, String(reconcileIntervalMinutes));
monitor.stop();
}
});
test('status owns monitoring start and next reconciliation timestamps across pause resume and intervals', async () => {
let now = 1000;
const timers = createManualTimers();
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
now: () => now,
...timers
});
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(monitor.status().startedAt, 1000);
assert.equal(monitor.status().nextReconcileAt, 301000);
now = 2000;
await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.equal(monitor.status().startedAt, 1000);
assert.equal(monitor.status().nextReconcileAt, 301000);
await monitor.pause();
assert.equal(monitor.status().startedAt, null);
assert.equal(monitor.status().nextReconcileAt, null);
now = 4000;
await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(monitor.status().startedAt, 4000);
assert.equal(monitor.status().nextReconcileAt, 304000);
now = 304000;
await timers.runInterval();
assert.equal(monitor.status().startedAt, 4000);
assert.equal(monitor.status().lastScanAt, 304000);
assert.equal(monitor.status().nextReconcileAt, 604000);
});
test('paused configuration keeps watcher and interval closed while allowing a read-only scan', async () => {
const timers = createManualTimers();
let watcherStarts = 0;
const monitor = new FolderMonitor({
watch: () => {
watcherStarts++;
return createSilentWatch()();
},
access: async () => {},
walkFolder: async () => [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
const result = monitor.configure({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
const scan = await monitor.scan({ emitFiles: false, trigger: 'test' });
const productive = await monitor.scan({ emitFiles: true, trigger: 'manual' });
assert.deepEqual(result, { includesExisting: false, paused: true });
assert.equal(watcherStarts, 0);
assert.equal(timers.intervalDelays.length, 0);
assert.equal(monitor.status().paused, true);
assert.equal(monitor.status().folderPath, 'C:\\watch');
assert.deepEqual(scan.files.map(file => file.name), ['a.mkv']);
assert.equal(productive.cancelled, true);
});
test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => {
const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness();
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' });
const third = monitor.scan({ emitFiles: true, trigger: 'manual' });
releaseFirstScan();
await Promise.all([first, second, third]);
assert.equal(scanCalls(), 2);
});
test('disconnect preserves configuration and reconnect performs one immediate scan', async () => {
const { monitor, setReachable, statusEvents, scanCalls, runInterval } = createReachabilityHarness(false);
monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 });
await runInterval();
assert.equal(statusEvents.at(-1).reachable, false);
assert.equal(monitor.status().folderPath, 'Z:\\watch');
setReachable(true);
await runInterval();
assert.equal(statusEvents.at(-1).reachable, true);
assert.equal(scanCalls(), 1);
});
test('reachable reconciliation intervals scan the configured folder', async () => {
const { monitor, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
await runInterval();
await runInterval();
assert.equal(scanCalls(), 2);
});
test('pause stops watcher and reconciliation until explicit resume', async () => {
const { monitor, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
await monitor.pause();
await runInterval();
assert.equal(scanCalls(), 0);
assert.equal(monitor.status().paused, true);
await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(scanCalls(), 1);
assert.equal(monitor.status().paused, false);
});
test('resume can activate watcher and interval before a separately gated reconciliation', async () => {
const { monitor, scanCalls } = createReachabilityHarness(true);
const settings = { folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 };
monitor.start(settings);
await monitor.pause();
const result = await monitor.resume(settings, { reconcile: false });
assert.equal(scanCalls(), 0);
assert.equal(result.reconciled, false);
assert.equal(monitor.status().running, true);
assert.equal(monitor.status().paused, false);
});
test('repeated pause emits one status change and keeps reconciliation stopped', async () => {
const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const statusCountBeforePause = statusEvents.length;
await monitor.pause();
await monitor.pause();
await runInterval();
assert.equal(statusEvents.length, statusCountBeforePause + 1);
assert.equal(statusEvents.at(-1).paused, true);
assert.equal(scanCalls(), 0);
});
test('stop emits an immutable non-running status snapshot', () => {
const { monitor } = createWatcherHarness();
const statuses = [];
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const statusCount = statuses.length;
monitor.stop();
assert.equal(statuses.length, statusCount + 1);
assert.equal(statuses.at(-1).running, false);
assert.equal(Object.isFrozen(statuses.at(-1)), true);
});
test('pause invalidates a running scan before it can publish late state or files', async () => {
let releaseWalk;
let markWalkStarted;
const walkPending = new Promise((resolve) => { releaseWalk = resolve; });
const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; });
const timers = createManualTimers();
const statuses = [];
const newFiles = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => {
markWalkStarted();
await walkPending;
return [{ path: 'C:\\watch\\late.mkv', name: 'late.mkv', size: 1 }];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.on('new-files', (files) => newFiles.push(files));
monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 });
const scan = monitor.scan({ emitFiles: true, trigger: 'manual' });
await walkStarted;
await monitor.pause();
const statusCountAfterPause = statuses.length;
releaseWalk();
const result = await scan;
assert.equal(result.cancelled, true);
assert.equal(statuses.length, statusCountAfterPause);
assert.deepEqual(newFiles, []);
assert.equal(monitor.status().scanning, false);
});
test('stop cancels a running scan and its pending follow-up', async () => {
let releaseWalk;
let markWalkStarted;
let walkCalls = 0;
const walkPending = new Promise((resolve) => { releaseWalk = resolve; });
const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; });
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => {
walkCalls++;
if (walkCalls === 1) {
markWalkStarted();
await walkPending;
}
return [];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
await walkStarted;
const followUp = monitor.scan({ emitFiles: true, trigger: 'manual' });
monitor.stop();
const statusCountAfterStop = statuses.length;
releaseWalk();
const results = await Promise.all([first, followUp]);
assert.equal(walkCalls, 1);
assert.equal(results.every((result) => result.cancelled === true), true);
assert.equal(statuses.length, statusCountAfterStop);
});
test('late watcher add callbacks are ignored after pause', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
await monitor.pause();
watchers[0].emit('add', 'C:\\watch\\late.mkv');
await timers.runTimeouts();
assert.deepEqual(newFiles, []);
});
test('resume full scan redelivers candidates while watcher duplicate history remains reserved', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [{ path: 'C:\\watch\\same.mkv', name: 'same.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
monitor.start(settings);
watchers[0].emit('add', 'C:\\watch\\same.mkv');
await timers.runTimeouts();
await monitor.pause();
await monitor.resume(settings);
watchers[1].emit('add', 'C:\\watch\\same.mkv');
await timers.runTimeouts();
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [
['C:\\watch\\same.mkv'],
['C:\\watch\\same.mkv']
]);
assert.equal(monitor.status().seenCount, 1);
});
test('watcher add paused before batch timeout is emitted exactly once by resume scan', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const filePath = 'C:\\watch\\pending.mkv';
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [{ path: filePath, name: 'pending.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
monitor.start(settings);
watchers[0].emit('add', filePath);
assert.deepEqual(newFiles, []);
await monitor.pause();
assert.deepEqual(newFiles, []);
await monitor.resume(settings);
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]);
assert.equal(monitor.status().seenCount, 0);
});
test('synchronous pause during watcher delivery keeps its reservation while resume scan redelivers the candidate', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const filePath = 'C:\\watch\\delivered.mkv';
let pausePromise;
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [{ path: filePath, name: 'delivered.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => {
newFiles.push(files);
pausePromise = monitor.pause();
});
const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
monitor.start(settings);
watchers[0].emit('add', filePath);
await timers.runTimeouts();
await pausePromise;
assert.deepEqual(newFiles, [[filePath]]);
await monitor.resume(settings);
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath], [filePath]]);
assert.equal(monitor.status().seenCount, 1);
});
test('pause rollback never deletes historical seen state from a dedupe-off batch', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const filePath = 'C:\\watch\\historical.mkv';
let discoverExisting = false;
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => discoverExisting ? [{ path: filePath, name: 'historical.mkv', size: 1 }] : [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
const dedupeOn = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
const dedupeOff = { ...dedupeOn, skipDuplicates: false };
monitor.start(dedupeOn);
watchers[0].emit('add', filePath);
await timers.runTimeouts();
assert.deepEqual(newFiles, [[filePath]]);
await monitor.pause();
await monitor.resume(dedupeOff);
watchers[1].emit('add', filePath);
assert.deepEqual(newFiles, [[filePath]]);
await monitor.pause();
await monitor.resume(dedupeOn);
watchers[2].emit('add', filePath);
await timers.runTimeouts();
assert.deepEqual(newFiles, [[filePath]]);
assert.equal(monitor.status().seenCount, 1);
});
test('dry scan leaves the public status byte-identical and does not consume reconnect state', async () => {
let reachable = false;
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {
if (!reachable) throw new Error('offline');
},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 });
await monitor.scan({ emitFiles: true, trigger: 'interval' });
reachable = true;
const before = JSON.stringify(monitor.status());
const statusCount = statuses.length;
const dry = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.equal(dry.reachable, true);
assert.equal(JSON.stringify(monitor.status()), before);
assert.equal(statuses.length, statusCount);
const productive = await monitor.scan({ emitFiles: true, trigger: 'reconnect' });
assert.equal(productive.reconnected, true);
});
test('walk failure clears scan state, sanitizes the error and preserves one follow-up', async () => {
let releaseFailure;
let walkCalls = 0;
const failurePending = new Promise((resolve) => { releaseFailure = resolve; });
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => {
walkCalls++;
if (walkCalls === 1) {
await failurePending;
throw new Error('token=secret-value at C:\\private\\file.mkv');
}
return [];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' });
const third = monitor.scan({ emitFiles: true, trigger: 'manual' });
releaseFailure();
await Promise.all([first, second, third]);
assert.equal(walkCalls, 2);
assert.equal(statuses.some((status) => status.error === 'Ordnerscan fehlgeschlagen'), true);
assert.equal(statuses.some((status) => status.error.includes('secret-value') || status.error.includes('C:\\private')), false);
assert.equal(monitor.status().scanning, false);
});
test('new-files listener failure terminates the productive scan with a sanitized error', async () => {
const { monitor } = createScanHarness({
files: [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1, mtimeMs: 1 }]
});
monitor.on('new-files', () => {
throw new Error('apiKey=listener-secret');
});
monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
const result = await monitor.scan({ emitFiles: true, trigger: 'manual' });
assert.equal(result.error, 'Ordnerscan fehlgeschlagen');
assert.equal(monitor.status().error, 'Ordnerscan fehlgeschlagen');
assert.equal(monitor.status().error.includes('listener-secret'), false);
assert.equal(monitor.status().scanning, false);
});
test('interval callback contains unexpected scan rejection', async () => {
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
monitor.scan = async () => { throw new Error('token=interval-secret'); };
await assert.doesNotReject(() => timers.runInterval());
assert.equal(statuses.at(-1).error, 'Ordnerscan fehlgeschlagen');
assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
});
test('real temporary folder recovers a file-atomic deferral with default duplicate protection', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
const detached = `${root}-detached`;
const timers = createManualTimers();
const queuedPaths = new Set();
const admissions = [];
let currentJobCount = 14998;
try {
fs.mkdirSync(path.join(root, 'nested'));
const fourJobPath = path.join(root, 'four-jobs.mkv');
const twoJobPath = path.join(root, 'nested', 'two-jobs.mkv');
fs.writeFileSync(fourJobPath, Buffer.from('four'));
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('ignored'));
fs.writeFileSync(twoJobPath, Buffer.from('two'));
fs.writeFileSync(path.join(root, 'nested', 'excluded.mp4'), Buffer.from('excluded'));
fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z'));
fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z'));
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.on('new-files', (files) => {
const descriptors = files.map(file => typeof file === 'string' ? {
path: file,
name: path.basename(file),
mtimeMs: fs.statSync(file).mtimeMs
} : file).filter(file => file.filterMatched !== false);
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
const unprocessed = new Set(processed.unprocessedPaths);
const candidates = descriptors
.filter(file => unprocessed.has(file.path))
.map(file => ({ ...file, eligibleJobCount: file.name === 'four-jobs.mkv' ? 4 : 2 }));
const plan = planAtomicAdmissions({ candidates, currentJobCount, queueLimitJobs: 15000 });
for (const filePath of plan.admittedPaths) queuedPaths.add(filePath);
currentJobCount += plan.plannedJobs;
admissions.push({
trigger: monitor.status().lastScanTrigger,
admittedPaths: plan.admittedPaths,
deferredPaths: plan.deferredPaths,
currentJobCount
});
});
monitor.start({
folderPath: root,
recursive: true,
extensions: 'mkv',
filterMode: 'include',
skipDuplicates: true,
reconcileIntervalMinutes: 5
});
const first = await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.deepEqual(first.files.filter(file => file.filterMatched).map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']);
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
assert.deepEqual(admissions[0], {
trigger: 'startup',
admittedPaths: [twoJobPath],
deferredPaths: [fourJobPath],
currentJobCount: 15000
});
currentJobCount = 14996;
await timers.runInterval();
assert.deepEqual(admissions[1], {
trigger: 'interval',
admittedPaths: [fourJobPath],
deferredPaths: [],
currentJobCount: 15000
});
fs.renameSync(root, detached);
await timers.runInterval();
assert.equal(monitor.status().reachable, false);
assert.equal(admissions.length, 2);
fs.renameSync(detached, root);
await timers.runInterval();
assert.equal(monitor.status().reachable, true);
assert.equal(admissions.at(-1).trigger, 'reconnect');
assert.deepEqual(admissions.at(-1).admittedPaths, []);
await timers.runInterval();
assert.equal(admissions.filter(entry => entry.trigger === 'reconnect').length, 1);
assert.equal(admissions.at(-1).trigger, 'interval');
assert.deepEqual([...queuedPaths].sort(), [fourJobPath, twoJobPath].sort());
await monitor.pause();
} finally {
if (fs.existsSync(detached)) fs.renameSync(detached, root);
fs.rmSync(root, { recursive: true, force: true });
}
});