Portiere PHP-Cronjobs auf Spring-Tasks und ergänze Legacy-Tests

Migriert die weiterhin benötigten Zeitaufgaben einschließlich SOAP-Importen, Betriebsstatus und Wiederanlaufkontrolle. Aktiviert 115 native Spring-Tasks im Docker-Image ohne Cron-Daemon oder PHP und dokumentiert die entfallenen HHA-Aufrufe. Ergänzt PHP-/UI-Testwerkzeuge und aktualisiert den Projektstand auf Version 0.2.0.
This commit is contained in:
Sven committed 2026-10-27 09:53:23 +01:00
1 parent 781a2b82dc
commit 9de2358480
57 files changed
+3251 -7

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
/reports/
/test-results/
/.auth/
/node_modules/
.env*
!.env.example
+95
View File
@@ -0,0 +1,95 @@
# Oberflächentests der alten Votian-PHP-Anwendung
Die Tests bedienen die echte Anwendung unter **https://test.sb.assecutor.de**. Sie verwenden echte Anmeldung, Kundenwahl, Preisberechnung und Auftragsformulare; es gibt keine simulierten Geschäftsdaten. Die Anwendung läuft auf `172.16.0.158` aus `/data/0/votian-test/html`. Die Tests liegen daneben unter `/data/0/votian-test/tests/ui`, außerhalb des Webroots. PHP-Produktivcode wird nicht verändert.
## Ausführen
Auf dem Server, mit interaktivem Terminal:
```sh
/data/0/votian-test/tests/ui/run-server.sh
```
Benutzername und Passwort werden abgefragt. Der Standardlauf speichert keine Aufträge; der Speichertest wird ausdrücklich übersprungen. Für den vollständigen Lauf einschließlich **eines neuen Testauftrags**:
```sh
/data/0/votian-test/tests/ui/run-server.sh --create-order
```
Einen bereits angelegten Testauftrag erneut prüfen, ohne einen weiteren anzulegen:
```sh
/data/0/votian-test/tests/ui/run-server.sh --verify-order 21891310 UI-TEST-20261026T094639-670e98
```
Der Runner startet einen kurzlebigen Docker-Container mit `mcr.microsoft.com/playwright:v1.63.0-noble`, installiert die drei per Lockfile festgelegten npm-Pakete im Testverzeichnis und startet Firefox. Er bindet nur das Testverzeichnis ein, keine Anwendungs- oder Datenbankverzeichnisse. Docker und HTTPS-Zugriff auf die Testanwendung sind erforderlich. Parallel gestartete Läufe im selben Verzeichnis vermeiden.
Lokal, mit Node.js 20 oder neuer und Python 3:
```sh
cd legacy-ui-tests
npm ci
npx playwright install firefox
python3 run.py
```
Alternativ im Projektstamm: `npm run test:legacy-ui`. Die Servervariante ist auf diesem Arbeitsplatz die geprüfte Ausführungsumgebung: Der lokale Firefox startete hier wegen eines Profilverzeichnisfehlers nicht.
## Testumfang
15 Tests prüfen:
- JavaScript-Fehler beim Öffnen und Aufbau der vier Frames;
- leeres Formular und gesperrte Sendeschaltfläche;
- Bezahlerwahl über die echte Kundenliste;
- Auftragseingang „Sonstiges“ und Rücksetzen des Zusatzfelds;
- Abhängigkeiten zwischen Angebot, manueller Disposition und unvollständigem Status;
- Versicherung ein-/ausschalten, Warenwert und drei ungültige Eingaben;
- Vertauschen und Entfernen von Stationen;
- Vorbereitung mit zwei Testkunden, Besteller, Hinweisen und 10,00 € Festpreis;
- optionales Speichern über „Senden“, Serverbestätigung mit Auftragsnummer und erneutes Laden einer frischen Detailansicht. Dort werden Stationsreihenfolge, Hinweise, Festpreis und „UNFERTIG!“ geprüft.
Es handelt sich um funktionale Tests der Einzelerfassung, nicht um vollständige Abdeckung der Verwaltung, Disposition oder Abrechnung. Die Unit-Tests stehen separat in `../legacy-php-tests/`.
## Testdaten und Schreibzugriffe
Der Speichertest nutzt ausschließlich **Testkunde Assecutor 1** (Kostenstelle `878914`) und **Testkunde Assecutor 2** (`878915`). Beide Stammsätze sind als „Testaufträge Assecutor“ gekennzeichnet. Jeder neue Auftrag erhält einen eindeutigen `UI-TEST-…`-Marker im Besteller und in beiden Stationshinweisen, darunter „NICHT AUSFÜHREN“.
Vor dem Senden validiert eine Netzwerksperre die tatsächlichen Formulardaten: genau zwei markierte Teststationen, Testkunde 1 als Bezahler, 10,00 €, manuelle Disposition, unvollständig, kein Kurier, keine Serien-/Mehrfachaufträge, keine Höherversicherung und `sendmail=0`. Pro Test wird höchstens ein Speichervorgang zugelassen; Wiederholungen und Retries des Speichertests werden abgewiesen. Ein vor dem Versand angelegter Eintrag in `reports/created-orders.jsonl` verhindert nach einem unklaren Ausgang erneutes Speichern, bis das Ergebnis geprüft wurde. Ein Testauftrag wird nicht automatisch gelöscht oder abgeschlossen.
Andere fachliche Schreibzugriffe werden vor dem Versand blockiert. Freigegeben sind Anmeldung, gelesene Nachschlagefunktionen, Kunden-/Tourensuche und Preisberechnung. Die automatische News-Ticker-Bestätigung und JavaScript-Fehlertelemetrie werden im Browser beantwortet, ohne sie zu speichern. Google-Maps-Telemetrie wird unterdrückt. Normale serverseitige Sitzungen, Zugriffsprotokolle und Folgearbeiten der PHP-Anwendung bleiben möglich; dies ist keine vollständig schreibfreie Datenbank-Sandbox.
Zugangsdaten werden nur abgefragt bzw. aus `VOTIAN_UI_USERNAME` und `VOTIAN_UI_PASSWORD` gelesen. Sie werden nicht in Dateien gespeichert. Anmeldung und Sitzungszustand bleiben im Speicher; Traces und Videos sind deaktiviert. Berichte und Screenshots sind von Git ausgeschlossen und können Daten der Testanwendung enthalten.
## Bekannter Fehler und Browserwahl
`jobs/job_edit.php:13` führt `top.opener.top.job_edit = this` aus, obwohl die über `target="_blank"` geöffnete Seite kein `window.opener` besitzt. Der Test „Auftragserfassung startet ohne JavaScript-Ausnahme“ bleibt deshalb absichtlich rot. Der exakt erkannte Fehler wird in den anderen Tests als Anhang protokolliert; neue JavaScript-Ausnahmen lassen jeden betroffenen Test scheitern. Es gibt kein `test.fail()` oder stilles Überspringen dieses Anwendungsfehlers.
Im vollständigen Speichertest trat zusätzlich `TypeError: can't access property "currentTSZ", parent.job_options.document.tourOptions is undefined` auf. Die Preisantwort greift auf ein während des Speicherns neu geladenes Formular zu (erzeugter JavaScript-Code aus `jobs/job_options.php:3357`). Das Speichern und die Prüfung der frischen Detailansicht waren trotzdem erfolgreich. Der Test bleibt wegen der erfassten Ausnahme rot; sie wird nicht als erlaubter Fehler unterdrückt. Der zeitliche Zusammenhang spricht für eine Race Condition, deren genauer Auslöser gesondert untersucht werden muss.
Für die Netzwerksperre mit den alten `document.write`-Popups wird Firefox verwendet. Playwright 1.63 blockiert diese Popups unter Chromium bereits durch aktiviertes Routing; das ist ein [bestätigter Playwright-Fehler](https://github.com/microsoft/playwright/issues/43496), kein Beleg für einen Speicherfehler der PHP-Anwendung. Der Ablauf wurde dafür nicht durch direkte API- oder Datenbank-Schreibzugriffe ersetzt.
## Ergebnisse
Vollständiger Serverlauf vom **26.10.2026**, mit `--create-order`: **15 ausgeführt, 13 bestanden, 2 fehlgeschlagen, 0 übersprungen**, Laufzeit 41 Sekunden. Beide Fehlschläge betreffen die oben dokumentierten JavaScript-Ausnahmen. Alle fachlichen Prüfungen des gespeicherten Auftrags waren erfolgreich.
Angelegte und erhaltene Testaufträge:
| Auftrag | Marker | Prüfung |
| --- | --- | --- |
| 21891310 | `UI-TEST-20261026T094639-670e98` | Erstlauf; Speicherung zusätzlich per lesender Datenbankabfrage bestätigt, frische Detailansicht geprüft |
| 21891311 | `UI-TEST-20261026T095313-b89f03` | Vollständiger Lauf; Serverbestätigung und Detailansicht geprüft |
Beide verwenden 10,00 € Festpreis, Testkunde 1 als Bezahler, manuelle Disposition, „unvollständig“ und keine Kurierzuweisung. Sie wurden nicht abgeschlossen oder gelöscht.
Geprüftes Container-Image: `sha256:2c1f4e0fd6450f43ddb46d60c2a6df30855a8588e165b1f2559fb0eda8d7ff35` (Tag `mcr.microsoft.com/playwright:v1.63.0-noble`). Im vollständigen Lauf wurde die Datenübertragung nicht simuliert; die Zugangsdaten kamen aus der interaktiven Eingabe.
Nach jedem Lauf: `reports/html/index.html`, `reports/junit.xml`, `reports/results.json` und `test-results/` mit Screenshots. Die Liste angelegter Aufträge wird separat fortgeschrieben und nicht durch den nächsten Testlauf gelöscht.
HTML-Bericht öffnen:
```sh
npx playwright show-report reports/html
```
Der Rückgabecode bleibt bei einem offenen Anwendungsfehler ungleich null. Maßgeblich ist der letzte vollständige Lauf; ein gefilterter Wiederholungslauf überschreibt die Standardberichte.
+60
View File
@@ -0,0 +1,60 @@
{
"name": "votian-legacy-ui-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "votian-legacy-ui-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.63.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "votian-legacy-ui-tests",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": { "test": "python3 run.py" },
"devDependencies": { "@playwright/test": "1.63.0" }
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig } from '@playwright/test';
import { fileURLToPath } from 'node:url';
const report = (name: string) => fileURLToPath(new URL('./reports/' + name, import.meta.url));
export default defineConfig({
testDir: './tests',
fullyParallel: false,
workers: 1,
retries: 0,
maxFailures: 0,
timeout: 45_000,
expect: { timeout: 10_000 },
reporter: [
['list'],
['html', { outputFolder: report('html'), open: 'never' }],
['junit', { outputFile: report('junit.xml') }],
['json', { outputFile: report('results.json') }],
],
outputDir: './test-results',
use: {
baseURL: 'https://test.sb.assecutor.de',
// Chromium routing stalls document.write popups in Playwright 1.63 (#43496).
browserName: 'firefox',
headless: true,
viewport: { width: 1600, height: 1200 },
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
serviceWorkers: 'block',
screenshot: 'only-on-failure',
// A trace could contain login credentials and customer data.
trace: 'off',
video: 'off',
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
});
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
test_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
test_image='mcr.microsoft.com/playwright:v1.63.0-noble'
terminal_flags=(-i)
if [[ -t 0 && -t 1 ]]; then terminal_flags+=(-t); fi
# Only this test directory is mounted. The browser accesses the PHP app via HTTPS.
exec docker run --rm --init --shm-size=512m "${terminal_flags[@]}" \
--mount "type=bind,source=$test_dir,target=/tests" --workdir /tests \
"$test_image" bash -c 'npm ci --ignore-scripts --no-audit --no-fund && exec python3 run.py "$@"' -- "$@"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Prompt credentials without echo; never save them in source or reports."""
from pathlib import Path
import getpass
import os
import subprocess
import sys
suite = Path(__file__).resolve().parent
env = os.environ.copy()
arguments = sys.argv[1:]
if '--verify-order' in arguments:
position = arguments.index('--verify-order')
if len(arguments) < position + 3 or not arguments[position + 1].isdigit() or not arguments[position + 2].startswith('UI-TEST-'):
raise SystemExit('--verify-order benötigt AUFTRAGSNUMMER und UI-TEST-MARKER.')
env['VOTIAN_UI_VERIFY_ORDER_ID'], env['VOTIAN_UI_VERIFY_ORDER_MARKER'] = arguments[position + 1:position + 3]
del arguments[position:position + 3]
if '--create-order' in arguments:
if env.get('VOTIAN_UI_VERIFY_ORDER_ID'):
raise SystemExit('--create-order und --verify-order dürfen nicht kombiniert werden.')
arguments.remove('--create-order')
env['VOTIAN_UI_CREATE_ORDER'] = '1'
print('Speichert einen markierten, unvollständigen Testauftrag ohne Kurier.')
if not env.get('VOTIAN_UI_USERNAME'):
env['VOTIAN_UI_USERNAME'] = input('Votian-Testbenutzer: ')
if not env.get('VOTIAN_UI_PASSWORD'):
env['VOTIAN_UI_PASSWORD'] = getpass.getpass('Votian-Passwort (wird nicht gespeichert): ')
binary = suite / 'node_modules/.bin/playwright'
if not binary.exists():
binary = suite.parent / 'node_modules/.bin/playwright'
if not binary.exists():
raise SystemExit('Playwright fehlt. Zuerst npm ci im Projekt oder im Testverzeichnis ausführen.')
command = [str(binary), 'test', '-c', str(suite / 'playwright.config.ts'), *arguments]
try:
sys.exit(subprocess.call(command, cwd=suite, env=env))
finally:
env.pop('VOTIAN_UI_PASSWORD', None)
+44
View File
@@ -0,0 +1,44 @@
import { test as base, expect, type BrowserContext } from '@playwright/test';
import { installGuard } from './network';
type State = Awaited<ReturnType<BrowserContext['storageState']>>;
export const test = base.extend<{ guard: Awaited<ReturnType<typeof installGuard>> }, { signedIn: State }>({
signedIn: [async ({ browser }, use) => {
const username = process.env.VOTIAN_UI_USERNAME;
const password = process.env.VOTIAN_UI_PASSWORD;
if (!username || !password) throw new Error('Votian UI credentials required. Run python3 legacy-ui-tests/run.py.');
const context = await browser.newContext({ locale: 'de-DE', serviceWorkers: 'block' });
const guard = await installGuard(context, true);
try {
const page = await context.newPage();
await page.goto('https://test.sb.assecutor.de/admin/login.php');
const details = page.locator('details').filter({ has: page.locator('input[name="f_chk_account"]') });
if (await details.count() && !(await details.getAttribute('open'))) await details.locator('summary').click();
await page.locator('input[name="f_chk_account"]').fill(username);
await page.locator('input[name="f_chk_password"]').fill(password);
await page.locator('[name="f_submit"]').click();
await expect(page).toHaveURL(/\/admin\/start\.php/);
expect(guard.errors, 'Legacy JavaScript errors').toEqual([]);
expect(guard.denied, 'Unexpected requests during login').toEqual([]);
const state = await context.storageState();
await use(state);
} finally {
await context.close();
}
}, { scope: 'worker' }],
storageState: async ({ signedIn }, use) => { await use(signedIn); },
guard: [async ({ context }, use, info) => {
const guard = await installGuard(context);
await use(guard);
if (info.status !== info.expectedStatus) await info.attach('request-paths', { body: JSON.stringify([...new Set(guard.allowed)], null, 2), contentType: 'application/json' });
if (guard.consoleErrors.length) await info.attach('console-diagnostics', { body: JSON.stringify(guard.consoleErrors, null, 2), contentType: 'application/json' });
if (guard.knownErrors.length) await info.attach('known-opener-bug', { body: JSON.stringify(guard.knownErrors), contentType: 'application/json' });
if (guard.errors.length) await info.attach('legacy-js-errors', { body: JSON.stringify(guard.errors), contentType: 'application/json' });
if (guard.denied.length) {
await info.attach('blocked-request-paths', { body: JSON.stringify([...new Set(guard.denied)], null, 2), contentType: 'application/json' });
}
expect(guard.errors, 'Legacy JavaScript errors').toEqual([]);
expect(guard.denied, 'Unexpected request (blocked before it reached the server)').toEqual([]);
}, { auto: true }],
});
export { expect } from '@playwright/test';
+144
View File
@@ -0,0 +1,144 @@
import type { BrowserContext } from '@playwright/test';
import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const ledgerDirectory = fileURLToPath(new URL('../reports/', import.meta.url));
export function recordOrder(event: Record<string, unknown>) {
mkdirSync(ledgerDirectory, { recursive: true });
appendFileSync(ledgerDirectory + 'created-orders.jsonl', JSON.stringify({ time: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 });
}
// The old form uses ISO-8859-1, including superscript station delimiters.
function formData(body: string) {
const utf8 = new URLSearchParams(body);
if (![...utf8.values()].some(value => value.includes('\uFFFD'))) return utf8;
const decode = (value: string) => new TextDecoder('windows-1252').decode(Buffer.from(value.replace(/\+/g, ' ').replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))), 'latin1'));
return new URLSearchParams(body.split('&').map(pair => { const index = pair.indexOf('='); return [decode(pair.slice(0, index)), decode(pair.slice(index + 1))]; }));
}
function validateCreation(data: URLSearchParams, marker: string): string[] {
const problems: string[] = [];
const required = { toursubmit: 'Senden', getPriceOnly: '', jb_id: '', csc_id_payer: '878914', jb_orderer_hidden: marker, jb_status_manual: '1', jb_incomplete: '1', jb_multi_factor: '1', sendmail: '0', jb_fixprice: '10,00' };
for (const [key, expected] of Object.entries(required)) {
if ((data.get(key) ?? '') !== expected) problems.push(key);
}
for (const key of ['cr_id_order', 'cr_id_order_hidden', 'cr_id_orders', 'jb_permanent', 'jb_multi', 'jb_globaljob', 'afterwards', 'jb_offer', 'jb_insurance', 'jb_id_parent', 'jb_id_org', 'jb_tourname', 'jb_tourname_hidden', 'tmp_add_jb_ids', 'tmp_add_sids', 'do_delete_suborder', 'jb_offer_mail_hidden', 'jb_cashpayer_mail_hidden']) {
if (!['', '0'].includes(data.get(key) ?? '')) problems.push(key);
}
if (!data.get('request_token')) problems.push('request_token missing');
const stations = (data.get('jobList') ?? '').split('¹').map(row => row.split('²')).filter(row => [0, 1, 3, 4, 6, 7, 8].some(index => row[index]));
if (stations.length !== 2 || stations[0][0] !== '878914' || stations[1][0] !== '878915' || !stations.every(row => row.some(cell => cell.includes(marker)))) problems.push('exactly two marked test stations');
return problems;
}
const origin = 'https://test.sb.assecutor.de';
const pages = new Set([
'/', '/index.php', '/admin/login.php', '/admin/start.php',
'/jobs/job_edit.php', '/jobs/job_header.php', '/jobs/job_tour.php',
'/jobs/job_options.php', '/jobs/job_tour_list.php',
'/admin/cs_csc_list.php',
'/css/phoenix.css.php', '/css/navigation.css.php',
'/include/maps.googleapis.com_maps_api_js_places.php',
]);
export async function installGuard(context: BrowserContext, login = false) {
const denied: string[] = [];
const allowed: string[] = [];
const errors: string[] = [];
const knownErrors: string[] = [];
const suppressed: string[] = [];
const consoleErrors: string[] = [];
let creationMarker: string | undefined;
let saveAttempts = 0;
const readableOrders = new Set<string>();
context.on('page', page => {
page.on('console', message => { if (['error', 'warning'].includes(message.type())) consoleErrors.push(message.text()); });
page.on('pageerror', error => {
// Still fails the dedicated regression test; do not mask unrelated exceptions.
if (["Cannot read properties of null (reading 'top')", 'can\'t access property "top", top.opener is null'].includes(error.message) && /\/jobs\/job_edit\.php[^\n]*:13:\d+/.test(error.stack ?? '')) knownErrors.push(error.stack!);
else errors.push(error.stack ?? error.message);
});
});
await context.route('**/*', async route => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
const description = `${method} ${url.origin}${url.pathname} [${[...url.searchParams.keys()].join(',')}]`;
if (url.origin === origin && url.pathname === '/include/ajaxReqLib.php' &&
url.searchParams.get('mode') === '500' && url.searchParams.get('value_03') === 'usr_state_newsticker') {
suppressed.push('News ticker acknowledgement');
return route.fulfill({ status: 200, contentType: 'application/javascript', body: "retValue = '1';" });
}
if (url.pathname === '/include/ajaxJsError.php' && url.origin === origin) {
errors.push(url.searchParams.get('message') ?? 'Legacy JavaScript error');
return route.fulfill({ status: 204 });
}
if (url.hostname === 'maps.googleapis.com' && url.pathname.endsWith('/gen_204')) {
suppressed.push('Google Maps telemetry');
return route.fulfill({ status: 204 });
}
let ok = false;
if (url.origin === origin) {
if (method === 'GET') {
ok = /\.(?:js|css|png|gif|jpe?g|svg|ico|woff2?|ttf)$/i.test(url.pathname) || pages.has(url.pathname);
// Never permit action parameters on otherwise readable pages.
for (const key of ['f_act', 'action', 'save', 'delete', 'sendmail', 'toursubmit']) {
if (url.searchParams.get(key) && !(url.pathname === '/admin/cs_csc_list.php' && key === 'f_act' && url.searchParams.get(key) === 'search')) ok = false;
}
if (url.pathname === '/include/ajaxReqJob.php') {
ok = [...url.searchParams.keys()].every(key => ['cs_id', 'csc_id', 'cs_id_photo', 'csc_id_ask_mail', 'cs_id_ask_mail', 'hq_id', 'vht_id', 'csc_id_payer_cash', '_'].includes(key)) && ['cs_id', 'csc_id', 'cs_id_photo', 'csc_id_ask_mail'].some(key => url.searchParams.has(key));
}
if (url.pathname === '/include/ajaxReqDocument.php') ok = url.searchParams.get('mode') === '1';
if (url.pathname === '/jobs/get_tournames.php') ok = [...url.searchParams.keys()].every(key => key === 'csc_id') && /^\d+$/.test(url.searchParams.get('csc_id') ?? '');
if (url.pathname === '/admin/jb_detail.php') ok = [...url.searchParams.keys()].every(key => key === 'job_id') && readableOrders.has(url.searchParams.get('job_id') ?? '');
if (url.pathname === '/include/ajaxReqGeneric.php') ok = [...url.searchParams.keys()].every(key => ['incFile', 'fun', 'par_01', 'retIdx', 'retParName', '_'].includes(key)) && url.searchParams.get('incFile') === 'inc_job.inc' && url.searchParams.get('fun') === 'getJobInternalRemarkItems' && readableOrders.has(url.searchParams.get('par_01') ?? '') && url.searchParams.get('retIdx') === '0' && url.searchParams.get('retParName') === '_RAW_TEXT_';
if (url.pathname === '/include/ajaxReqLib.php') {
ok = ['2', '6'].includes(url.searchParams.get('mode') ?? '');
if (!ok) denied.push('Library mode=' + url.searchParams.get('mode') + ' field=' + url.searchParams.get('value_03'));
}
} else if (method === 'POST' && url.pathname === '/jobs/job_options.php') {
const data = formData(request.postData() ?? '');
ok = data.get('getPriceOnly') === '1' && data.get('sendmail') !== '1';
if (!ok && creationMarker && saveAttempts === 0) {
const problems = validateCreation(data, creationMarker);
if (problems.length) denied.push('Order creation rejected: ' + problems.join(', '));
else {
saveAttempts++;
recordOrder({ state: 'submit-attempt', marker: creationMarker });
ok = true;
}
}
} else if (method === 'POST' && url.pathname === '/admin/cs_csc_list.php') {
const data = new URLSearchParams(request.postData() ?? '');
ok = data.get('f_act') === 'search';
} else if (login && method === 'POST' && url.pathname === '/admin/login.php') {
const data = new URLSearchParams(request.postData() ?? '');
ok = data.get('f_act') === '' && data.has('f_chk_account') && data.has('f_chk_password');
}
} else if (method === 'GET' && ['maps.googleapis.com', 'maps.gstatic.com', 'fonts.googleapis.com', 'fonts.gstatic.com'].includes(url.hostname)) {
ok = ['script', 'stylesheet', 'font', 'image'].includes(request.resourceType());
}
if (ok) {
allowed.push(description);
await route.continue();
} else {
denied.push(description);
await route.abort('blockedbyclient');
}
});
return {
denied, allowed, errors, knownErrors, suppressed, consoleErrors,
armCreation(marker: string) {
if (process.env.VOTIAN_UI_CREATE_ORDER !== '1' || !/^UI-TEST-\d{8}T\d{6}-[a-f0-9]{6}$/.test(marker) || creationMarker) throw new Error('Creation is not enabled or already armed');
const ledger = ledgerDirectory + 'created-orders.jsonl';
if (existsSync(ledger)) {
const events = readFileSync(ledger, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
const unresolved = events.find(event => event.state === 'submit-attempt' && !events.some(later => later.marker === event.marker && later.state === 'server-confirmed'));
if (unresolved) throw new Error('Unconfirmed previous save for ' + unresolved.marker + '. Inspect the server before another creation; see reports/created-orders.jsonl.');
}
creationMarker = marker;
},
allowOrderRead(id: string) { if (!/^\d+$/.test(id)) throw new Error('Invalid order ID'); readableOrders.add(id); },
get saveAttempts() { return saveAttempts; },
};
}
+70
View File
@@ -0,0 +1,70 @@
import { expect, type Page } from '@playwright/test';
export async function openOrder(start: Page): Promise<Page> {
await start.goto('/admin/start.php');
await start.locator('#auftraege a').click();
const popup = start.waitForEvent('popup');
await start.locator('#erfassung a').click();
const order = await popup;
await order.waitForLoadState('load');
await expect(order).toHaveTitle('AUFTRAGSERFASSUNG');
await expect(order.frameLocator('frame[name="job_tour"]').locator('[name="von_cs_comp"]')).toBeVisible();
await expect(order.frameLocator('frame[name="job_options"]').locator('[name="toursubmit"]')).toBeVisible();
await expect(order.frameLocator('frame[name="job_tour"]').locator('[name="nameOfPayer"]')).toHaveValue(/Bezahler wählen/);
await expect(order.frameLocator('frame[name="job_tour_list"]').getByText('Tourendaten (Etappen)', { exact: true })).toBeVisible();
await expect.poll(() => order.frame({ name: 'job_options' })!.evaluate(() => (window as any).isCurrentlySubmitting)).toBe(false);
return order;
}
export function fields(order: Page) {
return {
tour: order.frameLocator('frame[name="job_tour"]'),
options: order.frameLocator('frame[name="job_options"]'),
list: order.frameLocator('frame[name="job_tour_list"]'),
};
}
/** Select an existing, explicitly named test cost centre through the real customer picker. */
export async function selectTestCustomer(order: Page, button: string, name: 'Testkunde Assecutor 1' | 'Testkunde Assecutor 2') {
const popup = order.waitForEvent('popup', { predicate: candidate => candidate.url().includes('/admin/cs_csc_list.php') });
await fields(order).tour.locator(`[name="${button}"]`).click();
const search = await popup;
await search.locator('[name="f_cmp_comp"]').fill(name);
await Promise.all([
search.waitForNavigation({waitUntil:'domcontentloaded'}),
search.getByRole('link', {name:'Suchen',exact:true}).first().click(),
]);
const match = search.locator('a[href^="javascript:finishPage("]');
await expect(match).toHaveCount(1);
const href = await match.getAttribute('href');
expect(href).toContain(name);
const expectedCentre = name.endsWith('1') ? '878914' : '878915';
expect(href).toMatch(new RegExp(`finishPage\\('\\d+','${expectedCentre}'`));
await Promise.all([search.waitForEvent('close'), match.click()]);
await expect.poll(() => order.frame({ name: 'job_options' })!.evaluate(() => (window as any).isCurrentlySubmitting)).toBe(false);
}
export async function fillTestOrder(order: Page, marker: string) {
await selectTestCustomer(order, 'vonFirmaButton', 'Testkunde Assecutor 1');
await selectTestCustomer(order, 'nachFirmaButton', 'Testkunde Assecutor 2');
await selectTestCustomer(order, 'bezahlerFirmaButton', 'Testkunde Assecutor 1');
const {tour,options}=fields(order);
await expect(tour.locator('[name="nameOfPayer"]')).toHaveValue('Testkunde Assecutor 1');
await tour.locator('[name="jb_orderer"]').fill(marker);
await tour.locator('[name="von_tr_remark"]').fill(marker + ' - automatischer Test, NICHT AUSFÜHREN');
await tour.locator('[name="nach_tr_remark"]').fill(marker + ' - keine echte Beförderung');
await options.locator('[name="jb_fixprice"]').fill('10,00');
await options.locator('[name="jb_fixprice"]').press('Tab');
await options.locator('[name="jb_markup"]').fill('0');
await options.locator('[name="jb_markup"]').press('Tab');
await options.locator('[name="jb_status_manual"]').check();
await options.locator('[name="jb_incomplete"]').check();
await expect(options.locator('[name="cr_id_order"]')).toHaveValue('');
await expect(options.locator('[name="jb_globaljob"]')).not.toBeChecked();
await expect(options.locator('[name="jb_insurance"]')).not.toBeChecked();
await expect(options.locator('[name="jb_offer"]')).not.toBeChecked();
await expect(options.locator('[name="afterwards"]')).not.toBeChecked();
await expect(options.locator('[name="jb_multi_factor"]')).toHaveValue('1');
await expect(options.locator('[name="toursubmit"]')).toBeEnabled();
await expect.poll(() => order.frame({ name: 'job_options' })!.evaluate(() => ({ busy: (window as any).isCurrentlySubmitting, alert: (window as any).alertIsOpen })), { timeout: 35_000, message: 'Preisberechnung ist vor dem Absenden abgeschlossen' }).toEqual({ busy: false, alert: false });
}
+132
View File
@@ -0,0 +1,132 @@
import { test, expect } from '../support/fixtures';
import { openOrder, fields, selectTestCustomer, fillTestOrder } from '../support/order-page';
test('Auftragserfassung startet ohne JavaScript-Ausnahme', async ({ page, guard }) => {
await openOrder(page);
expect(guard.knownErrors, 'job_edit.php greift trotz fehlendem window.opener auf top zu').toEqual([]);
});
test('Auftragserfassung öffnet vier Frames und verhindert leeres Absenden', async ({ page }) => {
const order = await openOrder(page);
expect(order.frames().map(f => f.name())).toEqual(expect.arrayContaining(['job_header', 'job_tour', 'job_options', 'job_tour_list']));
const { tour, options } = fields(order);
await expect(tour.locator('[name="nameOfPayer"]')).toHaveValue(/Bezahler wählen/);
await expect(options.locator('[name="toursubmit"]')).toBeDisabled();
await expect(options.locator('[name="jb_permanent"]')).toBeDisabled();
});
test('Bezahlerauswahl übernimmt die explizite Testkostenstelle', async ({ page }) => {
const order = await openOrder(page);
await selectTestCustomer(order, 'bezahlerFirmaButton', 'Testkunde Assecutor 1');
await expect(fields(order).tour.locator('[name="nameOfPayer"]')).toHaveValue('Testkunde Assecutor 1');
});
test('Auftragseingang Sonstiges blendet Zusatzfeld ein und räumt es wieder auf', async ({ page }) => {
const { tour } = fields(await openOrder(page));
const detail = tour.locator('[name="jb_origin_other"]');
await expect(detail).toBeHidden();
await tour.locator('[title="Auftragseingang sonstiges"]').check();
await expect(detail).toBeVisible();
await detail.fill('UI-TEST ohne Speicherung');
await tour.locator('[title="Auftragseingang per Telefon"]').check();
await expect(detail).toBeHidden();
await expect(detail).toHaveValue('');
});
test('Angebot erzwingt manuelle Disposition und unvollständigen Status', async ({ page }) => {
const { options } = fields(await openOrder(page));
await options.locator('[name="jb_offer"]').check();
for (const name of ['jb_status_manual', 'jb_incomplete']) {
await expect(options.locator(`[name="${name}"]`)).toBeChecked();
await expect(options.locator(`[name="${name}"]`)).toBeDisabled();
}
for (const name of ['afterwards', 'jb_insurance', 'jb_globaljob']) {
await expect(options.locator(`[name="${name}"]`)).not.toBeChecked();
await expect(options.locator(`[name="${name}"]`)).toBeDisabled();
}
await options.locator('[name="jb_offer"]').uncheck();
for (const name of ['afterwards', 'jb_insurance', 'jb_status_manual', 'jb_incomplete']) {
await expect(options.locator(`[name="${name}"]`)).toBeEnabled();
}
});
test('Höherversicherung zeigt Warenwert und Tarif und setzt sie beim Abschalten zurück', async ({ page }) => {
const { options } = fields(await openOrder(page));
const value = options.locator('[name="jb_value_of_goods"]');
await expect(value).toBeHidden();
await options.locator('[name="jb_insurance"]').check();
await expect(value).toBeVisible();
await value.fill('1234,56');
await options.locator('[name="jb_insurance_rate"]').selectOption('1');
await options.locator('[name="special_case"]').check();
await options.locator('[name="jb_insurance"]').uncheck();
await expect(value).toBeHidden();
await expect(value).toHaveValue('');
await expect(options.locator('[name="jb_insurance_rate"]')).toHaveValue('0');
await expect(options.locator('[name="special_case"]')).not.toBeChecked();
});
for (const [input, message] of [['abc', /Warenwert bitte/], ['500001', /500\.000/], ['12,345', /Warenwert bitte/]] as const) {
test(`Höherversicherung weist ungültigen Warenwert ${input} zurück`, async ({ page }) => {
const order = await openOrder(page);
const { options } = fields(order);
await options.locator('[name="jb_insurance"]').check();
const value = options.locator('[name="jb_value_of_goods"]');
await value.fill(input);
const alert = order.waitForEvent('dialog');
const blur = value.press('Tab');
const dialog = await alert;
expect(dialog.type()).toBe('alert');
expect(dialog.message()).toMatch(message);
await dialog.accept();
await blur;
await expect(value).toHaveValue('');
await expect(options.locator('[name="toursubmit"]')).toBeDisabled();
});
}
test('Stationswechsel vertauscht Firmen, Ansprechpartner und Hinweise', async ({ page }) => {
const { tour } = fields(await openOrder(page));
for (const [name, value] of Object.entries({von_cs_comp:'UI-TEST Start', nach_cs_comp:'UI-TEST Ziel', von_tr_person:'Kontakt A', nach_tr_person:'Kontakt B', von_tr_remark:'Hinweis A', nach_tr_remark:'Hinweis B'})) {
await tour.locator(`[name="${name}"]`).fill(value);
}
await tour.locator('[name="switchButton"]').click();
for (const [name, value] of Object.entries({von_cs_comp:'UI-TEST Ziel', nach_cs_comp:'UI-TEST Start', von_tr_person:'Kontakt B', nach_tr_person:'Kontakt A', von_tr_remark:'Hinweis B', nach_tr_remark:'Hinweis A'})) {
await expect(tour.locator(`[name="${name}"]`)).toHaveValue(value);
}
});
for (const [side, name] of [['von', 'waste1Button'], ['nach', 'waste2Button']] as const) {
test(`Löschen der ${side}-Station erhält die verbleibende Route`, async ({ page }) => {
const order = await openOrder(page);
const { tour } = fields(order);
await tour.locator('[name="von_cs_comp"]').fill('UI-TEST Start');
await tour.locator('[name="nach_cs_comp"]').fill('UI-TEST Ziel');
await tour.locator(`[name="${name}"]`).click();
await expect(tour.locator('[name="von_cs_comp"]')).toHaveValue(side === 'von' ? 'UI-TEST Ziel' : 'UI-TEST Start');
await expect(tour.locator('[name="nach_cs_comp"]')).toHaveValue('');
});
}
test('Manuelle Disposition und unvollständig sind ohne Speichern auswählbar', async ({ page }) => {
const { options } = fields(await openOrder(page));
await options.locator('[name="jb_status_manual"]').check();
await options.locator('[name="jb_incomplete"]').check();
await expect(options.locator('[name="jb_status_manual"]')).toBeChecked();
await expect(options.locator('[name="jb_incomplete"]')).toBeChecked();
await expect(options.locator('[name="toursubmit"]')).toBeDisabled();
});
test('Vollständiger Testauftrag wird absendebereit, aber noch nicht gespeichert', async ({ page }, info) => {
const order = await openOrder(page);
await fillTestOrder(order, 'UI-TEST-VORSCHAU');
const {options,tour}=fields(order);
await expect(tour.locator('[name="von_cs_comp"]')).toHaveValue('Testkunde Assecutor 1');
await expect(tour.locator('[name="nach_cs_comp"]')).toHaveValue('Testkunde Assecutor 2');
await expect(options.locator('[name="jb_fixprice"]')).toHaveValue('10,00');
await expect(options.locator('[name="csc_id_payer"]')).toHaveValue('878914');
await expect(options.locator('[name="sendmail"]')).toHaveValue('0');
await order.screenshot({path:info.outputPath('testauftrag-vorbereitet.png'),fullPage:true});
});
+55
View File
@@ -0,0 +1,55 @@
import { randomBytes } from 'node:crypto';
import { test, expect } from '../support/fixtures';
import { openOrder, fields, fillTestOrder } from '../support/order-page';
import { recordOrder } from '../support/network';
test('Testauftrag speichern und in frischer Detailansicht wieder öffnen', async ({ page, context, guard }, info) => {
test.skip(process.env.VOTIAN_UI_CREATE_ORDER !== '1' && !process.env.VOTIAN_UI_VERIFY_ORDER_ID, 'Speichern nur mit --create-order; vorhandenen Testauftrag mit VERIFY_ORDER_ID prüfen.');
test.setTimeout(90_000);
let id = process.env.VOTIAN_UI_VERIFY_ORDER_ID;
let marker = process.env.VOTIAN_UI_VERIFY_ORDER_MARKER;
if (!id) {
if (info.retry || info.repeatEachIndex) throw new Error('A save test must never be automatically retried or repeated.');
marker = 'UI-TEST-' + new Date().toISOString().replace(/[-:]/g, '').slice(0, 15) + '-' + randomBytes(3).toString('hex');
const order = await openOrder(page);
await fillTestOrder(order, marker);
expect(guard.denied, 'All lookups must finish before enabling a save').toEqual([]);
guard.armCreation(marker);
const unexpectedDialogs: string[] = [];
let savedId: string | undefined;
order.on('dialog', async dialog => {
const match = dialog.message().match(/^Der Auftrag wurde gespeichert!\s*Auftragsnummer:\s*(\d+)\s*$/);
if (dialog.type() === 'alert' && match) {
savedId = match[1];
recordOrder({ state: 'server-confirmed', marker, id: savedId });
await dialog.accept();
} else {
unexpectedDialogs.push(dialog.type() + ': ' + dialog.message());
await dialog.dismiss();
}
});
await fields(order).options.locator('[name="toursubmit"]').click();
await expect.poll(() => Boolean(savedId || unexpectedDialogs.length), { timeout: 30_000, message: 'Server bestätigt gespeicherten Auftrag mit Nummer' }).toBe(true);
expect(unexpectedDialogs).toEqual([]);
expect(guard.denied).toEqual([]);
expect(savedId).toMatch(/^\d+$/);
expect(guard.saveAttempts).toBe(1);
id = savedId;
}
expect(marker, 'Marker des gespeicherten Testauftrags').toMatch(/^UI-TEST-/);
guard.allowOrderRead(id!);
const detail = await context.newPage();
await detail.goto('/admin/jb_detail.php?job_id=' + id);
await expect(detail.locator('body')).toContainText(marker!);
await expect(detail.locator('body')).toContainText('Testkunde Assecutor 1');
await expect(detail.locator('body')).toContainText('Testkunde Assecutor 2');
await expect(detail.locator('body')).toContainText('NICHT AUSFÜHREN');
await expect(detail.locator('body')).toContainText('keine echte Beförderung');
await expect(detail.locator('tr:has(> td:text-is("Fixpreis"))')).toContainText('10,00');
await expect(detail.locator('tr:has(> td:text-is("Firma")) > td')).toHaveText(['Firma', 'Testkunde Assecutor 1', 'Testkunde Assecutor 2']);
await expect(detail.getByText('UNFERTIG!', { exact: true })).toBeVisible();
await expect(detail.getByRole('link', { name: 'Kurierhistorie', exact: true })).toHaveCount(0);
await info.attach('saved-test-order', { body: JSON.stringify({ id, marker, url: detail.url() }, null, 2), contentType: 'application/json' });
await detail.screenshot({ path: info.outputPath('testauftrag-gespeichert.png'), fullPage: true });
recordOrder({ state: 'ui-verified', marker, id });
});