Files
breadcrumb-the-shire/tests/Service/Export/CsvExportServiceTest.php
fs 12356e2266 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

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;
}
}