forked from fa/breadcrumb-the-shire
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>
151 lines
5.9 KiB
PHP
151 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
|
|
|
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
|
use MintyPHP\Module\Helpdesk\Service\DomainListService;
|
|
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class DomainListServiceTest extends TestCase
|
|
{
|
|
private const TENANT_ID = 7;
|
|
|
|
public function testFiltersByState(): void
|
|
{
|
|
$service = $this->buildService($this->sampleDomains());
|
|
$result = $service->query(self::TENANT_ID, ['state' => 'Aktiv']);
|
|
|
|
$this->assertSame(2, $result['total']);
|
|
$this->assertEqualsCanonicalizing(
|
|
['DNS001', 'DNS002'],
|
|
array_column($result['rows'], 'No')
|
|
);
|
|
}
|
|
|
|
public function testFiltersBySearchAcrossMultipleColumns(): void
|
|
{
|
|
$service = $this->buildService($this->sampleDomains());
|
|
$result = $service->query(self::TENANT_ID, ['search' => 'example.com']);
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame('DNS001', $result['rows'][0]['No']);
|
|
}
|
|
|
|
public function testAllValueDisablesFilter(): void
|
|
{
|
|
$service = $this->buildService($this->sampleDomains());
|
|
$result = $service->query(self::TENANT_ID, ['state' => 'all']);
|
|
|
|
$this->assertSame(3, $result['total']);
|
|
}
|
|
|
|
public function testSortingAscAndDesc(): void
|
|
{
|
|
$domains = $this->sampleDomains();
|
|
$service = $this->buildService($domains);
|
|
|
|
$asc = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'asc']);
|
|
$desc = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'desc']);
|
|
|
|
$this->assertSame(['DNS001', 'DNS002', 'DNS003'], array_column($asc['rows'], 'No'));
|
|
$this->assertSame(['DNS003', 'DNS002', 'DNS001'], array_column($desc['rows'], 'No'));
|
|
}
|
|
|
|
public function testLimitAndOffsetPaging(): void
|
|
{
|
|
$service = $this->buildService($this->sampleDomains());
|
|
$result = $service->query(self::TENANT_ID, [
|
|
'order' => 'No',
|
|
'dir' => 'asc',
|
|
'limit' => 1,
|
|
'offset' => 1,
|
|
]);
|
|
|
|
$this->assertSame(3, $result['total'], 'total counts the filtered set before paging');
|
|
$this->assertCount(1, $result['rows']);
|
|
$this->assertSame('DNS002', $result['rows'][0]['No']);
|
|
}
|
|
|
|
public function testEnrichesRowsWithSecurityLevelVariantAndNote(): void
|
|
{
|
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
|
$levels->method('getAllDetailsForDomains')->willReturn([
|
|
'DNS001' => ['level' => 'hoch', 'note' => 'important'],
|
|
]);
|
|
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
|
$gateway->method('listDomains')->willReturn($this->sampleDomains());
|
|
$gateway->method('listDomainContractLines')->willReturn([]);
|
|
|
|
$service = new DomainListService($gateway, $levels);
|
|
$result = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'asc']);
|
|
|
|
$first = $result['rows'][0];
|
|
$this->assertSame('hoch', $first['security_level']);
|
|
$this->assertSame('important', $first['security_level_note']);
|
|
$this->assertSame(DomainSecurityLevelService::getBadgeVariant('hoch'), $first['security_level_variant']);
|
|
}
|
|
|
|
public function testBcFailureReturnsEmptyResult(): void
|
|
{
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
|
$gateway->method('listDomains')->willThrowException(new \RuntimeException('upstream down'));
|
|
|
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
|
|
|
$service = new DomainListService($gateway, $levels);
|
|
$result = $service->query(self::TENANT_ID, []);
|
|
|
|
$this->assertSame(['rows' => [], 'total' => 0], $result);
|
|
}
|
|
|
|
public function testSecurityLevelFilterRestrictsTenantScoped(): void
|
|
{
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
|
$gateway->method('listDomains')->willReturn($this->sampleDomains());
|
|
$gateway->method('listDomainContractLines')->willReturn([]);
|
|
|
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
|
$levels->expects($this->once())
|
|
->method('getAllLevelsForDomains')
|
|
->with(self::TENANT_ID, $this->callback(static fn (array $nos): bool => $nos === ['DNS001', 'DNS002', 'DNS003']))
|
|
->willReturn(['DNS002' => 'hoch']);
|
|
$levels->method('getAllDetailsForDomains')->willReturn([]);
|
|
|
|
$service = new DomainListService($gateway, $levels);
|
|
$result = $service->query(self::TENANT_ID, ['security_level' => 'hoch']);
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame('DNS002', $result['rows'][0]['No']);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string,mixed>> $domains
|
|
*/
|
|
private function buildService(array $domains): DomainListService
|
|
{
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
|
$gateway->method('listDomains')->willReturn($domains);
|
|
$gateway->method('listDomainContractLines')->willReturn([]);
|
|
|
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
|
$levels->method('getAllDetailsForDomains')->willReturn([]);
|
|
$levels->method('getAllLevelsForDomains')->willReturn([]);
|
|
|
|
return new DomainListService($gateway, $levels);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
private function sampleDomains(): array
|
|
{
|
|
return [
|
|
['No' => 'DNS001', 'Customer_No' => 'C-1', 'Customer_Name' => 'Acme GmbH', 'URL' => 'example.com', 'State' => 'Aktiv', 'Administration' => 'A'],
|
|
['No' => 'DNS002', 'Customer_No' => 'C-2', 'Customer_Name' => 'Beta AG', 'URL' => 'beta.de', 'State' => 'Aktiv', 'Administration' => 'A'],
|
|
['No' => 'DNS003', 'Customer_No' => 'C-3', 'Customer_Name' => 'Gamma KG', 'URL' => 'gamma.io', 'State' => 'In Vorbereitung', 'Administration' => 'B'],
|
|
];
|
|
}
|
|
}
|