Compare commits

...
Author SHA1 Message Date
openhands 461d6e7fa8 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
2026-08-17 10:19:56 +00:00
OpenClaw 96ec0c2411 fix(economic): sanitize user-input fields to prevent 400 errors
E-conomic API returns HTTP 400 when text-line descriptions contain certain
characters. The most common case is '/' in the order reference field,
which causes the entire draft-invoice export to fail.

This change adds a single sanitizer class (economic_export_sanitizer) that
handles all user-input fields flowing into e-conomic:

  - sanitizeTextLine() — for plain text lines (reference, notes, po, reg_*, etc.)
  - sanitizeProductNumber() — for product identifiers
  - sanitizeProductDescription() — for product-line descriptions
  - sanitizeForEconApi() — catch-all

Sanitization rules:
  - '/' is replaced with '-' (the reported 400 trigger)
  - Control characters (\x00-\x1F except \t and \n) are stripped
  - Tab and newline characters collapse to a single space
  - Whitespace is normalized and trimmed
  - Lengths capped (text 250, product 50, description 500) with '...' suffix

Applied to all vulnerable fields in economic_invoice_draft.php:
  - order.po
  - order.reference (PRIMARY FIX for the reported issue)
  - order.notes
  - order.reg_1/2/3
  - order_item.reference
  - order_item.notes
  - product.description
  - product.productNumber
  - department_name

Test coverage:
  - 31 unit tests with 45 assertions
  - All edge cases (null, empty, control chars, multibyte, very long)
  - Lint and test suite both pass

Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196
2026-08-17 10:13:46 +00:00
4 changed files with 893 additions and 24 deletions
@@ -0,0 +1,111 @@
<?php
namespace classes;
/**
* Sanitizes user-input fields that are sent to the e-conomic API.
*
* Background: e-conomic returns 400 errors when description fields contain
* certain characters. The known issue is "/" in the order reference field
* (TRU-188), but we sanitize defensively for all such cases.
*
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
* - sanitizeProductNumber(): for product identifiers
* - sanitizeProductDescription(): for product-line descriptions
* - sanitizeForEconApi(): catch-all for arbitrary user input
*/
class economic_export_sanitizer
{
/** E-conomic soft limit for a single description line. */
public const TEXT_LINE_MAX_LENGTH = 250;
/** E-conomic soft limit for a product description. */
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
/** E-conomic soft limit for a product number. */
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
/** Characters that are illegal in product numbers on most e-conomic setups. */
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\0"];
/**
* Sanitize a value for use in a single-line text description.
*
* Transformations (in order):
* 1. Replaces "/" with "-" (the reported 400 trigger)
* 2. Strips control characters (\x00-\x1F) except \t and \n
* 3. Replaces tab with single space
* 4. Collapses newlines into spaces (text lines are single-line)
* 5. Collapses runs of spaces to a single space
* 6. Trims leading/trailing whitespace
* 7. Truncates to $maxLength with "..." suffix if needed
*/
public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string
{
if ($value === null) {
return '';
}
$text = (string)$value;
if ($text === '') {
return '';
}
// 1. Strip control characters except \t and \n
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
// 2. Replace tab with single space
$text = str_replace("\t", ' ', $text);
// 3. Collapse newlines to single space (text lines are single-line)
$text = preg_replace('/[\r\n]+/u', ' ', $text);
// 4. Replace forward slashes (the reported 400 trigger)
$text = str_replace('/', '-', $text);
// 5. Collapse runs of spaces
$text = preg_replace('/\s+/u', ' ', $text);
// 6. Trim
$text = trim($text);
// 7. Truncate with ellipsis if too long
if ($maxLength > 3 && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength - 3) . '...';
} elseif (mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength);
}
return $text;
}
/**
* Sanitize a product number/identifier.
*
* Removes characters that are illegal in product numbers on most
* e-conomic setups (filesystem-unsafe + path separators).
*/
public static function sanitizeProductNumber(mixed $value): string
{
if ($value === null) {
return '';
}
$text = (string)$value;
if ($text === '') {
return '';
}
$text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text);
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text);
$text = trim($text);
if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) {
$text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH);
}
return $text;
}
/**
* Sanitize a longer product description.
*/
public static function sanitizeProductDescription(mixed $value): string
{
return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH);
}
/**
* Catch-all sanitizer for any user-input value going to e-conomic.
* Defaults to text-line rules.
*/
public static function sanitizeForEconApi(mixed $value): string
{
return self::sanitizeTextLine($value);
}
}
@@ -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.
*
@@ -185,45 +325,55 @@ class economic_invoice_draft
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
// Parse the date of the transaction.
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
// Sanitize the department name (could contain "/" or other chars)
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
// Add the text line to the draft invoice
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
// If there's a PO number, add it to the invoice
if ($order->po->value() !== '') {
self::addTextLine('PO: ' . $order->po->value());
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
}
// If there's a reference, add it to the invoice
if ($order->reference->value() !== '') {
$reference_value = $order->reference->value();
if ($reference_value !== '') {
self::addTextLine('Reference:');
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->reference->value(), "\n")) {
foreach ( explode("\n", $order->reference->value()) as $line ) {
if (str_contains($reference_sanitized, "\n")) {
foreach ( explode("\n", $reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->reference->value());
self::addTextLine('# ' . $reference_sanitized);
}
}
// Add the registration numbers (if any)
$line_reg = '';
if ($order->reg_1->value() !== '')
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
if ($order->reg_2->value() !== '')
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
if ($order->reg_3->value() !== '')
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
}
// Add the line to the invoice (If there's any registration numbers)
if ($line_reg !== '')
self::addTextLine($line_reg);
// If there's a note, add it to the invoice
if ($order->notes->value() !== '') {
$notes_value = $order->notes->value();
if ($notes_value !== '') {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->notes->value(), "\n")) {
foreach ( explode("\n", $order->notes->value()) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
if (str_contains($notes_sanitized, "\n")) {
foreach ( explode("\n", $notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->notes->value());
self::addTextLine('# ' . $notes_sanitized);
}
}
}
@@ -341,26 +491,28 @@ class economic_invoice_draft
// If there's a reference, add it to the line
if ($order_item['reference'] !== '') {
self::addTextLine('Reference:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['reference'], "\n")) {
foreach ( explode("\n", $order_item['reference']) as $line ) {
// Sanitize the reference (handles "/" → "-" per TRU-188)
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
if (str_contains($item_reference_sanitized, "\n")) {
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order_item['reference']);
self::addTextLine('# ' . $item_reference_sanitized);
}
}
// If there's a note, add it to the line
if (!empty($order_item['notes'])) {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['notes'], "\n")) {
foreach ( explode("\n", $order_item['notes']) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
if (str_contains($item_notes_sanitized, "\n")) {
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order_item['notes']);
self::addTextLine('# ' . $item_notes_sanitized);
}
}
@@ -470,6 +622,9 @@ class economic_invoice_draft
*/
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
{
// Sanitize product identifier and description (defense in depth — also done at addLines())
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
// Add a line to the invoice
$line = [
@@ -0,0 +1,224 @@
<?php
namespace tests\Unit\Economic;
use classes\economic_export_sanitizer;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
class EconomicExportSanitizerTest extends TestCase
{
// ========================================================================
// sanitizeTextLine
// ========================================================================
public function testSlashIsReplacedWithDash(): void
{
$this->assertSame('ABC-123-XYZ', economic_export_sanitizer::sanitizeTextLine('ABC/123/XYZ'));
$this->assertSame('Order 1 - 2 - 3', economic_export_sanitizer::sanitizeTextLine('Order 1 / 2 / 3'));
$this->assertSame('-leading and trailing-', economic_export_sanitizer::sanitizeTextLine('/leading and trailing/'));
}
public function testControlCharactersAreStripped(): void
{
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x00lo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x01lo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x1Flo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x7F\x7Flo"));
}
public function testTabIsReplacedWithSpace(): void
{
$this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine("a\tb\tc"));
}
public function testNewlinesCollapsedToSpace(): void
{
$this->assertSame('line1 line2 line3', economic_export_sanitizer::sanitizeTextLine("line1\nline2\nline3"));
$this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2"));
$this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\n\n\nline2"));
}
public function testMultipleSpacesCollapsed(): void
{
$this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine('a b c'));
}
public function testTrimsLeadingAndTrailingWhitespace(): void
{
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine(' hello '));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("\n\thello\n\t"));
}
public function testTruncatesAtMaxLengthWithEllipsis(): void
{
$text = str_repeat('a', 300);
$result = economic_export_sanitizer::sanitizeTextLine($text, 250);
$this->assertSame(250, mb_strlen($result));
$this->assertStringEndsWith('...', $result);
}
public function testTruncatesAtMaxLengthWithoutEllipsisWhenTooShort(): void
{
// When maxLength is 3, no room for ellipsis
$text = str_repeat('a', 100);
$result = economic_export_sanitizer::sanitizeTextLine($text, 3);
$this->assertSame(3, mb_strlen($result));
$this->assertSame('aaa', $result);
}
public function testDoesNotTruncateWhenShorterThanMaxLength(): void
{
$this->assertSame('short text', economic_export_sanitizer::sanitizeTextLine('short text', 250));
}
public function testNullReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(null));
}
public function testEmptyReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(''));
}
public function testWhitespaceOnlyReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(" \t\n "));
}
public function testWhitespaceOnlyWithSlashesReturnsEmptyString(): void
{
// After all transformations, "///" becomes "---"
// After trim of whitespace-only, " / " becomes "" (since / is replaced but space was there)
// Actually let's see: " / " -> " " stays; " - " -> "-"; then trim -> "-"
// So it doesn't become empty in this case. Let me re-test:
$result = economic_export_sanitizer::sanitizeTextLine(' / ');
$this->assertSame('-', $result);
}
public function testHandlesMultibyteChars(): void
{
$this->assertSame('æøå', economic_export_sanitizer::sanitizeTextLine('æøå'));
$this->assertSame('中文', economic_export_sanitizer::sanitizeTextLine('中文'));
$this->assertSame('🚗 car', economic_export_sanitizer::sanitizeTextLine('🚗 car'));
}
public function testTruncationRespectsMultibyteBoundaries(): void
{
$text = str_repeat('æ', 300);
$result = economic_export_sanitizer::sanitizeTextLine($text, 10);
$this->assertSame(10, mb_strlen($result));
$this->assertStringEndsWith('...', $result);
}
public function testHtmlTagsAreNotStripped(): void
{
// We don't strip HTML — that's a different concern (XSS). We just sanitize for e-conomic.
// The "/" in </b> gets replaced with "-" (per the rules).
$this->assertSame('<b>notags<-b>', economic_export_sanitizer::sanitizeTextLine('<b>notags</b>'));
}
public function testSlashesInTheMiddleOfValueAreReplaced(): void
{
$this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeTextLine('foo/bar/baz'));
}
public function testMultipleProblemCharsCombined(): void
{
$input = "AB/\nC\t\rD\x00E ";
$result = economic_export_sanitizer::sanitizeTextLine($input);
// After: strip control -> "AB/\nC\tDE ", tab->space -> "AB/\nC DE ",
// newline->space -> "AB/ C DE ", slash->dash -> "AB- C DE ",
// collapse spaces -> "AB- C DE ", trim -> "AB- C DE"
$this->assertSame('AB- C DE', $result);
}
public function testIntegerIsConvertedToString(): void
{
$this->assertSame('42', economic_export_sanitizer::sanitizeTextLine(42));
}
public function testFloatIsConvertedToString(): void
{
$this->assertSame('3.14', economic_export_sanitizer::sanitizeTextLine(3.14));
}
// ========================================================================
// sanitizeProductNumber
// ========================================================================
public function testProductNumberRemovesPathSeparators(): void
{
$this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC/DEF'));
$this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC\\DEF'));
}
public function testProductNumberRemovesForbiddenChars(): void
{
$input = "PROD:01?*<>|\"";
$result = economic_export_sanitizer::sanitizeProductNumber($input);
$this->assertSame('PROD01', $result);
}
public function testProductNumberTruncatesAt50Chars(): void
{
$text = str_repeat('a', 100);
$result = economic_export_sanitizer::sanitizeProductNumber($text);
$this->assertSame(50, mb_strlen($result));
}
public function testProductNumberTrimsWhitespace(): void
{
$this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber(' PROD01 '));
}
public function testProductNumberStripsControlChars(): void
{
$this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber("PROD\x0001"));
}
public function testProductNumberNullReturnsEmpty(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeProductNumber(null));
}
public function testProductNumberAllForbiddenReturnsEmpty(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeProductNumber('///\\\\::'));
}
public function testProductNumberKeepsDotsAndDashes(): void
{
$this->assertSame('PROD-01.0', economic_export_sanitizer::sanitizeProductNumber('PROD-01.0'));
}
// ========================================================================
// sanitizeProductDescription
// ========================================================================
public function testProductDescriptionTruncatesAt500(): void
{
$text = str_repeat('a', 1000);
$result = economic_export_sanitizer::sanitizeProductDescription($text);
$this->assertSame(500, mb_strlen($result));
}
public function testProductDescriptionReplacesSlashes(): void
{
$this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeProductDescription('foo/bar/baz'));
}
// ========================================================================
// sanitizeForEconApi
// ========================================================================
public function testSanitizeForEconApiIsAliasForTextLine(): void
{
$this->assertSame(
economic_export_sanitizer::sanitizeTextLine('foo/bar'),
economic_export_sanitizer::sanitizeForEconApi('foo/bar')
);
}
}
@@ -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());
}
}