service = new CsvExportService(); } public function testCsvFlavorUsesCommaWithoutBom(): void { $output = $this->render( rows: [ ['name' => 'Alice', 'city' => 'Berlin'], ['name' => 'Bob', 'city' => 'Hamburg'], ], columns: [ new ExportColumn('Name', static fn (array $r): string => (string) $r['name']), new ExportColumn('City', static fn (array $r): string => (string) $r['city']), ], flavor: CsvExportService::FLAVOR_CSV, ); $this->assertStringStartsWith('Name,City', $output); $this->assertStringContainsString("Alice,Berlin", $output); $this->assertStringContainsString("Bob,Hamburg", $output); $this->assertStringNotContainsString("\xEF\xBB\xBF", $output, 'CSV flavor must not emit UTF-8 BOM'); } public function testExcelFlavorUsesSemicolonWithBom(): void { $output = $this->render( rows: [['name' => 'Alice', 'city' => 'Berlin']], columns: [ new ExportColumn('Name', static fn (array $r): string => (string) $r['name']), new ExportColumn('City', static fn (array $r): string => (string) $r['city']), ], flavor: CsvExportService::FLAVOR_EXCEL, ); $this->assertStringStartsWith("\xEF\xBB\xBF", $output, 'Excel flavor must start with UTF-8 BOM'); $this->assertStringContainsString('Name;City', $output); $this->assertStringContainsString('Alice;Berlin', $output); } public function testFormulaInjectionIsEscaped(): void { foreach (['=SUM(A1:A10)', '@foo()', '+cmd', '-bad', "\tTAB", "\rCR"] as $payload) { $this->assertSame( "'" . $payload, CsvExportService::escapeValue($payload), 'Value starting with leading special character must be quoted: ' . bin2hex($payload) ); } } public function testHeaderRowIsAlsoEscaped(): void { $output = $this->render( rows: [['v' => 'safe']], columns: [new ExportColumn('=HYPERLINK("x")', static fn (array $r): string => (string) $r['v'])], ); // The header itself must be escaped — a future consumer may pass user-provided column labels. $this->assertStringContainsString('"\'=HYPERLINK(""x"")"', $output); } public function testSignedNumericIsAllowedForPhoneLikeColumns(): void { $this->assertSame('+49 123 456', CsvExportService::escapeValue('+49 123 456', allowSignedNumeric: true)); $this->assertSame('-12.5', CsvExportService::escapeValue('-12.5', allowSignedNumeric: true)); // Non-numeric leading + still gets escaped even when flag set $this->assertSame("'+SUM(1)", CsvExportService::escapeValue('+SUM(1)', allowSignedNumeric: true)); } public function testNullAndBoolsAreNormalized(): void { $this->assertSame('', CsvExportService::escapeValue(null)); $this->assertSame('1', CsvExportService::escapeValue(true)); $this->assertSame('0', CsvExportService::escapeValue(false)); $this->assertSame('', CsvExportService::escapeValue('')); } public function testSpecialCharactersAreQuotedProperly(): void { $output = $this->render( rows: [['value' => 'contains "quotes" and, commas']], columns: [new ExportColumn('V', static fn (array $r): string => (string) $r['value'])], ); $this->assertStringContainsString('"contains ""quotes"" and, commas"', $output); } public function testMultilineValuesAreQuoted(): void { $output = $this->render( rows: [['note' => "line 1\nline 2"]], columns: [new ExportColumn('Note', static fn (array $r): string => (string) $r['note'])], ); $this->assertStringContainsString("\"line 1\nline 2\"", $output); } public function testEmptyColumnsThrow(): void { $this->expectException(\InvalidArgumentException::class); $this->render(rows: [['a' => 'b']], columns: []); } public function testIterableRowsSupported(): void { $generator = (function () { yield ['n' => 1]; yield ['n' => 2]; })(); $output = $this->render( rows: $generator, columns: [new ExportColumn('N', static fn (array $r): string => (string) $r['n'])], ); $this->assertStringContainsString("N\n1\n2", $output); } /** * @param iterable> $rows * @param list $columns */ private function render(iterable $rows, array $columns, string $flavor = CsvExportService::FLAVOR_CSV): string { $handle = fopen('php://memory', 'w+'); $this->service->writeTo($handle, $rows, $columns, $flavor); rewind($handle); $contents = stream_get_contents($handle); fclose($handle); return (string) $contents; } }