"""Wartungsfenster für den Neustart nach Updates (deploy/wartungsfenster.sh, seit 24.09.2026 einstellbar) und wie deploy.sh mit den Einstellungen umgeht: Radar-Schalter respektieren, das Zeitfenster-Drop-in nie anfassen. Die Tests fahren die echten Shell-Funktionen mit vorgetäuschter Zeit (MC_FENSTER_JETZT).""" import json import os import re import shutil import subprocess import sys from pathlib import Path import pytest from services import einstellungen DEPLOY = Path(__file__).resolve().parents[2] / "deploy" BASH = shutil.which("bash") pytestmark = pytest.mark.skipif( BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()), reason="braucht eine bash (Git-Bash oder Linux)", ) def _fenster(befehl: str, **env: str) -> subprocess.CompletedProcess: skript = f'. "{(DEPLOY / "wartungsfenster.sh").as_posix()}"; {befehl}' umgebung = {k: v for k, v in os.environ.items() if k not in ("MC_WARTUNGSFENSTER", "MC_FENSTER_JETZT")} umgebung.update({"MC_EINSTELLUNGEN": "/gibt/es/nicht.json", **env}) return subprocess.run([BASH, "-c", skript], env=umgebung, capture_output=True, text=True, encoding="utf-8", timeout=60) def _im_fenster(jetzt: str, fenster: str | None = None) -> bool: env = {"MC_FENSTER_JETZT": jetzt} if fenster: env["MC_WARTUNGSFENSTER"] = fenster return _fenster("im_wartungsfenster", **env).returncode == 0 @pytest.mark.parametrize("jetzt, erwartet", [ ("7 04:00", True), ("7 04:40", True), ("7 06:59", True), ("7 03:59", False), ("7 07:00", False), ("4 15:00", False), # Donnerstagnachmittag: der Neustart vom 24.09. ("6 05:00", False), ]) def test_standard_ist_sonntag_frueh(jetzt, erwartet): assert _im_fenster(jetzt) is erwartet @pytest.mark.parametrize("fenster, jetzt, erwartet", [ ("6 05:00", "6 05:00", True), ("6 05:00", "6 07:59", True), ("6 05:00", "6 08:00", False), ("6 05:00", "7 05:00", False), ("6 23:30", "6 23:00", True), ("6 23:30", "7 01:59", True), ("6 23:30", "7 02:00", False), ("6 23:30", "6 22:59", False), ("7 23:00", "1 01:30", True), ("7 23:00", "1 02:00", False), # über das Wochenende hinaus ("9 25:00", "7 04:30", True), ("9 25:00", "6 05:00", False), # Unsinn → Standard So 04:30 ]) def test_eingestelltes_fenster(fenster, jetzt, erwartet): assert _im_fenster(jetzt, fenster) is erwartet @pytest.mark.parametrize("tag, uhrzeit", [(7, "04:30"), (6, "05:00"), (6, "23:30"), (7, "22:00"), (1, "00:00"), (3, "21:30")]) def test_text_wie_in_der_oberflaeche(tag, uhrzeit): lauf = _fenster("fenster_text", MC_WARTUNGSFENSTER=f"{tag} {uhrzeit}") assert lauf.returncode == 0, lauf.stderr assert lauf.stdout == einstellungen.neustart_fenster(tag, uhrzeit) @pytest.mark.skipif(shutil.which("jq") is None, reason="jq fehlt hier (auf der Box ist es da)") def test_fenster_aus_der_einstellungsdatei(tmp_path): datei = tmp_path / "mc2-einstellungen.json" datei.write_text(json.dumps({"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}), encoding="utf-8") lauf = _fenster("fenster_text", MC_EINSTELLUNGEN=datei.as_posix()) assert lauf.stdout == "Samstag 05:00–07:59" def test_ohne_datei_gilt_sonntag(): assert _fenster("fenster_text").stdout == "Sonntag 04:00–06:59" def test_autoupdate_nutzt_das_fenster(): skript = (DEPLOY / "autoupdate.sh").read_text(encoding="utf-8") assert '. "$SRC_DIR/wartungsfenster.sh"' in skript assert "! im_wartungsfenster" in skript and "$(fenster_text)" in skript assert '[ "$tag" != "7" ]' not in skript # die alte, feste Sonntagsregel ist raus # --- deploy.sh und die Einstellungen ------------------------------------------------------------------ def _befehlszeilen(skript: str) -> list[str]: return [z for z in skript.splitlines() if z.strip() and not z.strip().startswith("#")] def test_deploy_fasst_das_zeitfenster_nicht_an(): """Das Drop-in gehört der Oberfläche: kein Befehl in deploy.sh nennt es, keiner räumt Drop-in-Ordner weg.""" for zeile in _befehlszeilen((DEPLOY / "deploy.sh").read_text(encoding="utf-8")): assert "mc2-autoupdate.timer.d" not in zeile and "zeitfenster" not in zeile, zeile assert not re.search(r"\brm\b.*(\.d\b|systemd/user)", zeile), zeile def test_deploy_respektiert_den_radar_schalter(): skript = (DEPLOY / "deploy.sh").read_text(encoding="utf-8") stufe2 = skript[skript.index("stufe2() {"):skript.index("homelab_mitziehen() {")] assert "if radar_aus; then" in stufe2 and 'aktiv="${aktiv//mc2-radar.timer/}"' in stufe2 assert "systemctl --user disable --now mc2-radar.timer" in stufe2 assert "systemctl --user enable $aktiv" in stufe2 and "enable $AKTIV" not in stufe2 # Kein Start des Radars ohne den Schalter und ohne den Stempel gegen das Nachholen. for zeile in _befehlszeilen(stufe2): if "systemctl --user start" in zeile and "mc2-radar.timer" in zeile: assert zeile.strip() == "systemctl --user start mc2-radar.timer" start = stufe2.index("systemctl --user start mc2-radar.timer") assert stufe2.rindex('touch "$HOME/.local/share/systemd/timers/stamp-mc2-radar.timer"', 0, start) > \ stufe2.rindex('[ "$radar_an" = 1 ]', 0, start) @pytest.fixture def radar_aus(tmp_path): """Die echte Funktion radar_aus aus deploy.sh, mit einem python3, das es auch am PC gibt.""" skript = (DEPLOY / "deploy.sh").read_text(encoding="utf-8") funktion = re.search(r"^radar_aus\(\) \{\n.*?^\}\n", skript, re.DOTALL | re.MULTILINE).group(0) shim = tmp_path / "bin" shim.mkdir() (shim / "python3").write_text(f'#!/bin/sh\nexec "{Path(sys.executable).as_posix()}" "$@"\n', encoding="utf-8", newline="\n") (shim / "python3").chmod(0o755) def pruefen(inhalt: str | None) -> bool: datei = tmp_path / "mc2-einstellungen.json" datei.unlink(missing_ok=True) if inhalt is not None: datei.write_text(inhalt, encoding="utf-8") lauf = subprocess.run([BASH, "-c", f'{funktion}\nradar_aus'], capture_output=True, text=True, timeout=60, env={**os.environ, "EINSTELLUNGEN": datei.as_posix(), "PATH": f"{shim}{os.pathsep}{os.environ.get('PATH', '')}"}) return lauf.returncode == 0 return pruefen @pytest.mark.parametrize("inhalt, aus", [ (None, False), # keine Datei: an, wie vor dem Schalter ('{"radar": {"an": false}}', True), ('{"radar": {"an": true}}', False), ('{"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}', False), ("kaputt", False), ("[]", False), ('{"radar": "aus"}', False), ]) def test_radar_aus_liest_den_schalter(radar_aus, inhalt, aus): assert radar_aus(inhalt) is aus