Files
api/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php
T
Jeppe B 4b08453ee2 docs(economic): audit e-conomic invoice templates (TRU-197) (#394)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-17 13:38:09 +02:00

323 lines
12 KiB
PHP

<?php
/**
* End-to-end integration test for the e-conomic draft invoice export flow.
*
* Verifies that:
* - addTextLine() sanitizes all user input (slash → dash, control chars, length)
* - addProductLine() sanitizes product numbers and descriptions
* - preflightValidate() catches all 5 rule violations
* - Mixed text + product lines (with/without discount) pass preflight
* - Empty/whitespace-only lines are skipped (not added to draft)
*
* This test does NOT hit a live e-conomic API.
* For live verification, see /workspace/scripts/verify-economic-drafts-live.php
*
* Run: php8.4 services/nginx/app/vendor/bin/phpunit \
* -c services/nginx/app/phpunit.xml \
* services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php
*/
namespace tests\Integration\Invoicing;
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
require_once __DIR__ . '/../../../modules/economic/helpers/economic_invoice_draft.php';
use PHPUnit\Framework\TestCase;
use helpers\economic_invoice_draft;
class EconomicDraftSanitizationIntegrationTest extends TestCase
{
private economic_invoice_draft $draft;
protected function setUp(): void
{
$this->draft = new economic_invoice_draft(12345, 'DKK', true); // skip_fetch=true: no live API call
}
// ========================================================================
// addTextLine — sanitization
// ========================================================================
public function testAddTextLineSanitizesSlashToDash(): void
{
$this->draft->addTextLine('Reference: Order/123/ABC');
$lines = $this->draft->getDraftLines();
$this->assertCount(1, $lines);
$this->assertSame('Reference: Order-123-ABC', $lines[0]['description']);
}
public function testAddTextLineStripsControlChars(): void
{
$this->draft->addTextLine("Line 1\nLine 2\twith tab");
$lines = $this->draft->getDraftLines();
$this->assertSame('Line 1 Line 2 with tab', $lines[0]['description']);
}
public function testAddTextLineTruncatesVeryLongString(): void
{
$long = str_repeat('A', 5000);
$this->draft->addTextLine($long);
$lines = $this->draft->getDraftLines();
$this->assertLessThanOrEqual(250, mb_strlen($lines[0]['description']));
$this->assertStringEndsWith('...', $lines[0]['description']);
}
public function testMultipleTextLinesAllSanitized(): void
{
$this->draft->addTextLine('Reference: A/B');
$this->draft->addTextLine('PO: C/D');
$this->draft->addTextLine('Reg 1: E/F');
$lines = $this->draft->getDraftLines();
$this->assertCount(3, $lines);
$this->assertSame('Reference: A-B', $lines[0]['description']);
$this->assertSame('PO: C-D', $lines[1]['description']);
$this->assertSame('Reg 1: E-F', $lines[2]['description']);
}
public function testEmptyAndWhitespaceOnlyLinesAreSkipped(): void
{
$this->draft->addTextLine('');
$this->draft->addTextLine(' ');
$this->draft->addTextLine("\t\n ");
$this->draft->addTextLine('///'); // All slashes become dashes, then trim leaves '-', not empty
$lines = $this->draft->getDraftLines();
// '///' becomes '---' which is not empty after trim
$this->assertCount(1, $lines);
$this->assertSame('---', $lines[0]['description']);
}
public function testPurelyWhitespaceAfterSanitizationIsSkipped(): void
{
$this->draft->addTextLine("\x00\x01\x02"); // All control chars, no actual text
$lines = $this->draft->getDraftLines();
$this->assertCount(0, $lines);
}
public function testMultibyteTextPreservedCorrectly(): void
{
$this->draft->addTextLine('Kunde: ÆØÅ / 中文 / 🚗');
$lines = $this->draft->getDraftLines();
$this->assertSame('Kunde: ÆØÅ - 中文 - 🚗', $lines[0]['description']);
}
// ========================================================================
// addProductLine — sanitization
// ========================================================================
public function testProductLineWithoutDiscount(): void
{
$this->draft->addProductLine(
'PROD-001',
'Bilvask Standard',
1.0,
150.0,
1,
1,
0.0
);
$lines = $this->draft->getDraftLines();
$this->assertCount(1, $lines);
$this->assertSame('Bilvask Standard', $lines[0]['description']);
$this->assertSame(0.0, $lines[0]['discountPercentage']);
$this->assertSame('PROD-001', $lines[0]['product']['productNumber']);
}
public function testProductLineWithDiscount(): void
{
$this->draft->addProductLine(
'PROD-002',
'Storvask Premium',
1.0,
250.0,
1,
1,
20.0
);
$lines = $this->draft->getDraftLines();
$this->assertCount(1, $lines);
$this->assertSame(20.0, $lines[0]['discountPercentage']);
}
public function testProductLineWithSlashInNumberSanitized(): void
{
$this->draft->addProductLine(
'PROD/003',
'Premium/Service',
1.0,
100.0,
1,
1
);
$lines = $this->draft->getDraftLines();
// Product numbers REMOVE the slash (sanitizeProductNumber), text lines REPLACE with dash
$this->assertSame('PROD003', $lines[0]['product']['productNumber']);
$this->assertSame('Premium-Service', $lines[0]['description']);
}
public function testProductLineWithEmptyDescriptionSkipped(): void
{
$this->draft->addProductLine('PROD-001', '', 1.0, 100.0, 1, 1);
$lines = $this->draft->getDraftLines();
$this->assertCount(0, $lines);
}
// ========================================================================
// preflightValidate — via addLines (with transport stub would be ideal,
// but for unit-style integration we exercise preflight directly)
// ========================================================================
public function testPreflightCatchesEmptyDescription(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('description is empty');
$this->draft->preflightValidate([
['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0],
]);
}
public function testPreflightCatchesTooLongDescription(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('exceeds 250 chars');
$this->draft->preflightValidate([
['description' => str_repeat('A', 251), 'quantity' => 1, 'unitNetPrice' => 100.0],
]);
}
public function testPreflightCatchesInvalidProductNumber(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('productNumber does not match');
$this->draft->preflightValidate([
[
'description' => 'Valid line',
'product' => ['productNumber' => 'PROD/01'],
'quantity' => 1,
'unitNetPrice' => 100.0,
],
]);
}
public function testPreflightCatchesZeroQuantity(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('quantity is not a positive number');
$this->draft->preflightValidate([
['description' => 'Valid line', 'quantity' => 0, 'unitNetPrice' => 100.0],
]);
}
public function testPreflightCatchesNegativeUnitPrice(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('unitNetPrice is not a number');
$this->draft->preflightValidate([
['description' => 'Valid line', 'quantity' => 1, 'unitNetPrice' => -10.0],
]);
}
public function testPreflightIncludesOrderIdInMessage(): void
{
try {
$this->draft->preflightValidate(
[['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0]],
300
);
$this->fail('Expected RuntimeException');
} catch (\RuntimeException $e) {
$this->assertStringContainsString('order 300', $e->getMessage());
}
}
public function testPreflightPassesValidLines(): void
{
// Should not throw
$this->draft->preflightValidate([
['description' => 'Line 1', 'quantity' => 2, 'unitNetPrice' => 100.0],
[
'description' => 'Line 2 with product',
'product' => ['productNumber' => 'PROD-01'],
'quantity' => 1,
'unitNetPrice' => 50.0,
'discountPercentage' => 10,
],
]);
$this->assertTrue(true);
}
public function testPreflightPassesExactly250Chars(): void
{
$exactlyMax = str_repeat('B', 250);
// Should not throw
$this->draft->preflightValidate([
['description' => $exactlyMax, 'quantity' => 1, 'unitNetPrice' => 100.0],
]);
$this->assertTrue(true);
}
public function testPreflightFailsAt251Chars(): void
{
$this->expectException(\RuntimeException::class);
$this->draft->preflightValidate([
['description' => str_repeat('B', 251), 'quantity' => 1, 'unitNetPrice' => 100.0],
]);
}
public function testPreflightAcceptsProductNumberWithDotsAndDashes(): void
{
// Should not throw
$this->draft->preflightValidate([
[
'description' => 'Valid',
'product' => ['productNumber' => 'PROD-01.0_test'],
'quantity' => 1,
'unitNetPrice' => 100.0,
],
]);
$this->assertTrue(true);
}
// ========================================================================
// End-to-end: mixed flow
// ========================================================================
public function testMixedLinesAllTogetherAndPassPreflight(): void
{
$this->draft->addTextLine('Reference: Order/2024/Q1');
$this->draft->addProductLine('PROD-001', 'Bilvask', 2.0, 100.0, 1, 1, 10.0);
$this->draft->addProductLine('PROD-002', 'Storvask', 1.0, 200.0, 1, 1, 0.0);
$this->draft->addTextLine('Note: paid/in/full');
$lines = $this->draft->getDraftLines();
$this->assertCount(4, $lines);
$this->assertSame('Reference: Order-2024-Q1', $lines[0]['description']);
$this->assertSame('Bilvask', $lines[1]['description']);
$this->assertSame(10.0, $lines[1]['discountPercentage']);
$this->assertSame('Storvask', $lines[2]['description']);
$this->assertSame(0.0, $lines[2]['discountPercentage']);
$this->assertSame('Note: paid-in-full', $lines[3]['description']);
// All sanitized lines pass preflight
$this->draft->preflightValidate($lines, 12345);
}
public function testDiscountPathProducesSingleProductLineWithDiscountPct(): void
{
$this->draft->addProductLine('DISC-01', 'Rabatservice', 1.0, 100.0, 1, 1, 25.0);
$lines = $this->draft->getDraftLines();
$this->assertCount(1, $lines);
$this->assertSame(25.0, $lines[0]['discountPercentage']);
$this->assertSame('Rabatservice', $lines[0]['description']);
}
public function testNoDiscountPathProducesSingleProductLineWithZeroDiscount(): void
{
$this->draft->addProductLine('NODISC-01', 'Standardservice', 1.0, 100.0, 1, 1, 0.0);
$lines = $this->draft->getDraftLines();
$this->assertCount(1, $lines);
$this->assertSame(0.0, $lines[0]['discountPercentage']);
$this->assertSame('Standardservice', $lines[0]['description']);
}
}