feat(economic): add pre-flight validation to addLines() (TRU-194)

Defense in depth: before sending draft lines to e-conomic, run a 5-rule
preflight check that catches anything that slips past the sanitizers.

Rules (per line):
  1. description must be non-empty after trim()
  2. description must be <= 250 chars
  3. productNumber (if present) must match /^[A-Za-z0-9._-]{1,50}$/
  4. quantity (if present) must be a positive number
  5. unitNetPrice (if present) must be a number >= 0

Violations throw a RuntimeException and are logged via error_log with the
offending value truncated to 200 chars. Order id is included in the log
context when provided.

19 new unit tests in EconomicInvoiceDraftPreflightTest cover each rule
plus the disabled-flag bypass path.

Refs: TRU-194
This commit is contained in:
openhands
2026-08-17 10:19:56 +00:00
parent 96ec0c2411
commit 461d6e7fa8
2 changed files with 519 additions and 0 deletions
@@ -42,6 +42,13 @@ class economic_invoice_draft
*/
protected float $conversion_rate = 1.0;
/**
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
* Defense in depth — even after sanitization, a final check catches anything that slips through.
* @var bool $preflight_enabled
*/
protected bool $preflight_enabled = true;
/**
* Construct a new Economic draft invoice object
@@ -116,9 +123,142 @@ class economic_invoice_draft
*/
public function addLines(): void
{
if ($this->preflight_enabled) {
$this->preflightValidate($this->draft_lines, null);
}
$this->flushLinesInBatches();
}
/**
* Pre-flight validation: defense in depth before sending to e-conomic.
* Validates each line against 5 rules and throws RuntimeException on the first violation.
*
* Rules (in order, per line):
* 1. description — must be non-empty after trim()
* 2. description — must be <= 250 chars
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
* 4. quantity — must be a positive number (> 0)
* 5. unitNetPrice — must be a number (>= 0)
*
* @param array<int,array<string,mixed>> $lines
* @param int|null $orderId Optional order id for log context
* @throws \RuntimeException on any rule violation
*/
public function preflightValidate(array $lines, ?int $orderId = null): void
{
foreach ($lines as $i => $line) {
if (!is_array($line)) {
$this->logAndThrow(
$i,
$orderId,
'line is not an array',
$line
);
}
// Rule 1 + 2: description
$description = $line['description'] ?? null;
if ($description === null) {
$description = '';
}
if (!is_scalar($description)) {
$description = (string)$description;
} else {
$description = (string)$description;
}
$descriptionTrimmed = trim($description);
if ($descriptionTrimmed === '') {
$this->logAndThrow(
$i,
$orderId,
'description is empty',
$description
);
}
if (mb_strlen($descriptionTrimmed) > 250) {
$this->logAndThrow(
$i,
$orderId,
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
$description
);
}
// Rule 3: productNumber (only if present in product.productNumber)
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
$productNumber = $line['product']['productNumber'];
if ($productNumber === null) {
$productNumber = '';
} else {
$productNumber = (string)$productNumber;
}
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
$this->logAndThrow(
$i,
$orderId,
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
$productNumber
);
}
}
// Rule 4: quantity — only required if present in the line (text lines omit it)
if (array_key_exists('quantity', $line)) {
$quantity = $line['quantity'];
if (!is_numeric($quantity) || (float)$quantity <= 0) {
$this->logAndThrow(
$i,
$orderId,
'quantity is not a positive number',
$quantity
);
}
}
// Rule 5: unitNetPrice — only required if present in the line
if (array_key_exists('unitNetPrice', $line)) {
$unitNetPrice = $line['unitNetPrice'];
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
$this->logAndThrow(
$i,
$orderId,
'unitNetPrice is not a number >= 0',
$unitNetPrice
);
}
}
}
}
/**
* Log the offending line and throw a RuntimeException.
*/
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
{
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
if ($valueTruncated === false) {
$valueTruncated = '[unserializable]';
}
if (mb_strlen($valueTruncated) > 200) {
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
}
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
error_log(sprintf(
'[preflight] validation failed: %s | line=%d | %s | value=%s',
$rule,
$lineIndex,
$orderContext,
$valueTruncated
));
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
throw new \RuntimeException(sprintf(
'Preflight validation failed for line %d: %s%s',
$lineIndex,
$rule,
$orderPart
));
}
/**
* Add queued draft lines using chunked requests.
*
@@ -0,0 +1,379 @@
<?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());
}
}