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']); } }