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:
1 parent
781a2b82dc
commit
9de2358480
57 files changed
+3251
-7
No files matched your search
@@ -0,0 +1,3 @@
|
||||
/tools/*.phar
|
||||
/reports/
|
||||
/.phpunit.cache/
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Executable
+27
@@ -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 "$@"
|
||||
Executable
+18
@@ -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"
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]]];
|
||||
}
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
@@ -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', '', ' '],
|
||||
'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', '', ' '],
|
||||
'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']);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user