147 lines
5.3 KiB
PHP
147 lines
5.3 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace MintyPHP\Tests\Service\Export;
|
||
|
|
|
||
|
|
use MintyPHP\Service\Export\CsvExportService;
|
||
|
|
use MintyPHP\Service\Export\ExportColumn;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
class CsvExportServiceTest extends TestCase
|
||
|
|
{
|
||
|
|
private CsvExportService $service;
|
||
|
|
|
||
|
|
protected function setUp(): void
|
||
|
|
{
|
||
|
|
$this->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<array<string,mixed>> $rows
|
||
|
|
* @param list<ExportColumn> $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;
|
||
|
|
}
|
||
|
|
}
|