Add browser coverage and tested inventory fixes
Some checks failed
Tests / postgres (push) Has been cancelled

This commit is contained in:
DenisToxic
2026-10-02 09:30:03 +02:00
parent b4ed13a709
commit 4a3a9aeefb
9 changed files with 569 additions and 3 deletions

View File

@@ -1,12 +1,14 @@
.git .git
.venv .venv
__pycache__ __pycache__
**/__pycache__
*.pyc *.pyc
.DS_Store .DS_Store
.env .env
*.sqlite3 *.sqlite3
staticfiles staticfiles
backups backups
deploy
media media
*.zip *.zip
*.tar.gz *.tar.gz
@@ -14,4 +16,5 @@ media
.pytest_cache .pytest_cache
.coverage .coverage
tls tls
traefik
traefik/dynamic/tls.yml traefik/dynamic/tls.yml

View File

@@ -220,6 +220,25 @@ mit `python manage.py test --settings=csr_inventory.test_settings` ausgeführt
werden; PostgreSQL-Sperrtests werden dabei übersprungen. Für Tests `DEBUG=true` werden; PostgreSQL-Sperrtests werden dabei übersprungen. Für Tests `DEBUG=true`
und `SECURE_SSL_REDIRECT=false` in der Prozessumgebung setzen. und `SECURE_SSL_REDIRECT=false` in der Prozessumgebung setzen.
Browserprüfung mit einer automatisch angelegten und anschließend entfernten
SQLite-Datenbank (berührt die produktive PostgreSQL-Datenbank nicht):
```bash
pip install playwright
python -m playwright install chromium --only-shell
python scripts/test_browser.py
```
Der Playwright-Lauf prüft Anmeldung, Rollen, Geräteliste, Erfassung,
Bearbeitung, Ablauf und Aufgaben, Archiv, Einstellungen, Import/Export,
Änderungshistorie sowie Mobilnavigation und Dunkelmodus.
Für einen bewusst beschreibbaren Testserver kann `scripts/test_server_browser.py`
mit `CSR_TEST_BASE_URL`, `CSR_TEST_USER` und `CSR_TEST_PASSWORD` ausgeführt
werden. Der Lauf legt eindeutig markierte Testgeräte an und gibt die Kennung
`TEST_MARKER` zur anschließenden Bereinigung aus. Er akzeptiert das lokale
selbstsignierte HTTPS-Zertifikat des Testservers.
Docker-Prüfung auf einem Rechner mit Docker: Docker-Prüfung auf einem Rechner mit Docker:
```bash ```bash

View File

@@ -176,3 +176,23 @@
- Die CI-Konfiguration wurde von GitHub Actions nach Gitea Actions verschoben. - Die CI-Konfiguration wurde von GitHub Actions nach Gitea Actions verschoben.
Öffentliche Hilfsaktionen werden ausdrücklich von `gitea.com` geladen, nicht Öffentliche Hilfsaktionen werden ausdrücklich von `gitea.com` geladen, nicht
über die GitHub-Standardeinstellung von Gitea. über die GitHub-Standardeinstellung von Gitea.
## 29. September 2026 – Browserprüfung mit Playwright
- Ein wiederholbarer Playwright-Test prüft die wichtigsten Bedienabläufe in
Chromium mit einer kurzlebigen, getrennten Datenbank. Er deckt auch Rollen,
Import/Export, Änderungsverlauf, Mobilnavigation und Dunkelmodus ab.
- Nach Anwendung eines Filters schließt sich die Filterfläche wieder. Die
aktiven Filter bleiben am Filterknopf sichtbar; Gerätedaten lassen sich
sofort anklicken und bearbeiten.
- Geräte können nun direkt auf ihrer Detailseite archiviert werden. Der
vorhandene Archivbereich erlaubt anschließend die Wiederherstellung.
- Beim Excel-Import lassen sich jetzt auch Anschlüsse, Auflösung,
Druckverfahren, Duplex und Farbdruck zuordnen. Damit können diese
typspezifischen Angaben aus einem Export wieder übernommen werden.
- Die Änderungen wurden zusätzlich auf dem Testserver mit PostgreSQL 17
geprüft. Ein Playwright-Lauf über HTTPS sowie alle 66 Django-Tests im
Container waren erfolgreich. Angelegte Testdatensätze wurden entfernt.
- `deploy/`, `traefik/` und verschachtelte Python-Caches sind vom Docker-
Buildkontext ausgeschlossen; so gelingt der Web-Build auch bei
zugriffsbeschränkten Betriebsdateien auf dem Server.

View File

@@ -0,0 +1,10 @@
"""Settings for isolated browser tests against a temporary SQLite database."""
import os
from pathlib import Path
from .test_settings import * # noqa: F403
DATABASES["default"]["NAME"] = os.environ["CSR_BROWSER_TEST_DB"]
MEDIA_ROOT = Path(os.environ["CSR_BROWSER_TEST_MEDIA"])

View File

@@ -33,5 +33,6 @@
{% if definitions %}<section><h2>Zusatzfelder</h2><div class="form-grid">{% for field in definitions %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="custom_{{ field.key }}"><option value="">—</option>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key == option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'multi_select' %}<select name="custom_{{ field.key }}" multiple>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key|contains_value:option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'long_text' %}<textarea name="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true">JA</option><option value="false">NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key|german_date }}" type="text" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date>{% else %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key }}" type="{% if field.field_type == 'integer' or field.field_type == 'decimal' %}number{% else %}text{% endif %}">{% endif %}{% if field.unit %}<small>{{ field.unit }}</small>{% endif %}</label>{% endfor %}</div></section>{% endif %} {% if definitions %}<section><h2>Zusatzfelder</h2><div class="form-grid">{% for field in definitions %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="custom_{{ field.key }}"><option value="">—</option>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key == option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'multi_select' %}<select name="custom_{{ field.key }}" multiple>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key|contains_value:option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'long_text' %}<textarea name="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true">JA</option><option value="false">NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key|german_date }}" type="text" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date>{% else %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key }}" type="{% if field.field_type == 'integer' or field.field_type == 'decimal' %}number{% else %}text{% endif %}">{% endif %}{% if field.unit %}<small>{{ field.unit }}</small>{% endif %}</label>{% endfor %}</div></section>{% endif %}
<div class="sticky-actions"><a class="btn" href="{{ list_url }}">Abbrechen</a>{% if can_edit_devices %}<button class="btn primary">Änderungen speichern</button>{% endif %}</div> <div class="sticky-actions"><a class="btn" href="{{ list_url }}">Abbrechen</a>{% if can_edit_devices %}<button class="btn primary">Änderungen speichern</button>{% endif %}</div>
</form> </form>
{% if can_edit_devices and not device.archived_at %}<form method="post" action="{% url 'inventory:archive-device' device.pk %}" data-confirm="Gerät #{{ device.number }} wirklich archivieren?" data-confirm-action="Archivieren" class="archive-action">{% csrf_token %}<button class="btn danger" type="submit">Gerät archivieren</button></form>{% endif %}
{{ type_field_map|json_script:'device-type-fields' }} {{ type_field_map|json_script:'device-type-fields' }}
</section>{% endblock %} </section>{% endblock %}

View File

@@ -20,12 +20,12 @@
<nav aria-label="Gerätetypen" class="form-actions"><a class="btn" href="{% url 'inventory:device-list' %}">Alle Typen</a>{% for option in types %}<a class="btn" href="?type={{ option.pk }}">{{ option.label }}</a>{% endfor %}</nav> <nav aria-label="Gerätetypen" class="form-actions"><a class="btn" href="{% url 'inventory:device-list' %}">Alle Typen</a>{% for option in types %}<a class="btn" href="?type={{ option.pk }}">{{ option.label }}</a>{% endfor %}</nav>
<form class="toolbar" method="get" id="filter-form"> <form class="toolbar" method="get" id="filter-form">
<div class="search"><span>⌕</span><input name="q" value="{{ request.GET.q }}" placeholder="Gerätenummer suchen …" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Nach Gerätenummer suchen" data-search-input></div> <div class="search"><span>⌕</span><input name="q" value="{{ request.GET.q }}" placeholder="Gerätenummer suchen …" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Nach Gerätenummer suchen" data-search-input></div>
<button type="button" class="btn filter-toggle {% if has_filters %}active{% endif %}" data-filter-toggle aria-expanded="{% if has_filters %}true{% else %}false{% endif %}">☷ Filter <span class="filter-count" {% if not active_filter_count %}hidden{% endif %}>{{ active_filter_count }}</span></button> <button type="button" class="btn filter-toggle {% if has_filters %}active{% endif %}" data-filter-toggle aria-expanded="false">☷ Filter <span class="filter-count" {% if not active_filter_count %}hidden{% endif %}>{{ active_filter_count }}</span></button>
<label class="page-size">Zeilen <select name="page_size" data-auto-submit><option {% if page_size == 25 %}selected{% endif %}>25</option><option {% if page_size == 50 %}selected{% endif %}>50</option><option {% if page_size == 100 %}selected{% endif %}>100</option></select></label> <label class="page-size">Zeilen <select name="page_size" data-auto-submit><option {% if page_size == 25 %}selected{% endif %}>25</option><option {% if page_size == 50 %}selected{% endif %}>50</option><option {% if page_size == 100 %}selected{% endif %}>100</option></select></label>
<button type="button" class="btn icon" data-columns-toggle title="Spalten auswählen" aria-label="Spalten auswählen">☷</button> <button type="button" class="btn icon" data-columns-toggle title="Spalten auswählen" aria-label="Spalten auswählen">☷</button>
<div class="column-picker" hidden data-columns-panel></div> <div class="column-picker" hidden data-columns-panel></div>
<div class="filter-panel" {% if not has_filters %}hidden{% endif %} data-filter-panel> <div class="filter-panel" hidden data-filter-panel>
<header class="filter-panel-head"><div><h2>Geräte filtern</h2><p>Mehrere Angaben lassen sich miteinander kombinieren.</p></div><button type="button" class="icon-close" data-filter-close aria-label="Filter schließen">×</button></header> <header class="filter-panel-head"><div><h2>Geräte filtern</h2><p>Mehrere Angaben lassen sich miteinander kombinieren.</p></div><button type="button" class="icon-close" data-filter-close aria-label="Filter schließen">×</button></header>
<div class="filter-panel-body"> <div class="filter-panel-body">
<section class="filter-section"> <section class="filter-section">

View File

@@ -793,7 +793,10 @@ def import_map(request: HttpRequest, pk: int) -> HttpResponse:
("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"), ("recipient", "Empfänger"), ("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"), ("recipient", "Empfänger"),
("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"), ("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"),
("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"), ("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"),
("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]] ("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"),
("resolution", "Auflösung"), ("print_technology", "Druckverfahren"),
("duplex", "Duplex"), ("color_printing", "Farbdruck"),
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]
if request.method == "POST": if request.method == "POST":
mapping = {index: request.POST.get(f"column_{index}", "") for index in range(len(job.headers))} mapping = {index: request.POST.get(f"column_{index}", "") for index in range(len(job.headers))}
try: try:

355
scripts/test_browser.py Normal file
View File

@@ -0,0 +1,355 @@
"""Exercise the main UI flows with Playwright and a disposable local database.
Install the optional tooling with ``pip install playwright`` and
``python -m playwright install chromium --only-shell``, then run this script.
"""
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
PASSWORD = "Browser-Test-2026!"
def available_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def start_server(base_url, port, log_file):
server = subprocess.Popen(
[sys.executable, str(ROOT / "manage.py"), "runserver", f"127.0.0.1:{port}", "--noreload"],
cwd=ROOT,
env=os.environ.copy(),
stdout=log_file,
stderr=subprocess.STDOUT,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
for _ in range(100):
if server.poll() is not None:
raise RuntimeError("Browser test server exited before startup")
try:
with urllib.request.urlopen(f"{base_url}/health/", timeout=1) as response:
if response.status == 200:
return server
except (urllib.error.URLError, TimeoutError):
time.sleep(0.1)
server.terminate()
raise RuntimeError("Browser test server did not become healthy")
def login(page, base_url, username):
page.goto(f"{base_url}/accounts/login/")
page.locator('[name="username"]').fill(username)
page.locator('[name="password"]').fill(PASSWORD)
page.locator("form button").click()
assert page.url.rstrip("/") == base_url, f"Login failed for {username}: {page.url}"
def run():
try:
from playwright.sync_api import sync_playwright, expect
except ImportError as exc:
raise SystemExit("Install the optional Playwright package to run browser tests") from exc
with tempfile.TemporaryDirectory(prefix="csr-browser-") as temporary:
os.environ.update({
"DJANGO_SETTINGS_MODULE": "csr_inventory.browser_test_settings",
"CSR_BROWSER_TEST_DB": str(Path(temporary) / "browser.sqlite3"),
"CSR_BROWSER_TEST_MEDIA": str(Path(temporary) / "media"),
"DEBUG": "true",
})
import django
from django.core.management import call_command
from django.db import connections
django.setup()
from django.contrib.auth.models import User
from openpyxl import Workbook
from inventory.models import ChoiceValue, Device, Employee, UserProfile
call_command("migrate", interactive=False, verbosity=0)
call_command("seed_initial_data", verbosity=0)
User.objects.create_superuser("browser_admin", "browser@example.invalid", PASSWORD)
editor = User.objects.create_user("browser_editor", password=PASSWORD)
editor.profile.role = UserProfile.Role.EDITOR
editor.profile.save()
User.objects.create_user("browser_viewer", password=PASSWORD)
status = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Eingang")
laptop = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Laptop")
printer = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Drucker")
monitor = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Bildschirm")
employee = Employee.objects.get(code="JH")
for device_type, model in ((laptop, "Fixture Laptop"), (printer, "Fixture Printer"), (monitor, "Fixture Monitor")):
Device.objects.create(status=status, device_type=device_type, model=model, employee=employee)
port = available_port()
base_url = f"http://127.0.0.1:{port}"
with (Path(temporary) / "server.log").open("w", encoding="utf-8") as log_file:
server = start_server(base_url, port, log_file)
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1440, "height": 900}, accept_downloads=True)
page = context.new_page()
page.set_default_timeout(8000)
page_errors = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
page.goto(f"{base_url}/")
expect(page).to_have_url(f"{base_url}/accounts/login/?next=/")
login(page, base_url, "browser_admin")
print("PASS login and protected routes", flush=True)
for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/devices/new/",
"/devices/1/", "/configuration/", "/users/", "/import/", "/archive/", "/history/"):
response = page.goto(f"{base_url}{route}")
assert response.status == 200, f"{route}: HTTP {response.status}"
assert page.locator("h1").count() > 0, route
print("PASS all main pages", flush=True)
page.goto(f"{base_url}/devices/new/")
page.locator('[name="device_type"]').select_option(label="Laptop")
assert page.locator('[data-hardware-field="ram_gb"]').is_visible()
assert not page.locator('[data-hardware-field="print_technology"]').is_visible()
page.locator('[name="device_type"]').select_option(label="Drucker")
assert page.locator('[data-hardware-field="print_technology"]').is_visible()
assert not page.locator('[data-hardware-field="ram_gb"]').is_visible()
page.locator('[name="device_type"]').select_option(label="Laptop")
page.locator('[name="quantity"]').fill("2")
page.locator('[name="model"]').fill("Playwright Laptop")
page.locator('[name="ram_gb"]').fill("16")
page.locator('[name="ssd_gb"]').fill("512")
page.locator('[name="employee"]').select_option(index=1)
page.locator('form[data-device-type-form] button').click()
expect(page.locator(".toast.success")).to_contain_text("2 Geräte")
page.goto(f"{base_url}/")
assert page.locator('tr[data-device-id] td[data-column="model"]').all_inner_texts().count("Playwright Laptop") == 2
print("PASS dynamic type fields and batch creation", flush=True)
page.locator('[data-search-input]').fill("1")
expect(page.locator('tr[data-device-id]')).to_have_count(1)
assert page.locator('tr[data-device-id] td[data-column="number"]').inner_text() == "1"
page.locator('[data-search-input]').fill("")
expect(page.locator('tr[data-device-id]')).to_have_count(5)
page.locator('[data-filter-toggle]').click()
page.locator('[name="model_contains"]').fill("Playwright Laptop")
page.get_by_role("button", name="Filter anwenden").click()
expect(page.locator('tr[data-device-id]')).to_have_count(2)
assert page.locator('[data-filter-panel]').is_hidden()
page.locator('[data-columns-toggle]').click()
shelf_column = page.locator('[data-columns-panel] label[data-column="shelf"] input')
shelf_column.uncheck()
assert not page.locator('th[data-column="shelf"]').is_visible()
shelf_column.check()
assert page.locator('th[data-column="shelf"]').is_visible()
page.locator('[data-columns-toggle]').click()
print("PASS live search, filters, and column picker", flush=True)
first_row = page.locator('tr[data-device-id]').first
first_row.locator('td[data-field="model"]').dblclick()
editor_input = first_row.locator('td[data-field="model"] input')
editor_input.fill("Playwright Laptop Edited")
editor_input.press("Enter")
expect(first_row.locator('td[data-field="model"]')).to_have_text("Playwright Laptop Edited")
page.locator('[data-select-all]').check()
expect(page.locator('[data-bulk-count]')).to_have_text("2")
page.locator('[data-bulk-form] select[name="status"]').select_option(label="Fertig")
page.locator('[data-bulk-submit]').click()
page.locator('.confirm-box button').last.click()
expect(page.locator('tr[data-device-id]')).to_have_count(2)
assert all("Fertig" in text for text in page.locator('tr[data-device-id] td[data-column="status"]').all_inner_texts())
page.locator('.export-menu summary').click()
with page.expect_download() as download_event:
page.get_by_role('link', name='Als CSV exportieren').click()
assert download_event.value.suggested_filename.endswith('.csv')
print("PASS inline edit, bulk update, and filtered CSV export", flush=True)
page.goto(f"{base_url}/devices/4/")
page.locator('.detail-form [name="device_type"]').select_option(label="Drucker")
assert page.locator('[data-hardware-field="print_technology"]').is_visible()
assert page.locator('[data-hardware-field="ram_gb"]').is_hidden()
page.locator('.detail-form [name="print_technology"]').fill("Laser")
page.locator('.detail-form button').click()
expect(page.locator('.detail-form [name="print_technology"]')).to_have_value("Laser")
print("PASS detail editing and device-type field changes", flush=True)
page.goto(f"{base_url}/devices/1/")
task_form = page.locator('form[action="/devices/1/tasks/"]')
task_form.locator('[name="title"]').fill("Akku prüfen")
task_form.locator('[name="assigned_to"]').select_option(label="browser_editor")
task_form.locator('button').click()
expect(page.get_by_text("Akku prüfen")).to_be_visible()
stage_form = page.locator('form[action="/devices/1/stage/"]')
stage_form.locator('[name="stage"]').select_option("INSPECTION")
stage_form.locator('button').click()
expect(page.locator('h2').first).to_contain_text("Inspektion")
page.goto(f"{base_url}/ablauf/")
card = page.locator('.workflow-card').filter(has=page.locator('a[href="/devices/1/"]'))
inspection = card.locator('form[action="/devices/1/inspection/"]')
inspection.locator('[name="defects"]').fill("Akku schwach")
inspection.locator('[name="ram_gb"]').fill("8")
inspection.locator('button').click()
expect(card).to_contain_text("Akku schwach")
stage_form = card.locator('form[action="/devices/1/stage/"]')
stage_form.locator('[name="stage"]').select_option("REPAIR")
stage_form.locator('button').click()
repair = card.locator('form[action="/devices/1/repairs/"]')
repair.locator('[name="work_done"]').fill("Akku ersetzt")
repair.locator('button').click()
expect(card).to_contain_text("Akku ersetzt")
stage_form = card.locator('form[action="/devices/1/stage/"]')
stage_form.locator('[name="stage"]').select_option("STORAGE")
stage_form.locator('button').click()
assert card.locator('form[action="/devices/1/repairs/"]').is_visible()
page.goto(f"{base_url}/aufgaben/")
page.locator('form[action^="/devices/1/tasks/"][action$="/complete/"] button').click()
page.goto(f"{base_url}/ablauf/")
stage_form = card.locator('form[action="/devices/1/stage/"]')
stage_form.locator('[name="stage"]').select_option("STORAGE")
stage_form.locator('button').click()
storage = card.locator('form[action="/devices/1/recipient/"]')
expect(storage).to_be_visible()
stage_form = card.locator('form[action="/devices/1/stage/"]')
stage_form.locator('[name="stage"]').select_option("ISSUED")
stage_form.locator('button').click()
expect(storage).to_be_visible()
storage.locator('[name="recipient"]').fill("Testverein")
storage.locator('button').click()
stage_form = card.locator('form[action="/devices/1/stage/"]')
stage_form.locator('[name="stage"]').select_option("ISSUED")
stage_form.locator('button').click()
expect(page.locator('#stage-ISSUED').locator('../..')).to_contain_text("Fixture Laptop")
print("PASS task gating, inspection, repair, storage, and issuance", flush=True)
page.goto(f"{base_url}/devices/3/")
page.get_by_role("button", name="Gerät archivieren").click()
page.locator('.confirm-box button').last.click()
assert page.url.rstrip("/") == base_url
page.goto(f"{base_url}/archive/")
expect(page.locator("tbody")).to_contain_text("Fixture Monitor")
page.locator('form[action="/archive/3/restore/"] button').click()
expect(page.locator("tbody")).not_to_contain_text("Fixture Monitor")
page.goto(f"{base_url}/devices/3/")
expect(page.locator('h1')).to_contain_text("#3")
print("PASS archive and restore", flush=True)
page.goto(f"{base_url}/")
page.locator('.sidebar-theme').click()
assert page.locator('html').get_attribute('data-theme') == 'dark'
page.reload()
assert page.locator('html').get_attribute('data-theme') == 'dark'
page.locator('.sidebar-theme').click()
assert page.locator('html').get_attribute('data-theme') == 'light'
mobile = browser.new_context(viewport={"width": 390, "height": 844})
mobile_page = mobile.new_page()
login(mobile_page, base_url, "browser_admin")
expect(mobile_page.locator('.mobile-header')).to_be_visible()
mobile_page.locator('[data-sidebar-toggle]').click()
expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open')
mobile.close()
print("PASS dark mode persistence and mobile navigation", flush=True)
page.goto(f"{base_url}/users/new/")
page.locator('[name="username"]').fill('playwright_user')
page.locator('[name="password"]').fill('SecureBrowserTest-2026!')
page.locator('[name="role"]').select_option('EDITOR')
page.locator('form.app-form button').click()
expect(page.locator('tbody')).to_contain_text('playwright_user')
page.goto(f"{base_url}/configuration/employee/new/")
page.locator('[name="code"]').fill('PW')
page.locator('[name="name"]').fill('Playwright Tester')
page.locator('form.app-form button').click()
expect(page.locator('.employee-list')).to_contain_text('Playwright Tester')
print("PASS user and employee administration", flush=True)
page.goto(f"{base_url}/import/")
for label, suffix in (("XLSX herunterladen", ".xlsx"), ("ODS für LibreOffice", ".ods")):
with page.expect_download() as download_event:
page.get_by_role('link', name=label).click()
assert download_event.value.suggested_filename.endswith(suffix)
workbook = Workbook()
workbook.active.append(["Modell", "Typ", "Status", "Druckverfahren", "Anschlüsse", "Duplex", "Farbdruck"])
workbook.active.append(["Imported Browser Device", "Drucker", "Eingang", "Laser", "USB-C", "JA", "NEIN"])
import_path = Path(temporary) / "import.xlsx"
workbook.save(import_path)
page.locator('input[type="file"]').set_input_files(import_path)
page.get_by_role('button', name='Datei analysieren').click()
expect(page.locator('h1')).to_contain_text('Spalten zuordnen')
assert [page.locator(f'[name="column_{index}"]').input_value() for index in range(7)] == [
'model', 'device_type', 'status', 'print_technology', 'ports', 'duplex', 'color_printing',
]
page.get_by_role('button', name='Import bestätigen').click()
page.goto(f"{base_url}/")
expect(page.locator('td[data-column="model"]').filter(has_text='Imported Browser Device')).to_have_count(1)
imported_row = page.locator('tr[data-device-id]').filter(has_text='Imported Browser Device')
imported_id = imported_row.get_attribute('data-device-id')
page.goto(f"{base_url}/devices/{imported_id}/")
expect(page.locator('.detail-form [name="print_technology"]')).to_have_value('Laser')
expect(page.locator('.detail-form [name="ports"]')).to_have_value('USB-C')
print("PASS XLSX and ODS exports, XLSX mapping and import", flush=True)
page.goto(f"{base_url}/configuration/field/new/")
page.locator('[name="name"]').fill('Prüfcode')
page.locator('[name="key"]').fill('pruefcode')
page.locator('[name="field_type"]').select_option('text')
page.locator('form.app-form button').click()
expect(page.locator('.config-list').last).to_contain_text('Prüfcode')
page.goto(f"{base_url}/devices/2/")
page.locator('[name="custom_pruefcode"]').fill('PW-123')
page.locator('.detail-form button').click()
expect(page.locator('[name="custom_pruefcode"]')).to_have_value('PW-123')
print("PASS custom field creation and device entry", flush=True)
page.goto(f"{base_url}/history/?q=Playwright%20Laptop%20Edited")
changed_model = page.locator('.history-list article').filter(has_text='Playwright Laptop Edited').first
changed_model.locator('form[action$="/revert/"] button').click()
page.locator('.confirm-box button').last.click()
expect(page.locator('.history-list')).to_contain_text('Zurückgesetzt')
page.goto(f"{base_url}/devices/4/")
expect(page.locator('.detail-form [name="model"]')).to_have_value('Playwright Laptop')
print("PASS audit change rollback", flush=True)
editor_page = context.new_page()
login(editor_page, base_url, "browser_editor")
assert editor_page.goto(f"{base_url}/users/").status == 403
assert editor_page.goto(f"{base_url}/configuration/").status == 403
assert editor_page.goto(f"{base_url}/devices/new/").status == 200
viewer_page = context.new_page()
login(viewer_page, base_url, "browser_viewer")
assert viewer_page.goto(f"{base_url}/devices/new/").status == 403
assert viewer_page.goto(f"{base_url}/archive/").status == 403
assert viewer_page.goto(f"{base_url}/").status == 200
assert viewer_page.locator('[data-device-select]').count() == 0
print("PASS editor and viewer permissions", flush=True)
assert not page_errors, f"Browser JavaScript errors: {page_errors}"
context.close()
browser.close()
finally:
if os.name == "nt":
# The Windows venv launcher leaves its child Python process alive otherwise.
subprocess.run(["taskkill", "/PID", str(server.pid), "/T", "/F"], capture_output=True)
else:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=5)
connections.close_all()
time.sleep(1) # Give Windows time to release SQLite handles after process exit.
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,155 @@
"""Run representative write flows against a disposable deployed test server.
Requires CSR_TEST_BASE_URL, CSR_TEST_USER, and CSR_TEST_PASSWORD. Prints the
unique record prefix so created test data can be removed after inspection.
"""
import os
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from openpyxl import Workbook
from playwright.sync_api import expect, sync_playwright
def run():
base = os.environ["CSR_TEST_BASE_URL"].rstrip("/")
username = os.environ["CSR_TEST_USER"]
password = os.environ["CSR_TEST_PASSWORD"]
marker = f"PW{int(time.time())}"
print(f"TEST_MARKER={marker}", flush=True)
errors = []
with sync_playwright() as playwright, TemporaryDirectory(prefix="csr-server-browser-") as temporary:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context(ignore_https_errors=True, accept_downloads=True)
page = context.new_page()
page.set_default_timeout(10000)
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto(f"{base}/accounts/login/")
page.locator('[name="username"]').fill(username)
page.locator('[name="password"]').fill(password)
page.locator('form button').click()
assert page.url.rstrip("/") == base, f"Login failed: {page.url}"
for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/configuration/", "/users/", "/archive/", "/history/", "/import/"):
response = page.goto(f"{base}{route}")
assert response.status == 200, f"{route}: {response.status}"
print("PASS login and main routes", flush=True)
page.goto(f"{base}/devices/new/")
page.locator('[name="device_type"]').select_option(label="Laptop")
assert page.locator('[data-hardware-field="ram_gb"]').is_visible()
page.locator('[name="model"]').fill(f"{marker}-Laptop")
page.locator('[name="employee"]').select_option(index=1)
page.locator('[name="ram_gb"]').fill("16")
page.locator('[name="ssd_gb"]').fill("512")
page.locator('form[data-device-type-form] button').click()
page.goto(f"{base}/?model_contains={marker}")
expect(page.locator('tr[data-device-id]')).to_have_count(1)
row = page.locator('tr[data-device-id]').first
device_id = row.get_attribute('data-device-id')
assert page.locator('[data-filter-panel]').is_hidden()
row.locator('td[data-field="model"]').dblclick()
field = row.locator('td[data-field="model"] input')
field.fill(f"{marker}-Laptop-Edited")
field.press('Enter')
expect(row.locator('td[data-field="model"]')).to_have_text(f"{marker}-Laptop-Edited")
print(f"PASS create, filter, inline edit: device {device_id}", flush=True)
page.goto(f"{base}/devices/{device_id}/")
task = page.locator(f'form[action="/devices/{device_id}/tasks/"]')
task.locator('[name="title"]').fill(f"{marker}-Akku prüfen")
task.locator('button').click()
stage = page.locator(f'form[action="/devices/{device_id}/stage/"]')
stage.locator('[name="stage"]').select_option('INSPECTION')
stage.locator('button').click()
page.goto(f"{base}/ablauf/")
card = page.locator('.workflow-card').filter(has=page.locator(f'a[href="/devices/{device_id}/"]'))
inspection = card.locator(f'form[action="/devices/{device_id}/inspection/"]')
inspection.locator('[name="defects"]').fill(f"{marker}-Akku schwach")
inspection.locator('[name="ram_gb"]').fill('16')
inspection.locator('button').click()
expect(card).to_contain_text(f"{marker}-Akku schwach")
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
stage.locator('[name="stage"]').select_option('REPAIR')
stage.locator('button').click()
repair = card.locator(f'form[action="/devices/{device_id}/repairs/"]')
repair.locator('[name="work_done"]').fill(f"{marker}-Akku ersetzt")
repair.locator('button').click()
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
stage.locator('[name="stage"]').select_option('STORAGE')
stage.locator('button').click()
expect(repair).to_be_visible() # An open task blocks storage.
page.goto(f"{base}/aufgaben/")
page.locator(f'form[action^="/devices/{device_id}/tasks/"][action$="/complete/"] button').click()
page.goto(f"{base}/ablauf/")
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
stage.locator('[name="stage"]').select_option('STORAGE')
stage.locator('button').click()
storage = card.locator(f'form[action="/devices/{device_id}/recipient/"]')
expect(storage).to_be_visible()
storage.locator('[name="recipient"]').fill(f"{marker}-Recipient")
storage.locator('button').click()
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
stage.locator('[name="stage"]').select_option('ISSUED')
stage.locator('button').click()
expect(page.locator('#stage-ISSUED').locator('../..')).to_contain_text(f"{marker}-Laptop-Edited")
print("PASS PostgreSQL workflow, records, task gating, recipient, issuance", flush=True)
page.goto(f"{base}/devices/{device_id}/")
page.get_by_role('button', name='Gerät archivieren').click()
page.locator('.confirm-box button').last.click()
page.goto(f"{base}/archive/")
expect(page.locator('tbody')).to_contain_text(f"{marker}-Laptop-Edited")
page.locator(f'form[action="/archive/{device_id}/restore/"] button').click()
page.goto(f"{base}/devices/{device_id}/")
assert page.url.rstrip('/').endswith(f'/devices/{device_id}')
expect(page.locator('h1')).to_contain_text('Gerät #')
print("PASS archive and restore", flush=True)
page.goto(f"{base}/import/")
for label, suffix in (("XLSX herunterladen", '.xlsx'), ("ODS für LibreOffice", '.ods')):
with page.expect_download() as event:
page.get_by_role('link', name=label).click()
assert event.value.suggested_filename.endswith(suffix)
workbook = Workbook()
workbook.active.append(['Modell', 'Typ', 'Status', 'Druckverfahren', 'Anschlüsse'])
workbook.active.append([f"{marker}-Printer", 'Drucker', 'Eingang', 'Laser', 'USB-C'])
import_path = Path(temporary) / f"{marker}.xlsx"
workbook.save(import_path)
page.locator('input[type="file"]').set_input_files(import_path)
page.get_by_role('button', name='Datei analysieren').click()
assert [page.locator(f'[name="column_{i}"]').input_value() for i in range(5)] == [
'model', 'device_type', 'status', 'print_technology', 'ports',
]
page.get_by_role('button', name='Import bestätigen').click()
page.goto(f"{base}/?model_contains={marker}-Printer")
expect(page.locator('tr[data-device-id]')).to_have_count(1)
imported_id = page.locator('tr[data-device-id]').get_attribute('data-device-id')
page.goto(f"{base}/devices/{imported_id}/")
expect(page.locator('[name="print_technology"]')).to_have_value('Laser')
expect(page.locator('[name="ports"]')).to_have_value('USB-C')
print(f"PASS PostgreSQL import/export with printer fields: device {imported_id}", flush=True)
page.goto(f"{base}/")
page.locator('.sidebar-theme').click()
assert page.locator('html').get_attribute('data-theme') == 'dark'
page.reload()
assert page.locator('html').get_attribute('data-theme') == 'dark'
mobile = browser.new_context(ignore_https_errors=True, viewport={'width': 390, 'height': 844})
mobile_page = mobile.new_page()
mobile_page.goto(f"{base}/accounts/login/")
mobile_page.locator('[name="username"]').fill(username)
mobile_page.locator('[name="password"]').fill(password)
mobile_page.locator('form button').click()
mobile_page.locator('[data-sidebar-toggle]').click()
expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open')
mobile.close()
print("PASS dark mode and mobile navigation", flush=True)
assert not errors, f"JavaScript errors: {errors}"
context.close()
browser.close()
if __name__ == '__main__':
run()