Files
breadcrumb-the-shire/tests/Service/Export/CsvExportServiceTest.php

147 lines
5.3 KiB
PHP
Raw Normal View History

feat(core): generic CSV export primitive + helpdesk domains consumer Add a reusable CSV-export building block to the core so any Grid.js list page can gain a filter/sort-aware download with two clicks of glue. Core primitive: - core/Service/Export/CsvExportService: flavor-aware writer (plain CSV or Excel-compatible UTF-8+BOM+semicolon), with OWASP-aligned formula- injection escape (=, @, +, -, TAB, CR) applied to both rows *and* headers. Value objects for columns (ExportColumn) carry an extractor closure and an allowSignedNumeric flag for phone-number-shaped cells. - core/Support/helpers/export.php: thin HTTP layer (exportSendCsv, exportCapLimit, exportResolveFlavor, exportRequireGetRequest) reusing the requestInput() contract for GR-CORE-003 consistency. Filename is sanitized and length-clamped; callers must still enforce auth. - templates/partials/app-list-export-dropdown.phtml: zero-config <details class="dropdown"> with CSV + Excel triggers. - web/js/core/app-list-export.js: initListExport({ gridConfig, exportUrl }) mirrors the current grid filters + sort onto the export URL and navigates, preserving session cookies for download. First consumer — Helpdesk domains: - DomainListService extracts the shared filter/enrich/sort logic from domains-data so the grid endpoint and the new domains/export endpoint cannot drift. - domains/export endpoint delegates to DomainListService, declares ExportColumns (with translated level labels), and exits via exportSendCsv. - domains-data now ~20 lines, delegating to DomainListService. Tests: - CsvExportServiceTest (10 cases): both flavors, BOM/no-BOM, formula escapes incl. TAB/CR, header escape, signed-numeric allowlist, quoting, multiline, empty columns, generator-compatible iterable. - ExportHelpersTest (5 cases): exportCapLimit bounds. - DomainListServiceTest (8 cases): filter, search, "all" sentinel, sort, paging, enrichment, BC failure, tenant-scoped security filter. Gates: PHPUnit green, PHPStan clean on touched files, module:sync ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:57:19 +02:00
<?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;
}
}