feat(helpdesk): add Software Products page with BC contract type sync
Add a new Software-Produkte feature to the helpdesk module that syncs contract types (Create_SaaS_License=true) from BC OData into a local database table with a nightly scheduler job, providing a Grid.js list page and detail/edit page for managing custom product names. - DB migration 003: helpdesk_software_products table (code UNIQUE key) - BcODataGateway: FS_Contract_Types entity with SaaS filter + fallback - SoftwareProductRepository: upsert, listPaged, softDelete, updateName - SoftwareProductSyncService: fetch → upsert → soft-delete lifecycle - SoftwareProductSyncJobHandler: daily at 02:00 via scheduler platform - SoftwareProductService: web UI business logic with validation - Permission: helpdesk.software-products.manage (nav-gated) - List page: Grid.js with Code, BC Description, Name, Status columns - Detail page: Code/BC Description read-only, Name editable, PRG pattern - i18n: de + en translation keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SoftwareProductServiceTest extends TestCase
|
||||
{
|
||||
private function createService(?SoftwareProductRepository $repository = null): SoftwareProductService
|
||||
{
|
||||
$repository = $repository ?? $this->createMock(SoftwareProductRepository::class);
|
||||
|
||||
return new SoftwareProductService($repository);
|
||||
}
|
||||
|
||||
public function testFindByCodeReturnsNullForMissingProduct(): void
|
||||
{
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn(null);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$this->assertNull($service->findByCode('NONEXISTENT'));
|
||||
}
|
||||
|
||||
public function testFindByCodeReturnsProduct(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => 'Infopoints', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->findByCode('INFO');
|
||||
$this->assertSame('INFO', $result['code']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeReturnsErrorForMissingProduct(): void
|
||||
{
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn(null);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('NONEXISTENT', 'Test');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeTrimsWhitespace(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => '', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
$repository->expects($this->once())
|
||||
->method('updateName')
|
||||
->with('INFO', 'Trimmed Name')
|
||||
->willReturn(true);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('INFO', ' Trimmed Name ');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertEmpty($result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeRejectsOverlongName(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => '', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
$repository->expects($this->never())->method('updateName');
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('INFO', str_repeat('A', 256));
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('name', $result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeAllowsEmptyName(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => 'Old', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
$repository->expects($this->once())
|
||||
->method('updateName')
|
||||
->with('INFO', '')
|
||||
->willReturn(true);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('INFO', '');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testListPagedDelegatesToRepository(): void
|
||||
{
|
||||
$expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->expects($this->once())
|
||||
->method('listPaged')
|
||||
->with(['search' => 'test'])
|
||||
->willReturn($expected);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->listPaged(['search' => 'test']);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertCount(2, $result['rows']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SoftwareProductSyncServiceTest extends TestCase
|
||||
{
|
||||
private function createService(
|
||||
?BcODataGateway $gateway = null,
|
||||
?SoftwareProductRepository $repository = null,
|
||||
bool $isConfigured = true
|
||||
): SoftwareProductSyncService {
|
||||
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
|
||||
$repository = $repository ?? $this->createMock(SoftwareProductRepository::class);
|
||||
$settingsGateway = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settingsGateway->method('isConfigured')->willReturn($isConfigured);
|
||||
|
||||
return new SoftwareProductSyncService($gateway, $repository, $settingsGateway);
|
||||
}
|
||||
|
||||
public function testSyncSkipsWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(isConfigured: false);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('skipped', $result['status']);
|
||||
$this->assertSame('bc_not_configured', $result['error_code']);
|
||||
}
|
||||
|
||||
public function testSyncReturnsFailedOnGatewayException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')
|
||||
->willThrowException(new \RuntimeException('Connection refused'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('failed', $result['status']);
|
||||
$this->assertSame('bc_fetch_failed', $result['error_code']);
|
||||
$this->assertStringContainsString('Connection refused', $result['error_message']);
|
||||
}
|
||||
|
||||
public function testSyncUpsertsAndSoftDeletesCorrectly(): void
|
||||
{
|
||||
$bcTypes = [
|
||||
['Code' => 'INFO', 'Description' => 'Infopoints'],
|
||||
['Code' => 'WEBSITE', 'Description' => 'Saas-Gebuehren Website'],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')->willReturn($bcTypes);
|
||||
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->expects($this->exactly(2))
|
||||
->method('upsertByCode')
|
||||
->willReturnCallback(function (string $code, string $desc): bool {
|
||||
$this->assertContains($code, ['INFO', 'WEBSITE']);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$repository->expects($this->once())
|
||||
->method('softDeleteNotInCodes')
|
||||
->with(['INFO', 'WEBSITE'])
|
||||
->willReturn(1);
|
||||
|
||||
$service = $this->createService($gateway, $repository);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertNull($result['error_code']);
|
||||
$this->assertSame(2, $result['result']['bc_count']);
|
||||
$this->assertSame(2, $result['result']['upserted']);
|
||||
$this->assertSame(1, $result['result']['deactivated']);
|
||||
}
|
||||
|
||||
public function testSyncHandlesEmptyBcResponse(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')->willReturn([]);
|
||||
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->expects($this->never())->method('upsertByCode');
|
||||
$repository->expects($this->once())
|
||||
->method('softDeleteNotInCodes')
|
||||
->with([])
|
||||
->willReturn(3);
|
||||
|
||||
$service = $this->createService($gateway, $repository);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame(0, $result['result']['bc_count']);
|
||||
$this->assertSame(0, $result['result']['upserted']);
|
||||
$this->assertSame(3, $result['result']['deactivated']);
|
||||
}
|
||||
|
||||
public function testSyncIsIdempotent(): void
|
||||
{
|
||||
$bcTypes = [
|
||||
['Code' => 'INFO', 'Description' => 'Infopoints'],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')->willReturn($bcTypes);
|
||||
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('upsertByCode')->willReturn(true);
|
||||
$repository->method('softDeleteNotInCodes')->willReturn(0);
|
||||
|
||||
$service = $this->createService($gateway, $repository);
|
||||
|
||||
$result1 = $service->sync();
|
||||
$result2 = $service->sync();
|
||||
|
||||
$this->assertSame('success', $result1['status']);
|
||||
$this->assertSame('success', $result2['status']);
|
||||
$this->assertSame($result1['result']['bc_count'], $result2['result']['bc_count']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user