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

+3
View File
@@ -0,0 +1,3 @@
/tools/*.phar
/reports/
/.phpunit.cache/
+123
View File
@@ -0,0 +1,123 @@
# Unit-Tests der Votian-PHP-Altanwendung
Die Suite prüft 14 Funktionen direkt aus dem aktuellen PHP-Quellstand. Sie enthält
144 fachliche Testfälle und fünf Tests des isolierten Funktionsladers.
Anwendungsdateien und Composer-Konfiguration werden nicht verändert.
## Auf dem Server ausführen
Die Suite liegt auf `172.16.0.158` unter `/data/0/votian-test/tests/unit`, außerhalb
des Webroots. Aufruf auf diesem Server:
```sh
cd /data/0/votian-test/tests/unit
./run-server.sh
```
Einzelnen Bereich ausführen:
```sh
./run-server.sh --filter PricingTest
./run-server.sh --filter RecurringOrderTest
```
Standardquelle ist `/data/0/votian-test/html`. Das Skript ermittelt die unveränderliche
Image-ID des laufenden Containers `votian-test` und startet einen separaten Testcontainer
mit genau diesem Image. Beim ersten geprüften Lauf war dessen PHP-Version **8.2.35**;
das Host-PHP 8.2.30 wird nicht verwendet.
Andere Quelle bzw. Laufzeit explizit auswählen:
```sh
VOTIAN_SOURCE_ROOT=/pfad/zum/html VOTIAN_PHP_CONTAINER=votian-test ./run-server.sh
```
Ergebnisdateien in `reports/`:
- `junit.xml`: maschinenlesbare Testergebnisse, mit Fehlermeldungen.
- `source-manifest.json`: PHP-Version, Zeitzone, SHA-256 der fünf Quelldateien sowie
Name, Ursprungszeile und SHA-256 jeder geladenen Funktion.
- `runtime-image.txt`: genaue Docker-Image-ID des geprüften Laufzeitstands.
Das Skript reicht den PHPUnit-Exitcode durch: **0 = erfolgreich, 1 = Testfehler**.
Lade-, Konfigurations- und Laufzeitfehler werden ebenfalls als Fehler ausgegeben.
JUnit-Bericht und Quellenmanifest werden bei jedem Lauf ersetzt.
## Installation aus dieser Repository-Kopie
Benötigt werden Docker mit Zugriff auf das bereits vorhandene Votian-Image und
PHPUnit 11.5.57. Der PHAR-Download ist nicht im Git-Repository enthalten:
```sh
./setup-phpunit.sh
```
Das Installationsskript lädt die feste Version von `phar.phpunit.de` und prüft
SHA-256 `c704fdd37c6c6ab7adb386f31b4e186cd51156801fa9028b5ea8099a2cd7f106`.
Auch der Teststarter prüft diese Prüfsumme. Ein Netzwerkzugriff ist nur für die
Installation nötig. PHPUnit 11 passt zu PHP 8.2; siehe
[offizielle Versionsmatrix](https://phpunit.de/supported-versions.html).
Die Dateien dieses Ordners einschließlich `tools/phpunit.phar` können anschließend
in das oben genannte Serververzeichnis kopiert werden. Der Teststarter erwartet
Linux/Docker; die Tests selbst können bei passenden PHP-Erweiterungen auch direkt
mit `VOTIAN_SOURCE_ROOT=/pfad/zum/html php tools/phpunit.phar -c phpunit.xml` laufen.
Dieser direkte Aufruf bietet nicht die zusätzliche Container-Isolation.
## Abgedeckte Regeln
| Datei | Regeln |
| --- | --- |
| `tests/CalendarTest.php` | Schaltjahre, Jahrhundertregeln, Monatslängen, Jahreswechsel, positive und negative Datumsabstände |
| `tests/RecurringOrderTest.php` | Alle elf Wiederholungsrhythmen, Wochentagsmasken, konfigurierte Feiertage, Monatsende, Schaltjahr, Sommer-/Winterzeit |
| `tests/PricingTest.php` | Kunden-/Standardtarife, Niederlassungsfallback, negative Preiskorrekturen, Rabattkorrekturen, Ein-Cent-Marker, Frachtvergütung, Rundung, Fahrzeug- und Kundenwahl |
| `tests/InvoiceDiscountTest.php` | Bestehende Rabatt-/Zuschlagstexte, Servicepreisabgrenzung, Selbsterfasserrabatt, Reihenfolge und Beträge der BI-Positionen |
| `tests/FormattingTest.php` | Zahl-/Datumsformatierung, Parameterfallback, Telefonnummern, exakte Listeneinträge |
| `tests/ConditionTest.php` | Vergleichsoperatoren und bestehende PHP-Typumwandlung |
| `tests/LegacyFunctionsTest.php` | Funktionsauswahl, Zeichenkettenklammern, keine Methoden-/Nested-Verwechslung, fehlende/mehrdeutige Funktionen, verbotene Includes |
Die Rechnungstests charakterisieren den bestehenden Ausgabe- und BI-Vertrag;
sie sind keine fachliche Freigabe sämtlicher Rabattregeln. Datenbankabfragen,
Tarifdaten, Parameter und BI-Schreibziele werden durch strikt geprüfte Test-Doubles
ersetzt. Nicht erwartete Zugriffe lassen einen Test fehlschlagen. Die Kalender- und
Dauerauftragsfunktionen sind echte Serverfunktionen; die Feiertagsliste besteht aus
synthetischen Daten und wird vor jedem Test zurückgesetzt.
## Isolation und Grenzen
Die großen Legacy-Dateien enthalten Initialisierung mit Verbindungen und anderen
Seiteneffekten. `support/LegacyFunctions.php` liest mit dem PHP-Tokenizer ausschließlich
die benannten globalen Funktionsdefinitionen und führt deren unveränderte Bytes aus.
Top-Level-Code, Includes, App-Bootstrap und Konfigurationsdateien werden nicht geladen.
Fehlende bzw. doppelte Definitionen sowie Includes/Eval/Exit innerhalb ausgewählter
Funktionen brechen den Lauf ab. Änderungen an den Serverfunktionen werden bei jedem
Lauf direkt geprüft; es gibt keine eingefrorene Kopie der Geschäftslogik in den Tests.
Der separate Container hat kein Netzwerk, nur lesbaren Quellcode, ein nur lesbares
Root-Dateisystem, keine Linux-Capabilities und begrenzte Ressourcen. Schreibbar sind
lediglich sein temporäres Verzeichnis und der Testberichtordner. Mailversand und
Prozessstarts aus PHP sind zusätzlich deaktiviert. Der laufende Anwendungscontainer
wird nicht verändert oder neu gestartet. Es werden keine Bestandsdaten gelesen oder
geschrieben und keine Mails versandt.
Nicht abgedeckt sind vollständige HTTP-/UI-Abläufe, reale SQL-Abfragen,
Auftragsspeicherung, Rechnungserzeugung, Geräte-/Maildienste und alle übrigen
Legacy-Funktionen. Es wird kein vollständiger Coverage-Prozentsatz behauptet.
## Befund vom 26. Oktober 2026
**149 Tests, 511 Assertions: 145 bestanden, vier fehlgeschlagen; keine Laufzeitfehler,
keine übersprungenen Tests.** Die vier Fehlschläge betreffen denselben vorhandenen
Fehler in `html/include/calendar.inc.php`, Funktion `addDaysComplete`, Zeile 213:
Beim Rückwärtswechsel wird die Tageszahl des Ausgangsmonats statt des Zielmonats
addiert.
| Eingabe | Erwartet | Tatsächlich |
| --- | --- | --- |
| 01.03.2026 minus 1 Tag | 28.02.2026 | 31.02.2026 |
| 01.03.2024 minus 1 Tag | 29.02.2024 | 31.02.2024 |
| 01.05.2026 minus 1 Tag | 30.04.2026 | 31.04.2026 |
| 01.04.2026 minus 1 Tag | 31.03.2026 | 30.03.2026 |
Diese Regressionstests bleiben absichtlich aktiv und rot, bis der Anwendungsfehler
behoben ist. Der Altcode wurde im Rahmen der Testerstellung nicht korrigiert.
+30
View File
@@ -0,0 +1,30 @@
<?php
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Tests may only run through PHP CLI.');
}
require __DIR__ . '/support/LegacyFunctions.php';
require __DIR__ . '/support/Fixtures.php';
require __DIR__ . '/support/LegacyTestCase.php';
date_default_timezone_set('Europe/Berlin');
$root = getenv('VOTIAN_SOURCE_ROOT');
if (!$root || !is_dir($root)) {
throw new RuntimeException('Set VOTIAN_SOURCE_ROOT to the current server html directory.');
}
$selection = [
'include/calendar.inc.php' => ['leapYear', 'daysPerMonth', 'addDaysComplete', 'addMonths'],
'include/caglobal.inc.php' => ['getNextOrderDate', 'isPublicHoliday', 'checkDiscountStr'],
'include/services_func.inc.php' => ['getServicePrices', 'getCr_price_global', 'get_cr_price_rate'],
'include/mcglobal.inc.php' => ['formatOutput'],
'include/global.inc.php' => ['eregPhoneNo', 'removeItemStringList', 'mcCheckCondition'],
];
$manifest = LegacyFunctions::load($root, $selection);
$reportDir = getenv('VOTIAN_TEST_REPORT_DIR');
if ($reportDir) {
$report = ['php' => PHP_VERSION, 'timezone' => date_default_timezone_get(),
'sourceRoot' => realpath($root), 'files' => $manifest];
if (file_put_contents($reportDir . '/source-manifest.json',
json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n") === false) {
throw new RuntimeException('Could not write source manifest');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="bootstrap.php" cacheResult="false" colors="true"
failOnWarning="true" failOnRisky="true" failOnDeprecation="true">
<testsuites>
<testsuite name="Votian PHP unit tests"><directory>tests</directory></testsuite>
</testsuites>
<php><ini name="date.timezone" value="Europe/Berlin"/></php>
</phpunit>
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
suite=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
source_root=${VOTIAN_SOURCE_ROOT:-/data/0/votian-test/html}
runtime_container=${VOTIAN_PHP_CONTAINER:-votian-test}
[ -d "$source_root/include" ] || { echo "Missing source directory: $source_root" >&2; exit 2; }
[ -f "$suite/tools/phpunit.phar" ] || { echo 'Run ./setup-phpunit.sh first.' >&2; exit 2; }
expected=c704fdd37c6c6ab7adb386f31b4e186cd51156801fa9028b5ea8099a2cd7f106
actual=$(sha256sum "$suite/tools/phpunit.phar" | cut -d ' ' -f 1)
[ "$actual" = "$expected" ] || { echo 'PHPUnit checksum mismatch' >&2; exit 2; }
mkdir -p "$suite/reports"
image=$(docker inspect --format '{{.Image}}' "$runtime_container")
printf '%s\n' "$image" > "$suite/reports/runtime-image.txt"
# Use the exact deployed image, but no running application process, network or writable source.
docker run --rm --network none --read-only --cap-drop ALL \
--security-opt no-new-privileges --pids-limit 64 --memory 256m --cpus 1 \
--user "$(id -u):$(id -g)" --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--mount "type=bind,src=$source_root,dst=/legacy,readonly" \
--mount "type=bind,src=$suite,dst=/suite,readonly" \
--mount "type=bind,src=$suite/reports,dst=/reports" \
--env VOTIAN_SOURCE_ROOT=/legacy --env VOTIAN_TEST_REPORT_DIR=/reports \
--entrypoint php "$image" \
-d auto_prepend_file= -d auto_append_file= -d allow_url_fopen=0 \
-d max_execution_time=30 -d memory_limit=192M \
-d disable_functions=mail,exec,shell_exec,system,passthru,popen,proc_open,pcntl_exec \
/suite/tools/phpunit.phar --configuration /suite/phpunit.xml \
--log-junit /reports/junit.xml --testdox "$@"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
suite=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mkdir -p "$suite/tools"
expected=c704fdd37c6c6ab7adb386f31b4e186cd51156801fa9028b5ea8099a2cd7f106
hash_file() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1
else shasum -a 256 "$1" | cut -d ' ' -f 1; fi
}
if [ -f "$suite/tools/phpunit.phar" ] && [ "$(hash_file "$suite/tools/phpunit.phar")" = "$expected" ]; then
exit 0
fi
tmp=$(mktemp "$suite/tools/phpunit.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
curl --fail --location --silent --show-error --output "$tmp" https://phar.phpunit.de/phpunit-11.5.57.phar
[ "$(hash_file "$tmp")" = "$expected" ] || { echo 'PHPUnit checksum mismatch' >&2; exit 1; }
chmod 644 "$tmp"
mv "$tmp" "$suite/tools/phpunit.phar"
+90
View File
@@ -0,0 +1,90 @@
<?php
final class FixtureState
{
public static array $parameters = [];
public static array $serviceCalls = [];
public static array $writes = [];
public static array $logs = [];
public static function reset(): void
{
self::$parameters = [];
self::$serviceCalls = [];
self::$writes = [];
self::$logs = [];
$GLOBALS['db'] = new FixtureDatabase();
$GLOBALS['hq_id'] = 7;
$GLOBALS['phpVersion'] = '8.2';
$GLOBALS['ph'] = [];
}
}
final class FixtureDatabase
{
public array $expected = [];
public function expect(string $method, string $sql, mixed $result): void
{
$this->expected[] = [$method, $sql, $result];
}
public function __call(string $method, array $arguments): mixed
{
if (!$this->expected) {
throw new RuntimeException('Unexpected database operation: ' . $method);
}
[$expectedMethod, $expectedSql, $result] = array_shift($this->expected);
if ($method !== $expectedMethod || $arguments !== [$expectedSql]) {
throw new RuntimeException('Unexpected fixture query: ' . $method . ' ' . ($arguments[0] ?? ''));
}
return $result;
}
}
final class FixtureRows
{
private int $index = 0;
public bool $freed = false;
public function __construct(private array $rows) {}
public function fetch_assoc(): ?array { return $this->rows[$this->index++] ?? null; }
public function fetchRow(): ?array { return $this->fetch_assoc(); }
public function free(): void { $this->freed = true; }
}
final class DB
{
public static function isError(mixed $value): bool { return $value instanceof Throwable; }
}
function getParameterValue($employee, $key, $hq)
{
if (!in_array($key, ['DB_FIELDTYPE_WRAPPER_1', 'COUNTRY_FON_PREFIX',
'INV_PRINT_DISCOUNT', 'INV_USE_EURO_SIGN', 'MASK_MARKUP_MODE'], true)) {
throw new RuntimeException('Unexpected parameter: ' . $key);
}
return FixtureState::$parameters[$hq][$key] ?? '';
}
function getServiceValues(...$arguments)
{
if (!FixtureState::$serviceCalls) {
throw new RuntimeException('Unexpected service lookup');
}
[$expected, $result] = array_shift(FixtureState::$serviceCalls);
if ($arguments !== $expected) {
throw new RuntimeException('Service lookup arguments differ: ' . json_encode($arguments));
}
return $result;
}
function insertStmt($table, $row)
{
if ($table !== 'phoenix_log.b2b_objects') {
throw new RuntimeException('Unexpected write target: ' . $table);
}
FixtureState::$writes[] = [$table, $row];
}
function writeLog_($file, $message) { FixtureState::$logs[] = [$file, $message]; }
function reportDie($message) { throw new RuntimeException($message); }
@@ -0,0 +1,94 @@
<?php
/** Load only named top-level functions, never the application's startup code. */
final class LegacyFunctions
{
public static function load(string $root, array $selection): array
{
$manifest = [];
foreach ($selection as $relative => $names) {
$file = $root . '/' . $relative;
if (!is_file($file) || !is_readable($file)) {
throw new RuntimeException('Missing readable source: ' . $file);
}
$source = file_get_contents($file);
$functions = self::extract($source, $names);
foreach ($functions as $name => $definition) {
if (function_exists($name)) {
throw new RuntimeException('Function already defined: ' . $name);
}
// Preserve the source bytes and weak scalar coercion of the legacy code.
eval($definition['code']);
$manifest[$relative]['functions'][$name] = [
'line' => $definition['line'],
'sha256' => hash('sha256', $definition['code']),
];
}
$manifest[$relative]['sha256'] = hash('sha256', $source);
}
return $manifest;
}
public static function extract(string $source, array $names): array
{
$tokens = token_get_all($source);
$found = [];
$depth = 0;
for ($i = 0, $count = count($tokens); $i < $count; $i++) {
$token = $tokens[$i];
if ($depth === 0 && is_array($token) && $token[0] === T_FUNCTION) {
$j = $i + 1;
while (isset($tokens[$j]) && is_array($tokens[$j]) &&
in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT,
T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG, T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG], true)) {
$j++;
}
$name = is_array($tokens[$j] ?? null) && $tokens[$j][0] === T_STRING
? $tokens[$j][1] : null;
if ($name !== null && in_array($name, $names, true)) {
if (isset($found[$name])) {
throw new RuntimeException('Duplicate function: ' . $name);
}
$code = '';
$bodyDepth = 0;
$started = false;
for ($j = $i; $j < $count; $j++) {
$part = $tokens[$j];
$code .= is_array($part) ? $part[1] : $part;
if (is_array($part) && in_array($part[0], [T_INCLUDE, T_INCLUDE_ONCE,
T_REQUIRE, T_REQUIRE_ONCE, T_EVAL, T_EXIT], true)) {
throw new RuntimeException('Unsafe startup/exit operation in ' . $name);
}
if ($part === '{' || (is_array($part) && in_array($part[0],
[T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) {
$bodyDepth++;
$started = true;
} elseif ($part === '}') {
$bodyDepth--;
if ($started && $bodyDepth === 0) {
$found[$name] = ['code' => $code, 'line' => $token[2]];
$i = $j;
break;
}
}
}
if (!isset($found[$name])) {
throw new RuntimeException('Incomplete function: ' . $name);
}
continue;
}
}
if ($token === '{' || (is_array($token) && in_array($token[0],
[T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) {
$depth++;
} elseif ($token === '}') {
$depth--;
}
}
$missing = array_diff($names, array_keys($found));
if ($missing) {
throw new RuntimeException('Missing top-level function(s): ' . implode(', ', $missing));
}
return $found;
}
}
@@ -0,0 +1,15 @@
<?php
abstract class LegacyTestCase extends PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
FixtureState::reset();
}
protected function tearDown(): void
{
self::assertSame([], $GLOBALS['db']->expected, 'Unconsumed database expectations');
self::assertSame([], FixtureState::$serviceCalls, 'Unconsumed service expectations');
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class CalendarTest extends LegacyTestCase
{
#[DataProvider('leapYears')]
public function testGregorianLeapYears(int $year, bool $expected): void
{
self::assertSame($expected, leapYear($year));
}
public static function leapYears(): array
{
return ['ordinary' => [2026, false], 'leap' => [2024, true],
'century exception' => [1900, false], '400 years' => [2000, true],
'future century' => [2100, false]];
}
#[DataProvider('monthLengths')]
public function testMonthLength(int $year, int $month, int $expected): void
{
self::assertSame($expected, daysPerMonth($year, $month));
}
public static function monthLengths(): array
{
return [[2026, 1, 31], [2026, 2, 28], [2024, 2, 29], [1900, 2, 28],
[2000, 2, 29], [2026, 4, 30], [2026, 12, 31]];
}
#[DataProvider('dayOffsets')]
public function testDayArithmeticProducesValidCalendarDates(array $date, int $offset, array $expected): void
{
$actual = addDaysComplete(...[...$date, $offset]);
self::assertSame($expected, $actual);
self::assertTrue(checkdate($actual[1], $actual[2], $actual[0]));
}
public static function dayOffsets(): array
{
return [
'unchanged' => [[2026, 10, 26], 0, [2026, 10, 26]],
'next day' => [[2026, 10, 26], 1, [2026, 10, 27]],
'year rollover' => [[2026, 12, 31], 1, [2027, 1, 1]],
'February non-leap' => [[2026, 2, 28], 1, [2026, 3, 1]],
'February leap' => [[2024, 2, 28], 1, [2024, 2, 29]],
'leap rollover' => [[2024, 2, 29], 1, [2024, 3, 1]],
'multiple months' => [[2026, 1, 31], 60, [2026, 4, 1]],
'previous day same month' => [[2026, 10, 26], -1, [2026, 10, 25]],
'previous year' => [[2026, 1, 1], -1, [2025, 12, 31]],
'backwards into February' => [[2026, 3, 1], -1, [2026, 2, 28]],
'backwards into leap February' => [[2024, 3, 1], -1, [2024, 2, 29]],
'backwards into 30-day month' => [[2026, 5, 1], -1, [2026, 4, 30]],
'backwards from short month' => [[2026, 4, 1], -1, [2026, 3, 31]],
];
}
#[DataProvider('monthOffsets')]
public function testMonthArithmetic(int $year, int $month, int $offset, array $expected): void
{
self::assertSame($expected, addMonths($year, $month, $offset));
}
public static function monthOffsets(): array
{
return [[2026, 1, 0, [2026, 1]], [2026, 12, 1, [2027, 1]],
[2026, 1, -1, [2025, 12]], [2026, 1, 25, [2028, 2]],
[2026, 1, -13, [2024, 12]]];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class ConditionTest extends LegacyTestCase
{
#[DataProvider('conditions')]
public function testComparisonOperators(mixed $value, string $operator, mixed $compare, bool $expected): void
{
self::assertSame($expected, mcCheckCondition($value, $operator, $compare));
}
public static function conditions(): array
{
return [[5, '==', '5', true], [5, '==', 6, false], [5, '!=', 6, true],
[5, '!=', '5', false], [4, '<', 5, true], [5, '<', 5, false],
[6, '>', 5, true], [5, '>', 5, false], [5, '<=', 5, true],
[6, '<=', 5, false], [5, '>=', 5, true], [4, '>=', 5, false],
['10', '<', '2', false], ['text', '==', 'text', true],
[1, '===', 1, false], [1, 'invalid', 1, false]];
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class FormattingTest extends LegacyTestCase
{
#[DataProvider('formats')]
public function testLegacyOutput(string $value, string $type, string $format, string $decimals, string $expected): void
{
self::assertSame($expected, formatOutput($value, $type, $format, $decimals));
}
public static function formats(): array
{
return [
'empty cell' => ['', 'varchar', '1', '', '&nbsp;'],
'trim text' => [' Test ', 'text', '1', '', 'Test'],
'zero number' => ['0', 'number', '1', '2', '0,00'],
'positive half cent' => ['1234.565', 'number', '1', '2', '1.234,57'],
'negative half cent' => ['-1234.565', 'number', '1', '2', '-1.234,57'],
'integer rounding' => ['1234.5', 'number', '1', '0', '1.235'],
'numeric string unformatted' => ['001.20', 'number', '1', '', '001.20'],
'non-numeric preserved' => ['12,30', 'number', '1', '2', '12,30'],
'invalid number preserved' => ['bad <value>', 'number', '1', '2', 'bad <value>'],
'rounded float' => ['1234.565', 'float_rounded_2', '1', '', '1.234,57'],
'comma decimal normalized' => ['12,3', 'float', '1', '', '12.30'],
'float with comma' => ['12.345', 'float', '2', '', '12,35'],
'float as cents' => ['12.3', 'float', '3', '', '1230'],
'boolean false' => ['0', 'bool', '1', '', '&nbsp;'],
'boolean true' => ['1', 'bool', '1', '', '<img src="../images/circle.png" height="8" width="8">'],
'datetime time' => ['2026-10-26 14:35:59', 'datetime', '2', '', '14.35.59'],
'datetime full' => ['2026-10-26 14:35:59', 'datetime', '3', '', '26.10.26 : 14.35.59'],
'date four digit year' => ['2026-10-26 14:35:59', 'datetime', '4', '', '26.10.2026'],
'date two digit year' => ['2026-10-26 14:35:59', 'datetime', '5', '', '26.10.26'],
'sortable date' => ['2026-10-26 14:35:59', 'datetime', '8', '', '2026.10.26'],
'compact date' => ['2026-10-26 14:35:59', 'datetime', '9', '', '20261026'],
'hours minutes' => ['2026-10-26 14:35:59', 'datetime', '10', '', '14:35'],
'month year' => ['2026-10-26 14:35:59', 'datetime', '11', '', '10/26'],
'compact timestamp' => ['2026-10-26 14:35:59', 'datetime', '12', '', '20261026143559'],
'full year time display' => ['2026-10-26 14:35:59', 'datetime', '14', '', '14:35 (26.10.2026)'],
'classic timestamp' => ['20261026143559', 'timestamp_classic', '1', '', '26.10.26 : 14.35.59'],
];
}
public function testGlobalTimestampWrapperParameter(): void
{
FixtureState::$parameters[0]['DB_FIELDTYPE_WRAPPER_1'] = '1';
self::assertSame('26.10.26 : 14.35.59', formatOutput('2026-10-26 14:35:59', 'timestamp'));
}
public function testHeadquartersParameterOverridesGlobalTimestampWrapper(): void
{
FixtureState::$parameters[0]['DB_FIELDTYPE_WRAPPER_1'] = '1';
FixtureState::$parameters[7]['DB_FIELDTYPE_WRAPPER_1'] = '0';
self::assertSame('26.10.26 : 14.35.59', formatOutput('20261026143559', 'timestamp'));
}
#[DataProvider('phoneNumbers')]
public function testPhoneNormalization(string $input, array $parameters, string $expected): void
{
FixtureState::$parameters = $parameters;
self::assertSame($expected, eregPhoneNo($input));
}
public static function phoneNumbers(): array
{
return ['local' => ['0171 123-456', [], '171123456'],
'international plus' => ['+49 171 123456', [], '171123456'],
'international zeros' => ['0049 171 123456', [], '171123456'],
'foreign prefix kept' => ['0044 20 123456', [], '4420123456'],
'empty' => ['', [], ''],
'headquarters prefix' => ['+43 664 123456', [7 => ['COUNTRY_FON_PREFIX' => '43']], '664123456'],
'global prefix' => ['+43 664 123456', [0 => ['COUNTRY_FON_PREFIX' => '43']], '664123456'],
'headquarters overrides global' => ['+43 664 123456', [0 => ['COUNTRY_FON_PREFIX' => '49'], 7 => ['COUNTRY_FON_PREFIX' => '43']], '664123456']];
}
#[DataProvider('lists')]
public function testRemoveExactListItem(string $list, string $item, string $delimiter, string $expected): void
{
self::assertSame($expected, removeItemStringList($list, $item, $delimiter));
}
public static function lists(): array
{
return [['123|23|234', '23', '|', '123|234'], ['23|123', '23', '|', '123'],
['123|23', '23', '|', '123'], ['23', '23', '|', ''],
['123|234', '23', '|', '123|234'], ['', '23', '|', ''],
['a,b,c', 'b', ',', 'a,c'], ['23|23|123', '23', '|', '23|123']];
}
}
@@ -0,0 +1,95 @@
<?php
final class InvoiceDiscountTest extends LegacyTestCase
{
private function rows(array $rows, float $selfDiscount = 0, ?float $original = null): FixtureRows
{
$GLOBALS['db']->expect('getOne', 'SELECT jbprc_price FROM jobprice WHERE jb_id = 123 AND mt_sort = 13', $selfDiscount);
$result = new FixtureRows($rows);
$GLOBALS['db']->expect('query', "SELECT trs_discount, trs_price, trs_srv_name FROM tourservice WHERE jb_id = '123'", $result);
if ($original !== null) {
$GLOBALS['db']->expect('getOne', 'SELECT jbprc_remark FROM jobprice WHERE jb_id = 123 AND mt_sort = 13', $original);
}
return $result;
}
private function row(string $name, float $price, float $discount = 0): array
{
return ['trs_srv_name' => $name, 'trs_price' => $price, 'trs_discount' => $discount];
}
private function item(int $index): array
{
[$table, $row] = FixtureState::$writes[$index];
self::assertSame('phoenix_log.b2b_objects', $table);
$fields = [];
for ($i = 0; $i < count($row); $i += 2) {
$fields[$row[$i]] = $row[$i + 1];
}
self::assertSame(300, $fields['bo_type']);
self::assertSame(123, $fields['bo_ext_id0']);
self::assertSame(7, $fields['bo_ext_id1']);
return json_decode($fields['bo_obj_data'], true, 512, JSON_THROW_ON_ERROR);
}
public function testNoDiscountProducesNoTextOrBiItems(): void
{
$rows = $this->rows([$this->row('Fixpreis', 100)]);
self::assertSame('', checkDiscountStr(123, 0, 100, 0));
self::assertSame([], FixtureState::$writes);
self::assertTrue($rows->freed);
}
public function testDiscountTextAndBiItemUseSameAmounts(): void
{
FixtureState::$parameters[7]['INV_PRINT_DISCOUNT'] = '1';
$this->rows([$this->row('Fixpreis', 90, 10)]);
self::assertSame("\nRabatt 10,00% auf 90,00 EUR = -9,00 EUR", checkDiscountStr(123, 0, 90, 0));
self::assertCount(1, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Rabatt', 'p', '90', 't', '-9'], $this->item(0));
}
public function testHiddenDiscountDoesNotCreateBiItem(): void
{
FixtureState::$parameters[7]['INV_PRINT_DISCOUNT'] = '0';
$this->rows([$this->row('Fixpreis', 90, 10)]);
self::assertSame('', checkDiscountStr(123, 0, 90, 0));
self::assertSame([], FixtureState::$writes);
}
public function testFuelMarkupExcludesServicePrice(): void
{
FixtureState::$parameters[7]['MASK_MARKUP_MODE'] = '1';
$this->rows([$this->row('Fixpreis', 110), $this->row('Servicepreis', 12)]);
self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 EUR = 10,00 EUR", checkDiscountStr(123, 10, 110, 12));
self::assertCount(1, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Treibstoffzuschlag', 'p', '100', 't', '10'], $this->item(0));
}
public function testSelfEntryDiscountUsesStoredOriginalAmount(): void
{
$this->rows([$this->row('Fixpreis', 90), $this->row('Servicepreis', 12)], 10, 100);
self::assertSame("\nSelbsterfasserrabatt 10,00% auf 100,00 EUR = -10,00 EUR", checkDiscountStr(123, 0, 90, 12));
self::assertCount(1, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Selbsterfasserrabatt', 'p', '100', 't', '-10'], $this->item(0));
}
public function testMarkupThenSelfEntryDiscountKeepTextAndBiOrder(): void
{
FixtureState::$parameters[7]['MASK_MARKUP_MODE'] = '1';
$this->rows([$this->row('Fixpreis', 99)], 10, 110);
self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 EUR = 10,00 EUR\nSelbsterfasserrabatt 10,00% auf 110,00 EUR = -11,00 EUR",
checkDiscountStr(123, 10, 99, 0));
self::assertCount(2, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Treibstoffzuschlag', 'p', '100', 't', '10'], $this->item(0));
self::assertSame(['c', '0.1', 's', 'Selbsterfasserrabatt', 'p', '110', 't', '-11'], $this->item(1));
}
public function testLegacyEuroByteIsPreserved(): void
{
FixtureState::$parameters[7] = ['MASK_MARKUP_MODE' => '1', 'INV_USE_EURO_SIGN' => '1'];
$this->rows([$this->row('Fixpreis', 110)]);
self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 " . chr(128) . ' = 10,00 ' . chr(128),
checkDiscountStr(123, 10, 110, 0));
}
}
@@ -0,0 +1,44 @@
<?php
final class LegacyFunctionsTest extends PHPUnit\Framework\TestCase
{
public function testOnlySelectedTopLevelFunctionIsExtracted(): void
{
$source = <<<'SOURCE'
<?php
file_put_contents('/never-execute', 'startup');
// function wanted() { wrong }
class Example { function wanted() { return 'method'; } }
function unwanted() { return '}'; }
function wanted($name) { /* } */ return "{$name}: }"; }
SOURCE;
$found = LegacyFunctions::extract($source, ['wanted']);
self::assertSame(['wanted'], array_keys($found));
self::assertSame('function wanted($name) { /* } */ return "{$name}: }"; }', $found['wanted']['code']);
self::assertSame(6, $found['wanted']['line']);
}
public function testMissingFunctionFailsClosed(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function different() {}', ['wanted']);
}
public function testNestedFunctionCannotBeMistakenForGlobalDeclaration(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function outer() { function wanted() {} }', ['wanted']);
}
public function testIncludesCannotLoadApplicationBootstrap(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function wanted() { require "db.php"; }', ['wanted']);
}
public function testDuplicateDefinitionsFailClosed(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function wanted() {} function wanted() {}', ['wanted']);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class PricingTest extends LegacyTestCase
{
private const DATE = '2026-10-26 10:00:00';
#[DataProvider('prices')]
public function testCustomerAndStandardTariffs(array $customer, ?array $standard, array $expected): void
{
$this->expectServiceIds(7);
FixtureState::$serviceCalls[] = [[101, 201, 42, 7, self::DATE, 4, 3], $customer];
if ($standard !== null) {
FixtureState::$serviceCalls[] = [[101, 201, 0, 7, self::DATE, 4, 3], $standard];
}
self::assertSame($expected, getServicePrices('Transport', 'PKW', 42, 7, self::DATE, 0, 0, 0, false, 3));
}
public static function prices(): array
{
$standard = [100, 70, 5, 9, '2026-01-01'];
return [
'customer tariff' => [[80, 60, 10, 3, '2026-10-01'], null, [80, 0, 10, 0, 60, 0, true, '2026-10-01']],
'standard fallback' => [[0, 0, 0, 0, ''], $standard, [100, 0, 5, 0, 70, 0, false, '2026-01-01']],
'negative customer price is correction' => [[-15, 0, 0, 0, ''], $standard, [100, -15, 5, 0, 70, 0, true, '2026-01-01']],
'negative discount is correction' => [[0, 0, -3, 0, ''], $standard, [100, 0, 5, -3, 70, 0, true, '2026-01-01']],
'discount only keeps customer discount' => [[0, 0, 12, 0, ''], $standard, [100, 0, 12, 0, 70, 0, true, '2026-01-01']],
'one cent denotes free tariff' => [[0.01, 0.01, 0, 0, '2026-10-01'], null, [0, 0, 0, 0, 0, 0, true, '2026-10-01']],
'two cents remains charged' => [[0.02, 0.02, 0, 0, '2026-10-01'], null, [0.02, 0, 0, 0, 0.02, 0, true, '2026-10-01']],
];
}
public function testMissingHeadquartersTypeSwitchesBothLookupsToGlobalScope(): void
{
$GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '7'", '');
$this->expectServiceIds(0);
FixtureState::$serviceCalls[] = [[101, 201, 42, 0, self::DATE, 4, 3], [80, 60, 0, 0, '2026-01-01']];
self::assertSame([80, 0, 0, 0, 60, 0, true, '2026-01-01'],
getServicePrices('Transport', 'PKW', 42, 7, self::DATE, 0, 0, 0, false, 3));
}
public function testExplicitDiscountAndCorrectionsArePreserved(): void
{
$this->expectServiceIds(7);
FixtureState::$serviceCalls[] = [[101, 201, 42, 7, self::DATE, 4, 3], [80, 60, 10, 0, '2026-01-01']];
self::assertSame([80, -2, 15, -1, 60, 0, true, '2026-01-01'],
getServicePrices('Transport', 'PKW', 42, 7, self::DATE, -2, 15, -1, false, 3));
}
private function expectServiceIds(int $hq): void
{
$GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '$hq'", 201);
$GLOBALS['db']->expect('getOne', "SELECT srv_id FROM service WHERE srv_name = 'Transport' AND srv_mode = 0 AND hq_id = '$hq'", 101);
}
#[DataProvider('courierPrices')]
public function testGlobalCourierDeduction(mixed $price, mixed $rate, float $expected): void
{
$this->expectCourierIds('Globaler<br>Frachtverg.-Abschlag [%]');
FixtureState::$serviceCalls[] = [[301, 201, 42, 7, self::DATE, 4], [0, $rate, 0, 0, '2026-01-01']];
self::assertSame([$expected, is_numeric($rate) ? $rate : 0],
getCr_price_global($price, 7, 42, self::DATE, 'PKW', 0));
}
public static function courierPrices(): array
{
return ['percentage' => [100, 20, 80.0], 'cent rounding' => [10.01, 15, 8.51],
'negative credit' => [-100, 20, -80.0], 'full deduction' => [100, 100, 0.0],
'invalid base' => ['invalid', 20, 0.0], 'invalid deduction' => [100, 'invalid', 100.0]];
}
public function testZeroCustomerDeductionFallsBackToStandard(): void
{
$this->expectCourierIds('Globaler<br>Frachtverg.-Abschlag [%]');
FixtureState::$serviceCalls = [
[[301, 201, 42, 7, self::DATE, 4], [0, 0, 0, 0, '']],
[[301, 201, 0, 7, self::DATE, 4], [0, 25, 0, 0, '2026-01-01']],
];
self::assertSame([75.0, 25], getCr_price_global(100, 7, 42, self::DATE, 'PKW', 0));
}
public function testVehicleIdOverridesVehicleNameAndEmptyCustomerIsZero(): void
{
$GLOBALS['db']->expect('getOne', "SELECT mt_value FROM metatype WHERE mt_sort = '3' AND mt_type = 'vehicletype'", 'PKW');
$this->expectCourierIds('Globaler<br>Frachtverg.-Abschlag [%]');
FixtureState::$serviceCalls[] = [[301, 201, 0, 7, self::DATE, 4], [0, 20, 0, 0, '2026-01-01']];
self::assertSame([80.0, 20], getCr_price_global(100, 7, '', self::DATE, 'ignored', 3));
}
public function testCourierRateUsesSeparateTariffAndStandardFallback(): void
{
$this->expectCourierIds('Globaler<br>Fuhrlohnabschlag [%]');
FixtureState::$serviceCalls = [
[[301, 201, 42, 7, self::DATE, 4], [0, 0, 0, 0, '']],
[[301, 201, 0, 7, self::DATE, 4], [0, 17, 0, 0, '2026-01-01']],
];
self::assertSame(17, get_cr_price_rate(7, 42, self::DATE, 'PKW', 0));
}
private function expectCourierIds(string $service): void
{
$GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '7'", 201);
$GLOBALS['db']->expect('getOne', "SELECT srv_id FROM service WHERE srv_name = '$service' AND srv_mode = 0 AND hq_id = '7'", 301);
}
}
@@ -0,0 +1,58 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class RecurringOrderTest extends LegacyTestCase
{
#[DataProvider('schedules')]
public function testNextOrderDate(array $date, int $rhythm, string $weekdays, array $holidays, array $expected): void
{
$GLOBALS['ph'] = $holidays;
$actual = getNextOrderDate(...[...$date, $rhythm, $weekdays, false, 2028, 12, 31]);
self::assertSame($expected, array_map('intval', $actual));
self::assertTrue(checkdate((int)$actual[1], (int)$actual[2], (int)$actual[0]));
}
public static function schedules(): array
{
return [
'weekdays skip weekend' => [[2026, 10, 23], 1, '1111100', [], [2026, 10, 26]],
'weekdays skip configured holiday' => [[2026, 10, 23], 1, '1111100', [[2026, 10, 26]], [2026, 10, 27]],
'Saturday enabled' => [[2026, 10, 23], 2, '1111110', [], [2026, 10, 24]],
'daily includes Sunday' => [[2026, 10, 24], 3, '1111111', [], [2026, 10, 25]],
'daily excludes configured holiday' => [[2026, 10, 24], 3, '1111111', [[2026, 10, 25]], [2026, 10, 26]],
'individual weekday mask' => [[2026, 10, 26], 1, '0010000', [], [2026, 10, 28]],
'weekly' => [[2026, 10, 26], 4, '', [], [2026, 11, 2]],
'weekly holiday moves forward' => [[2026, 10, 26], 4, '', [[2026, 11, 2], [2026, 11, 3]], [2026, 11, 4]],
'fortnightly' => [[2026, 10, 26], 5, '', [], [2026, 11, 9]],
'monthly clips short month' => [[2026, 1, 31], 6, '', [], [2026, 2, 28]],
'monthly clips leap month' => [[2024, 1, 31], 6, '', [], [2024, 2, 29]],
'monthly holiday moves forward' => [[2026, 1, 31], 6, '', [[2026, 2, 28]], [2026, 3, 1]],
'monthly year rollover' => [[2026, 12, 15], 6, '', [], [2027, 1, 15]],
'month end' => [[2026, 4, 12], 7, '', [], [2026, 5, 31]],
'month end holiday moves backward' => [[2026, 4, 12], 7, '', [[2026, 5, 31]], [2026, 5, 30]],
'half month from fifteenth' => [[2026, 4, 15], 8, '', [], [2026, 4, 30]],
'half month from month end' => [[2026, 4, 30], 8, '', [], [2026, 5, 15]],
'half month holiday moves backward' => [[2026, 4, 30], 8, '', [[2026, 5, 15]], [2026, 5, 14]],
'holiday only' => [[2026, 12, 23], 9, '0000000', [[2026, 12, 25]], [2026, 12, 25]],
'holiday or weekday: weekday sooner' => [[2026, 12, 23], 9, '1111100', [[2026, 12, 25]], [2026, 12, 24]],
'holiday or weekday: holiday sooner' => [[2026, 12, 23], 9, '0000001', [[2026, 12, 25]], [2026, 12, 25]],
'sixteenth from first' => [[2026, 4, 1], 10, '', [], [2026, 4, 16]],
'first from sixteenth' => [[2026, 4, 16], 10, '', [], [2026, 5, 1]],
'first holiday moves forward' => [[2026, 4, 16], 10, '', [[2026, 5, 1]], [2026, 5, 2]],
'four weeks over new year' => [[2026, 12, 15], 11, '', [], [2027, 1, 12]],
'four weeks holiday moves forward' => [[2026, 12, 15], 11, '', [[2027, 1, 12]], [2027, 1, 13]],
'DST spring keeps calendar day' => [[2026, 3, 28], 3, '1111111', [], [2026, 3, 29]],
'DST autumn keeps calendar day' => [[2026, 10, 24], 3, '1111111', [], [2026, 10, 25]],
];
}
public function testHolidayLookupUsesExactDateFromConfiguredList(): void
{
$GLOBALS['ph'] = [[2026, 12, 25], [2026, 12, 26]];
self::assertTrue(isPublicHoliday(2026, 12, 25));
self::assertFalse(isPublicHoliday(2025, 12, 25));
self::assertFalse(isPublicHoliday(2026, 11, 25));
self::assertFalse(isPublicHoliday(2026, 12, 24));
}
}