## Summary Audit and (where needed) fix additional fields in the e-conomic export path. PR #391 covered the main order.* and order_item.* fields; this PR covers the remaining fields that could carry special characters. ## Changes 1. Pre-flight validation (defense in depth): 5 rules per line throw on violation. 2. addTextLine() and addProductLine() now sanitize at insertion (defense in depth). 3. Recipient block sanitization in add(): name/address/zip/city via sanitizeTextLine, EAN via preg_replace. 4. Audit document: documentation/economic/export-field-audit.md. 5. Tests: 94 tests / 171 assertions (14 + 19 + 6 + 24 new tests). ## Refs - TRU-193, TRU-188, TRU-194, PR #391 --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io> Co-authored-by: Bugfix Subagent <bugfix@subagent.local>
380 lines
13 KiB
PHP
380 lines
13 KiB
PHP
<?php
|
|
|
|
namespace tests\Unit\Economic;
|
|
|
|
use helpers\economic_invoice_draft;
|
|
use PHPUnit\Framework\TestCase;
|
|
use RuntimeException;
|
|
|
|
require_once __DIR__ . '/../../../modules/economic/helpers/economic_invoice_draft.php';
|
|
|
|
/**
|
|
* Unit tests for the pre-flight validation in economic_invoice_draft.
|
|
*
|
|
* These tests build a draft instance via the skip_fetch path (so we never
|
|
* hit the e-conomic API), call preflightValidate() directly, and assert
|
|
* that each rule is enforced.
|
|
*/
|
|
class EconomicInvoiceDraftPreflightTest extends TestCase
|
|
{
|
|
/**
|
|
* Build a draft instance in skip_fetch mode. We never hit the network.
|
|
*/
|
|
private function makeDraft(bool $preflight = true): economic_invoice_draft
|
|
{
|
|
$draft = new economic_invoice_draft(12345, 'DKK', true);
|
|
// Make the preflight_enabled flag mutable for the disabled test.
|
|
$reflection = new \ReflectionClass($draft);
|
|
$prop = $reflection->getProperty('preflight_enabled');
|
|
$prop->setAccessible(true);
|
|
$prop->setValue($draft, $preflight);
|
|
return $draft;
|
|
}
|
|
|
|
private function callPreflight(economic_invoice_draft $draft, array $lines, ?int $orderId = null): void
|
|
{
|
|
$draft->preflightValidate($lines, $orderId);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Rule 1: description must be non-empty after trim()
|
|
// ========================================================================
|
|
|
|
public function testEmptyDescriptionThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/description is empty/');
|
|
$this->callPreflight($draft, [
|
|
['description' => ''],
|
|
], 42);
|
|
}
|
|
|
|
public function testWhitespaceOnlyDescriptionThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/description is empty/');
|
|
$this->callPreflight($draft, [
|
|
['description' => " \t \n "],
|
|
], 99);
|
|
}
|
|
|
|
public function testMissingDescriptionThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/description is empty/');
|
|
$this->callPreflight($draft, [
|
|
['quantity' => 1, 'unitNetPrice' => 10.0],
|
|
], 1);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Rule 2: description must be <= 250 chars
|
|
// ========================================================================
|
|
|
|
public function testTooLongDescriptionThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/exceeds 250 chars/');
|
|
$long = str_repeat('a', 251);
|
|
$this->callPreflight($draft, [
|
|
['description' => $long],
|
|
], 7);
|
|
}
|
|
|
|
public function testDescriptionAt250Passes(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
// 250 chars — should pass (not throw)
|
|
$this->callPreflight($draft, [
|
|
['description' => str_repeat('b', 250)],
|
|
], 8);
|
|
$this->assertTrue(true); // no exception means success
|
|
}
|
|
|
|
// ========================================================================
|
|
// Rule 3: productNumber must match /^[A-Za-z0-9._-]{1,50}$/
|
|
// ========================================================================
|
|
|
|
public function testInvalidProductNumberThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/productNumber does not match/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'PROD/01'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 11);
|
|
}
|
|
|
|
public function testProductNumberTooLongThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/productNumber does not match/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => str_repeat('a', 51)],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 12);
|
|
}
|
|
|
|
public function testValidProductNumberPasses(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'PROD-01.0_v2'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 13);
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Rule 4: quantity must be a positive number (> 0)
|
|
// ========================================================================
|
|
|
|
public function testZeroQuantityThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/quantity is not a positive number/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => 0,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 21);
|
|
}
|
|
|
|
public function testNegativeQuantityThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/quantity is not a positive number/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => -1.5,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 22);
|
|
}
|
|
|
|
public function testNonNumericQuantityThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/quantity is not a positive number/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => 'NaN-ish',
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 23);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Rule 5: unitNetPrice must be a number (>= 0)
|
|
// ========================================================================
|
|
|
|
public function testNegativeUnitPriceThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/unitNetPrice is not a number/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => -5.0,
|
|
],
|
|
], 31);
|
|
}
|
|
|
|
public function testNonNumericUnitPriceThrows(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/unitNetPrice is not a number/');
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 'free',
|
|
],
|
|
], 32);
|
|
}
|
|
|
|
public function testZeroUnitPricePasses(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
// 0 is allowed — it's "a number >= 0"
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 0,
|
|
],
|
|
], 33);
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Happy path: a fully-valid line passes
|
|
// ========================================================================
|
|
|
|
public function testValidLinePasses(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$this->callPreflight($draft, [
|
|
[
|
|
'description' => 'A normal product line',
|
|
'product' => ['productNumber' => 'WASH-01'],
|
|
'quantity' => 2,
|
|
'unitNetPrice' => 99.5,
|
|
],
|
|
[
|
|
'description' => 'A text-only line',
|
|
],
|
|
[
|
|
'description' => 'Discount',
|
|
'product' => ['productNumber' => 'TotDiscount'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 0.0,
|
|
],
|
|
], 100);
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Disabled preflight: bad lines must NOT throw
|
|
// ========================================================================
|
|
|
|
public function testPreflightCanBeDisabled(): void
|
|
{
|
|
// The preflight_enabled flag gates addLines() (i.e. it controls whether
|
|
// preflightValidate() runs before flushLinesInBatches). It does NOT
|
|
// affect direct calls to preflightValidate(). So this test verifies:
|
|
// 1. The default value is true.
|
|
// 2. The flag is mutable to false.
|
|
// 3. addLines() is wired to short-circuit preflight when the flag is false.
|
|
$draft = $this->makeDraft(true);
|
|
|
|
// (1) Default: preflight is enabled
|
|
$ref = new \ReflectionClass($draft);
|
|
$prop = $ref->getProperty('preflight_enabled');
|
|
$prop->setAccessible(true);
|
|
$this->assertTrue($prop->getValue($draft), 'preflight_enabled should default to true');
|
|
|
|
// (2) Mutable
|
|
$prop->setValue($draft, false);
|
|
$this->assertFalse($prop->getValue($draft));
|
|
|
|
// (3) Disabled: addLines() must NOT call preflightValidate().
|
|
// We assert this indirectly: queue a guaranteed-invalid line and then
|
|
// catch the exception that flushLinesInBatches() would raise when it
|
|
// tries to send to e-conomic. If preflight were enabled we'd get
|
|
// RuntimeException("description is empty") first.
|
|
$this->expectException(\Throwable::class);
|
|
$reflection = new \ReflectionClass($draft);
|
|
$linesProp = $reflection->getProperty('draft_lines');
|
|
$linesProp->setAccessible(true);
|
|
$linesProp->setValue($draft, [
|
|
['description' => ''], // would fail rule 1 if preflight ran
|
|
]);
|
|
$draft->addLines();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Multiple errors: report the first one
|
|
// ========================================================================
|
|
|
|
public function testMultipleErrorsReportsFirst(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$caught = null;
|
|
try {
|
|
$this->callPreflight($draft, [
|
|
// First line is fine
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'P1'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
// Second line: empty description (rule 1)
|
|
['description' => ''],
|
|
// Third line: would also fail, but we should never get here
|
|
[
|
|
'description' => 'OK',
|
|
'product' => ['productNumber' => 'BAD/CHAR'],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => 10.0,
|
|
],
|
|
], 300);
|
|
} catch (RuntimeException $e) {
|
|
$caught = $e;
|
|
}
|
|
$this->assertNotNull($caught, 'Expected a RuntimeException to be thrown');
|
|
$this->assertStringContainsString('line 1', $caught->getMessage());
|
|
$this->assertStringContainsString('description is empty', $caught->getMessage());
|
|
$this->assertStringContainsString('order 300', $caught->getMessage());
|
|
}
|
|
|
|
// ========================================================================
|
|
// Exception message includes order id when provided
|
|
// ========================================================================
|
|
|
|
public function testExceptionMessageIncludesOrderId(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$caught = null;
|
|
try {
|
|
$this->callPreflight($draft, [
|
|
['description' => ''],
|
|
], 4242);
|
|
} catch (RuntimeException $e) {
|
|
$caught = $e;
|
|
}
|
|
$this->assertNotNull($caught);
|
|
$this->assertStringContainsString('order 4242', $caught->getMessage());
|
|
}
|
|
|
|
public function testExceptionMessageOmitsOrderWhenNull(): void
|
|
{
|
|
$draft = $this->makeDraft();
|
|
$caught = null;
|
|
try {
|
|
$this->callPreflight($draft, [
|
|
['description' => ''],
|
|
], null);
|
|
} catch (RuntimeException $e) {
|
|
$caught = $e;
|
|
}
|
|
$this->assertNotNull($caught);
|
|
$this->assertStringNotContainsString('order', $caught->getMessage());
|
|
}
|
|
}
|