Files
Multi-Hoster-Upload/tests/folder-monitor.test.js
T
2026-09-22 06:07:27 +02:00

840 lines
33 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 }) };
}
test('test scan works while inactive, active or paused without changing monitor state', async () => {
const folderPath = path.join(os.tmpdir(), 'monitor-read-only-test');
for (const state of ['inactive', 'active', 'paused']) {
const { monitor, events } = createScanHarness({ files: [
{ path: path.join(folderPath, 'video.mkv'), size: 12, mtimeMs: 1 },
{ path: path.join(folderPath, 'note.txt'), size: 2, mtimeMs: 2 }
] });
if (state === 'active') monitor.start({ folderPath: 'different-live-folder' });
if (state === 'paused') monitor.configure({ folderPath: 'different-paused-folder', paused: true });
const before = monitor.status();
const generation = monitor._generation;
const settings = monitor._settings;
const statuses = events.statuses.length;
const seen = [...monitor._seenFiles];
const result = await monitor.testScan({ enabled: false, folderPath, extensions: 'mkv', filterMode: 'include', recursive: true });
assert.equal(result.reachable, true);
assert.deepEqual(result.files.map(file => file.filterMatched), [true, false]);
assert.deepEqual(monitor.status(), before);
assert.equal(monitor._generation, generation);
assert.equal(monitor._settings, settings);
assert.deepEqual([...monitor._seenFiles], seen);
assert.equal(events.statuses.length, statuses);
assert.equal(events.newFiles.length, 0);
monitor.stop();
}
});
test('test scan uses independent settings for concurrent read-only requests', async () => {
const visited = [];
const monitor = new FolderMonitor({
access: async () => {},
walkFolder: async (folder, options) => { visited.push([folder, options.recursive]); return []; },
watch: () => { throw new Error('No watcher may be started'); },
setIntervalFn: () => { throw new Error('No timer may be started'); }
});
await Promise.all([
monitor.testScan({ folderPath: 'first', recursive: false }),
monitor.testScan({ folderPath: 'second', recursive: true })
]);
assert.deepEqual(visited, [['first', false], ['second', true]]);
assert.equal(monitor.running, false);
assert.equal(monitor.status().folderPath, '');
});
test('test scan distinguishes missing, unavailable and unreadable folders safely', async () => {
const missing = await new FolderMonitor().testScan({});
assert.equal(missing.error, 'Kein Ordnerpfad angegeben');
const unavailable = await new FolderMonitor({ access: async () => { throw new Error('private path'); } }).testScan({ folderPath: 'test' });
assert.equal(unavailable.error, 'Ordner nicht erreichbar');
const failed = await new FolderMonitor({ access: async () => {}, walkFolder: async () => { throw new Error('private path'); } }).testScan({ folderPath: 'test' });
assert.equal(failed.error, 'Ordnerscan fehlgeschlagen');
});
test('test scan IPC reads persisted settings instead of depending on the active watcher', async () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8');
const start = source.indexOf("ipcMain.handle('folder-monitor:test-scan'");
const end = source.indexOf('\n});', start) + 4;
const settings = { enabled: false, folderPath: 'test-folder' };
let handler;
const expected = { files: [], reachable: true };
vm.runInNewContext(source.slice(start, end), {
ipcMain: { handle: (_name, callback) => { handler = callback; } },
configStore: { load: () => ({ globalSettings: { folderMonitor: settings } }) },
folderMonitor: { testScan: snapshot => { assert.equal(snapshot, settings); return expected; } }
});
assert.equal(await handler(), expected);
});
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 });
}
});