Files
breadcrumb-the-shire/modules/helpdesk/tests/Module/Helpdesk/Service/DomainListServiceTest.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

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'],
];
}
}