"""Tests fuer cleanupSourceDir in 05_Finalize_Move.js. Hintergrund (16.09.2026): Das Script loeschte nur die Original-Videodatei, nicht das Release-Verzeichnis. Dadurch blieben unter /tdarr/complete/ leere Ordner mit .nfo-Resten liegen. Die tauchten im Butler-Endpoint GET /media/handoff/diagnostics als "target_directories" auf und sahen wie haengende Jobs aus, obwohl der Import laengst durch war. Diese Tests fahren das echte Script in einem Node-VM-Sandkasten und pruefen, dass rm -rf NUR bei leeren Release-Verzeichnissen unter SourceRoot laeuft. """ import json import subprocess import unittest from pathlib import Path SCRIPT = Path(__file__).parents[1] / "scripts" / "05_Finalize_Move.js" NODE_HARNESS = r""" const fs = require('fs'); const vm = require('vm'); const scenario = JSON.parse(process.argv[2]); let source = fs.readFileSync(process.argv[3], 'utf8') .replace(/^\/\*\*[\s\S]*?\*\/\s*/, ''); source += '\n;globalThis.__exports = { Script };'; const executed = []; function fakeExecute(spec) { const cmd = spec.command; const args = spec.argumentList || []; executed.push({command: cmd, args: args}); if (cmd === '/usr/bin/find') { const dir = args[0]; const hits = (scenario.mediaFiles || []).filter(f => f.startsWith(dir)); return {exitCode: scenario.findExitCode === undefined ? 0 : scenario.findExitCode, standardOutput: hits.join('\n')}; } if (cmd === '/usr/bin/test') { return {exitCode: 1}; // Ziel existiert nicht } if (cmd === '/bin/mv') { return {exitCode: scenario.mvExitCode === undefined ? 0 : scenario.mvExitCode}; } if (cmd === '/bin/rm') { return {exitCode: scenario.rmExitCode === undefined ? 0 : scenario.rmExitCode}; } if (cmd === '/bin/mkdir') { return {exitCode: 0}; } if (String(cmd).indexOf('ffprobe') !== -1) { // keine Subtitle-Streams -> handleSubtitles steigt sofort aus return {exitCode: 0, standardOutput: JSON.stringify({streams: []})}; } return {exitCode: 0, standardOutput: ''}; } const context = { Variables: { 'file.Orig.FullName': scenario.origPath, 'file.FullName': scenario.workingFile }, Flow: { Execute: fakeExecute, SetWorkingFile() {}, GetToolPath: name => '/usr/bin/' + name, TempPath: '/temp', NewGuid: () => 'guid-0000' }, Logger: {ILog(){}, WLog(){}, ELog(){}, DLog(){}}, JSON: JSON, console }; vm.createContext(context); vm.runInContext(source, context); const rc = context.__exports.Script( scenario.sourceRoot || '/tdarr/complete/', scenario.destRoot || '/tdarr/converted/' ); console.log(JSON.stringify({rc: rc, executed: executed})); """ def run_script(scenario): harness = Path("/tmp/ff_finalize_harness.js") harness.write_text(NODE_HARNESS) proc = subprocess.run( ["node", str(harness), json.dumps(scenario), str(SCRIPT)], capture_output=True, text=True, timeout=60, ) if proc.returncode != 0: raise AssertionError(f"harness failed: {proc.stderr[-2000:]}") return json.loads(proc.stdout.strip().splitlines()[-1]) def rm_rf_targets(result): return [e["args"][1] for e in result["executed"] if e["command"] == "/bin/rm" and e["args"][:1] == ["-rf"]] class CleanupSourceDirTests(unittest.TestCase): BASE = { "origPath": "/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K/slow.horses.s06e01-w4k.mkv", "workingFile": "/temp/Runner-1/slow.horses.s06e01-w4k.mkv", } def test_leeres_release_verzeichnis_wird_entfernt(self): """Nur .nfo uebrig -> Verzeichnis muss weg.""" result = run_script({**self.BASE, "mediaFiles": []}) self.assertEqual(result["rc"], 1) self.assertEqual( rm_rf_targets(result), ["/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K"], ) def test_verzeichnis_mit_restlicher_mediendatei_bleibt(self): """Season-Pack: zweite Episode noch da -> nicht loeschen.""" result = run_script({ **self.BASE, "mediaFiles": [ "/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K/slow.horses.s06e02-w4k.mkv" ], }) self.assertEqual(result["rc"], 1) self.assertEqual(rm_rf_targets(result), []) def test_find_fehler_laesst_verzeichnis_stehen(self): """find schlaegt fehl -> fail safe, nichts loeschen.""" result = run_script({**self.BASE, "mediaFiles": [], "findExitCode": 1}) self.assertEqual(result["rc"], 1) self.assertEqual(rm_rf_targets(result), []) def test_sourceroot_selbst_wird_nie_geloescht(self): """Datei direkt in SourceRoot -> rm -rf /tdarr/complete darf NICHT passieren.""" result = run_script({ "origPath": "/tdarr/complete/lose.datei.mkv", "workingFile": "/temp/Runner-1/lose.datei.mkv", "mediaFiles": [], }) self.assertEqual(rm_rf_targets(result), []) def test_pfad_ausserhalb_sourceroot_wird_abgelehnt(self): """Original nicht unter SourceRoot -> Script bricht mit 2 ab, kein rm -rf.""" result = run_script({ "origPath": "/mnt/anderswo/film/film.mkv", "workingFile": "/temp/Runner-1/film.mkv", "mediaFiles": [], }) self.assertEqual(result["rc"], 2) self.assertEqual(rm_rf_targets(result), []) def test_originaldatei_wird_weiterhin_geloescht(self): """Regression: rm -f auf das Original bleibt erhalten.""" result = run_script({**self.BASE, "mediaFiles": []}) rm_f = [e["args"][1] for e in result["executed"] if e["command"] == "/bin/rm" and e["args"][:1] == ["-f"]] self.assertIn(self.BASE["origPath"], rm_f) if __name__ == "__main__": unittest.main(verbosity=2)