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