server-up/tests/test_ui_logica.py
Ramon 3ee09eebc7
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m12s
fix(stacks): geslaagde wijziging niet meer terugdraaien
Het audit-logboek las request.remote_addr uit in de achtergrondthread van de
job. De RuntimeError die daarop volgde belandde in de foutafhandeling, die de
zojuist gemaakte backup terugzette — inclusief het oude compose-bestand.

- Adres vóór de threadstart vastleggen bij herconfigureren, koppelen, backup
  maken en terugrollen.
- UniFi: init-script probeert de beheerders 'unifi' en 'root' en herkent een
  server zonder authenticatie, zodat ook een oud Mongo-volume gerepareerd
  wordt; bestaande rollen blijven staan en 'backup'/'restore' worden aangevuld.
- UniFi: healthcheck van Mongo zonder inloggegevens, anders bleef de container
  ongezond en startte de reparatiecontainer nooit.
- Backups: mongodump/mongorestore gebruiken de beheerder uit compose in plaats
  van de verzonnen gebruiker 'app'.
- Appkaart: knop die de webinterface opent, met het adres uit /api/stacks.
- Joblog: geen dubbele regels meer doordat polls elkaar konden overlappen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 16:44:01 +02:00

115 lines
4.8 KiB
Python

"""De logica van het invulmenu, uitgevoerd zoals de browser het doet.
De stapindeling, de geavanceerd-schakelaar en het blokkeren van 'Volgende'
zitten in JavaScript. Een Python-test kan daar niets over zeggen, dus draaien we
de echte component uit index.html in Node. Zonder Node slaat dit zichzelf over.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
SCRIPT = WORTEL / "tests" / "js" / "menu.test.js"
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_invulmenu_logica():
r = subprocess.run(["node", str(SCRIPT)], capture_output=True, text=True,
timeout=60, cwd=str(WORTEL))
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_joblog_vraagt_niet_twee_keer_tegelijk():
"""Elke 400 ms een nieuwe poll starten terwijl de vorige nog loopt, laat
meerdere verzoeken met dezelfde offset vertrekken. Bij een trage stap —
een backup terugzetten bijvoorbeeld — kwam hetzelfde blok logregels dan
twee tot vijf keer in beeld."""
r = subprocess.run(["node", str(WORTEL / "tests" / "js" / "joblog.test.js")],
capture_output=True, text=True, timeout=60, cwd=str(WORTEL))
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_installatiemodal_in_een_echte_dom():
"""Rendert de modal met Alpine en klikt op Volgende.
Losse logica zegt niets over de binding: `:disabled="lijst.length"` gaf bij
een lege lijst 0, en Alpine haalt een boolean-attribuut alleen weg bij
null, undefined of false — dus de knop stond juist uit.
"""
r = subprocess.run(["node", str(WORTEL / "tests" / "js" / "modal.dom.test.js")],
capture_output=True, text=True, timeout=90, cwd=str(WORTEL))
if r.returncode == 2:
pytest.skip(r.stdout.strip() or "jsdom ontbreekt")
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_javascript_van_de_pagina_parseert():
"""Een typefout in de component maakt de hele interface blanco; dat wil je
niet pas in de browser ontdekken."""
import re
html = (WORTEL / "server-up" / "templates" / "index.html").read_text(encoding="utf-8")
blokken = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
grootste = max(blokken, key=len)
r = subprocess.run(["node", "--check", "-"], input=grootste,
capture_output=True, text=True, timeout=30)
assert r.returncode == 0, r.stderr
def test_de_html_van_de_pagina_is_gebalanceerd():
"""Een tag die niet dichtgaat maakt de pagina stil kapot.
De hele interface is één sjabloon van ruim drieduizend regels met Alpine-
templates die in elkaar zitten. Zet je bij het bewerken één `</div>`
verkeerd, dan schuift de rest van de pagina in de verkeerde tak en zie je
dat pas in de browser — als je die kant op klikt.
"""
from html.parser import HTMLParser
# Tags die geen sluiting hebben; svg-vormen zitten erbij omdat de iconen
# als inline svg meekomen.
LEEG = {"input", "br", "img", "hr", "meta", "link", "source", "area", "col",
"embed", "track", "wbr", "path", "circle", "rect", "line",
"polyline", "polygon", "use"}
class Balans(HTMLParser):
def __init__(self):
super().__init__()
self.stapel = []
self.fouten = []
def handle_starttag(self, tag, attrs):
if tag not in LEEG:
self.stapel.append((tag, self.getpos()[0]))
def handle_endtag(self, tag):
if tag in LEEG:
return
if not self.stapel:
self.fouten.append(f"regel {self.getpos()[0]}: </{tag}> zonder opening")
elif self.stapel[-1][0] != tag:
open_tag, regel = self.stapel[-1]
self.fouten.append(
f"regel {self.getpos()[0]}: </{tag}> sluit <{open_tag}> "
f"van regel {regel} af")
else:
self.stapel.pop()
p = Balans()
p.feed((WORTEL / "server-up" / "templates" / "index.html")
.read_text(encoding="utf-8"))
assert not p.fouten, p.fouten[:5]
assert not p.stapel, [f"<{t}> op regel {r} gaat nooit dicht" for t, r in p.stapel[:5]]
def test_modals_blijven_binnen_het_mobiele_scherm_en_scrollbaar():
html = (WORTEL / "server-up" / "templates" / "index.html").read_text(
encoding="utf-8")
assert "height: 100dvh" in html
assert "max-height: 100dvh" in html
assert ".modal-body" in html and "overflow-y-auto" in html
assert "-webkit-overflow-scrolling: touch" in html