Files
api/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php
T
ea9bdbe12c fix(economic): audit and sanitize additional export fields (TRU-193) (#393)
## 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>
2026-08-17 12:52:04 +02:00

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