1
0

feat(helpdesk): add software updates tracking with BC ticket integration

New Updates page in the Helpdesk module that fetches UPDATE/UPDATE-HF tickets
from Business Central (PBI_LV_Tickets) and allows assigning a domain and Gitea
link via a dialog. Ticket status (from BC) and assignment status (local) are
shown as separate columns with filters for both plus type and free-text search.
Assigned updates also appear on the domain detail page. Includes session-cached
BC fetch with refresh button, admin permissions, migration, and 16 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:21:40 +02:00
parent 693d33a0c8
commit da0abc824a
23 changed files with 1971 additions and 6 deletions

View File

@@ -0,0 +1,349 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\UpdateService;
use PHPUnit\Framework\TestCase;
class UpdateServiceTest extends TestCase
{
private const TENANT_ID = 1;
private const USER_ID = 42;
private function createService(
?UpdateRepository $repository = null,
?BcODataGateway $bcGateway = null,
): UpdateService {
$repository = $repository ?? $this->createMock(UpdateRepository::class);
$bcGateway = $bcGateway ?? $this->createMock(BcODataGateway::class);
return new UpdateService($repository, $bcGateway);
}
private function makeBcTicket(string $no = 'TK-001', string $category = 'UPDATE', string $customerNo = 'D10001', string $customerName = 'Acme Corp'): array
{
return [
'No' => $no,
'Customer_No' => $customerNo,
'Cust_Name' => $customerName,
'Category_1_Code' => $category,
'Ticket_State' => 'Offen',
'Created_On' => '2026-04-01T10:00:00Z',
'Last_Activity_Date' => '2026-04-15T14:30:00Z',
'Support_User_Name' => 'Max Mustermann',
];
}
// ── Assign tests ─────────────────────────────────────────────────
public function testAssignUpdateHappyPath(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->expects($this->once())->method('insert')->willReturn(1);
$service = $this->createService($repo);
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'2026-02-mysyde',
'Initial update',
self::USER_ID
);
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['id']);
$this->assertEmpty($result['errors']);
}
public function testAssignUpdateUpdatesExistingRecord(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn([
'id' => 5,
'ticket_no' => 'TK-001',
'status' => 'open',
]);
$repo->expects($this->once())->method('updateAssignment')->willReturn(true);
$repo->expects($this->never())->method('insert');
$service = $this->createService($repo);
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00002',
'new.example.com',
'2026-03-update',
'Changed domain',
self::USER_ID
);
$this->assertTrue($result['ok']);
$this->assertSame(5, $result['id']);
}
public function testAssignUpdateRejectsEmptyTicketNo(): void
{
$service = $this->createService();
$result = $service->assignUpdate(
self::TENANT_ID,
'',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('ticket_no', $result['errors']);
}
public function testAssignUpdateRejectsEmptyDebitorNo(): void
{
$service = $this->createService();
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('debitor_no', $result['errors']);
}
public function testAssignUpdateRejectsInvalidCategory(): void
{
$service = $this->createService();
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'BUGFIX',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('category_code', $result['errors']);
}
public function testAssignUpdateSetsStatusAssignedWhenDomainProvided(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['status'] === 'assigned' && $data['domain_no'] === 'DNS00001';
}))
->willReturn(1);
$service = $this->createService($repo);
$service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
}
public function testAssignUpdateSetsStatusOpenWhenNoDomainOrGitea(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['status'] === 'open' && $data['domain_no'] === '' && $data['gitea_path'] === '';
}))
->willReturn(1);
$service = $this->createService($repo);
$service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'',
'',
'',
'',
self::USER_ID
);
}
public function testAssignUpdateHandlesInsertFailure(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->method('insert')->willReturn(null);
$service = $this->createService($repo);
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
// ── Merge list tests ─────────────────────────────────────────────
public function testGetUpdatesListMergesBcWithLocalData(): void
{
$bcGateway = $this->createMock(BcODataGateway::class);
$bcGateway->method('fetchUpdateTickets')->willReturn([
'tickets' => [
$this->makeBcTicket('TK-001', 'UPDATE'),
$this->makeBcTicket('TK-002', 'UPDATE-HF'),
$this->makeBcTicket('TK-003', 'UPDATE'),
],
'truncated' => false,
]);
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findAllByTenant')->willReturn([
['ticket_no' => 'TK-001', 'domain_no' => 'DNS00001', 'domain_url' => 'example.com', 'gitea_path' => '2026-02-mysyde', 'notes' => '', 'status' => 'assigned', 'id' => 1],
]);
$service = $this->createService($repo, $bcGateway);
$result = $service->getUpdatesList(self::TENANT_ID);
$this->assertCount(3, $result['rows']);
// TK-001 should be enriched with local data
$tk001 = $result['rows'][0];
$this->assertSame('TK-001', $tk001['ticket_no']);
$this->assertSame('assigned', $tk001['status']);
$this->assertSame('DNS00001', $tk001['domain_no']);
$this->assertSame('2026-02-mysyde', $tk001['gitea_path']);
// TK-002 should be open (no local record)
$tk002 = $result['rows'][1];
$this->assertSame('TK-002', $tk002['ticket_no']);
$this->assertSame('open', $tk002['status']);
$this->assertSame('', $tk002['domain_no']);
}
public function testGetUpdatesListFiltersByStatus(): void
{
$bcGateway = $this->createMock(BcODataGateway::class);
$bcGateway->method('fetchUpdateTickets')->willReturn([
'tickets' => [
$this->makeBcTicket('TK-001'),
$this->makeBcTicket('TK-002'),
],
'truncated' => false,
]);
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findAllByTenant')->willReturn([
['ticket_no' => 'TK-001', 'domain_no' => 'DNS00001', 'domain_url' => '', 'gitea_path' => '', 'notes' => '', 'status' => 'assigned', 'id' => 1],
]);
$service = $this->createService($repo, $bcGateway);
$result = $service->getUpdatesList(self::TENANT_ID, ['status' => 'open']);
$this->assertCount(1, $result['rows']);
$this->assertSame('TK-002', $result['rows'][0]['ticket_no']);
}
public function testGetUpdatesListFiltersByCategory(): void
{
$bcGateway = $this->createMock(BcODataGateway::class);
$bcGateway->method('fetchUpdateTickets')->willReturn([
'tickets' => [
$this->makeBcTicket('TK-001', 'UPDATE'),
$this->makeBcTicket('TK-002', 'UPDATE-HF'),
],
'truncated' => false,
]);
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findAllByTenant')->willReturn([]);
$service = $this->createService($repo, $bcGateway);
$result = $service->getUpdatesList(self::TENANT_ID, ['category' => 'UPDATE-HF']);
$this->assertCount(1, $result['rows']);
$this->assertSame('TK-002', $result['rows'][0]['ticket_no']);
}
// ── Static helper tests ──────────────────────────────────────────
public function testStatusLabelsReturnExpectedValues(): void
{
$this->assertNotEmpty(UpdateService::statusLabel('open'));
$this->assertNotEmpty(UpdateService::statusLabel('assigned'));
}
public function testCategoryLabelsReturnExpectedValues(): void
{
$this->assertNotEmpty(UpdateService::categoryLabel('UPDATE'));
$this->assertNotEmpty(UpdateService::categoryLabel('UPDATE-HF'));
}
public function testStatusVariantsReturnValidValues(): void
{
$validVariants = ['neutral', 'info', 'warning', 'success', 'error'];
$this->assertContains(UpdateService::statusVariant('open'), $validVariants);
$this->assertContains(UpdateService::statusVariant('assigned'), $validVariants);
}
public function testTicketStateLabelsReturnExpectedValues(): void
{
$this->assertNotEmpty(UpdateService::ticketStateLabel('Offen'));
$this->assertNotEmpty(UpdateService::ticketStateLabel('In Bearbeitung'));
$this->assertNotEmpty(UpdateService::ticketStateLabel('Erledigt'));
$this->assertNotEmpty(UpdateService::ticketStateLabel('Geschlossen'));
}
public function testTicketStateVariantsReturnValidValues(): void
{
$validVariants = ['neutral', 'info', 'warning', 'success', 'error'];
$this->assertContains(UpdateService::ticketStateVariant('Offen'), $validVariants);
$this->assertContains(UpdateService::ticketStateVariant('Erledigt'), $validVariants);
$this->assertContains(UpdateService::ticketStateVariant('Geschlossen'), $validVariants);
}
}