323 lines
12 KiB
PHP
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']);
|
|
}
|
|
} |