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

+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 });
}