feat(helpdesk): add ticket/debtor communication feed and chat timeline UI

This commit is contained in:
2026-04-03 16:45:41 +02:00
parent 8de67fa882
commit e897cc2c56
18 changed files with 2372 additions and 11 deletions

View File

@@ -0,0 +1,142 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use PHPUnit\Framework\TestCase;
class BcSoapGatewayTest extends TestCase
{
/**
* @param array{http_code:int,body:string,error:string} $response
*/
private function createGateway(array $response): BcSoapGateway
{
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings->method('isConfigured')->willReturn(true);
$settings->method('getODataBaseUrl')->willReturn('https://bc.example.com:7048/BusinessCentral-NUP/ODataV4');
$settings->method('getCompanyName')->willReturn('Test Company');
$settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
$settings->method('getBasicUser')->willReturn('tester');
$settings->method('getBasicPassword')->willReturn('secret');
$oauthTokenService = $this->createMock(HelpdeskOAuthTokenService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([
'current_tenant' => ['id' => 1],
]);
return new class($settings, $oauthTokenService, $sessionStore, $response) extends BcSoapGateway {
/**
* @param array{http_code:int,body:string,error:string} $fakeResponse
*/
public function __construct(
HelpdeskSettingsGateway $settings,
HelpdeskOAuthTokenService $oauthTokenService,
SessionStoreInterface $sessionStore,
private readonly array $fakeResponse
) {
parent::__construct($settings, $oauthTokenService, $sessionStore);
}
protected function sendSoapRequest(
string $endpoint,
string $soapAction,
string $requestBody,
array $headers,
string $basicUser,
string $basicPassword
): array {
return $this->fakeResponse;
}
};
}
public function testGetTicketCommunicationTextReturnsErrorForEmptyTicketNo(): void
{
$gateway = $this->createGateway([
'http_code' => 200,
'body' => '',
'error' => '',
]);
$result = $gateway->getTicketCommunicationText('');
$this->assertFalse($result['ok']);
$this->assertSame('Missing ticket number', $result['error']);
}
public function testGetTicketCommunicationTextParsesSuccessfulResponse(): void
{
$soapBody = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetTicketCommunicationText_Result xmlns="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<return_value>0</return_value>
<communicationText>Hallo aus SOAP</communicationText>
<messageEntries>line 1</messageEntries>
</GetTicketCommunicationText_Result>
</soap:Body>
</soap:Envelope>
XML;
$gateway = $this->createGateway([
'http_code' => 200,
'body' => $soapBody,
'error' => '',
]);
$result = $gateway->getTicketCommunicationText('T26-1216');
$this->assertTrue($result['ok']);
$this->assertTrue($result['soap_used']);
$this->assertSame(0, $result['return_value']);
$this->assertSame('Hallo aus SOAP', $result['communication_text']);
$this->assertSame('line 1', $result['message_entries']);
}
public function testGetTicketCommunicationTextReturnsErrorWhenReturnCodeIndicatesFailure(): void
{
$soapBody = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetTicketCommunicationText_Result xmlns="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<return_value>400</return_value>
<communicationText></communicationText>
</GetTicketCommunicationText_Result>
</soap:Body>
</soap:Envelope>
XML;
$gateway = $this->createGateway([
'http_code' => 200,
'body' => $soapBody,
'error' => '',
]);
$result = $gateway->getTicketCommunicationText('T26-1216');
$this->assertFalse($result['ok']);
$this->assertSame(400, $result['return_value']);
$this->assertSame('BC SOAP returned error code 400', $result['error']);
}
public function testGetTicketCommunicationTextReturnsErrorForInvalidSoapXml(): void
{
$gateway = $this->createGateway([
'http_code' => 200,
'body' => 'invalid-xml',
'error' => '',
]);
$result = $gateway->getTicketCommunicationText('T26-1216');
$this->assertFalse($result['ok']);
$this->assertSame('Invalid SOAP XML response', $result['error']);
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use PHPUnit\Framework\TestCase;
class TicketCommunicationServiceTest extends TestCase
{
public function testLoadTicketCommunicationFeedMergesSoapAndOData(): void
{
$soapGateway = $this->createMock(BcSoapGateway::class);
$soapGateway->method('getTicketCommunicationText')->willReturn([
'ok' => true,
'soap_used' => true,
'return_value' => 0,
'communication_text' => "2026-04-03 08:10 - Alice: Hallo\n2026-04-03 08:11 - Alice: Update",
'message_entries' => '',
]);
$odataGateway = $this->createMock(BcODataGateway::class);
$odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([
[
'Entry_No' => 1,
'Created_By' => 'ICTEST',
'Creation_Date' => '2026-04-03',
'Creation_Time' => '07:59:00.000',
'Type' => 'Status',
'State_Subtype' => 'Offen',
'Record_ID' => 'ICI Support Ticket: T26-1216',
],
]);
$service = new TicketCommunicationService($soapGateway, $odataGateway);
$result = $service->loadTicketCommunicationFeed('T26-1216', 40);
$this->assertTrue($result['ok']);
$this->assertIsArray($result['entries']);
$this->assertGreaterThanOrEqual(3, count($result['entries']));
$this->assertSame(true, $result['meta']['soap_used']);
$this->assertSame(false, $result['meta']['fallback_used']);
$sources = array_unique(array_map(static fn (array $entry): string => (string) ($entry['source'] ?? ''), $result['entries']));
$this->assertContains('soap', $sources);
$this->assertContains('odata', $sources);
}
public function testLoadTicketCommunicationFeedFallsBackToODataWhenSoapFails(): void
{
$soapGateway = $this->createMock(BcSoapGateway::class);
$soapGateway->method('getTicketCommunicationText')->willReturn([
'ok' => false,
'soap_used' => false,
'return_value' => 400,
'communication_text' => '',
'message_entries' => '',
'error' => 'BC SOAP returned error code 400',
]);
$odataGateway = $this->createMock(BcODataGateway::class);
$odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([
[
'Entry_No' => 1,
'Created_By' => 'ICTEST',
'Creation_Date' => '2026-04-03',
'Creation_Time' => '07:59:00.000',
'Type' => 'Nachricht',
'State_Subtype' => '',
'Record_ID' => 'Fallback entry',
],
]);
$service = new TicketCommunicationService($soapGateway, $odataGateway);
$result = $service->loadTicketCommunicationFeed('T26-1216', 40);
$this->assertTrue($result['ok']);
$this->assertNotEmpty($result['entries']);
$this->assertSame(false, $result['meta']['soap_used']);
$this->assertSame(true, $result['meta']['fallback_used']);
$this->assertSame('BC SOAP returned error code 400', $result['meta']['soap_error']);
$this->assertSame('odata', $result['entries'][0]['source']);
}
public function testLoadDebitorCommunicationFeedUsesLastTenTickets(): void
{
$soapGateway = $this->createMock(BcSoapGateway::class);
$soapGateway->method('getTicketCommunicationText')->willReturn([
'ok' => false,
'soap_used' => false,
'return_value' => 400,
'communication_text' => '',
'message_entries' => '',
'error' => 'SOAP unavailable',
]);
$tickets = [];
for ($i = 1; $i <= 12; $i++) {
$tickets[] = [
'No' => 'T' . $i,
'Last_Activity_Date' => sprintf('2026-04-%02dT08:00:00Z', $i),
'Created_On' => sprintf('2026-03-%02dT08:00:00Z', $i),
];
}
$odataGateway = $this->createMock(BcODataGateway::class);
$odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn($tickets);
$odataGateway->method('getTicketLog')->willReturnCallback(static function (string $ticketNo): array {
return [[
'Entry_No' => 1,
'Created_By' => 'ICTEST',
'Creation_Date' => '2026-04-03',
'Creation_Time' => '07:59:00.000',
'Type' => 'Nachricht',
'State_Subtype' => '',
'Record_ID' => 'Entry ' . $ticketNo,
]];
});
$service = new TicketCommunicationService($soapGateway, $odataGateway);
$result = $service->loadDebitorCommunicationFeed('10001', 'Test GmbH', 10, 40, 250);
$this->assertTrue($result['ok']);
$this->assertSame(10, $result['meta']['tickets_considered']);
$this->assertSame(10, $result['meta']['tickets_with_data']);
$this->assertSame(10, $result['meta']['soap_partial_failures']);
$ticketNos = array_unique(array_map(static fn (array $entry): string => (string) ($entry['ticket_no'] ?? ''), $result['entries']));
$this->assertCount(10, $ticketNos);
$this->assertNotContains('T1', $ticketNos);
$this->assertNotContains('T2', $ticketNos);
$this->assertContains('T12', $ticketNos);
}
public function testLoadTicketCommunicationFeedRespectsEntryLimit(): void
{
$soapGateway = $this->createMock(BcSoapGateway::class);
$soapGateway->method('getTicketCommunicationText')->willReturn([
'ok' => true,
'soap_used' => true,
'return_value' => 0,
'communication_text' => "2026-04-03 08:10 - Alice: A\n2026-04-03 08:11 - Alice: B\n2026-04-03 08:12 - Alice: C",
'message_entries' => '',
]);
$odataGateway = $this->createMock(BcODataGateway::class);
$odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([]);
$service = new TicketCommunicationService($soapGateway, $odataGateway);
$result = $service->loadTicketCommunicationFeed('T26-1216', 2);
$this->assertTrue($result['ok']);
$this->assertCount(2, $result['entries']);
}
public function testLoadTicketCommunicationFeedMapsSoapDataBlobByEntryNo(): void
{
$soapGateway = $this->createMock(BcSoapGateway::class);
$soapGateway->method('getTicketCommunicationText')->willReturn([
'ok' => true,
'soap_used' => true,
'return_value' => 100,
'communication_text' => '[{"EntryNo":"19356","DataBlob":"<p>Hallo <strong>Team</strong></p>"}]',
'message_entries' => '',
]);
$odataGateway = $this->createMock(BcODataGateway::class);
$odataGateway->method('getTicketLog')->willReturn([
[
'Entry_No' => 19356,
'Created_By' => 'KT002452',
'Creation_Date' => '2026-04-02',
'Creation_Time' => '13:54:00.000',
'Type' => 'Nachricht',
'State_Subtype' => '',
'Record_ID' => '',
],
]);
$service = new TicketCommunicationService($soapGateway, $odataGateway);
$result = $service->loadTicketCommunicationFeed('T26-1204', 40);
$this->assertTrue($result['ok']);
$this->assertNotEmpty($result['entries']);
$this->assertSame(false, $result['meta']['fallback_used']);
$this->assertSame('soap', $result['entries'][0]['source']);
$this->assertSame('Hallo Team', $result['entries'][0]['message']);
}
public function testLoadTicketCommunicationFeedResolvesContactCodeToName(): void
{
$soapGateway = $this->createMock(BcSoapGateway::class);
$soapGateway->method('getTicketCommunicationText')->willReturn([
'ok' => false,
'soap_used' => false,
'return_value' => 400,
'communication_text' => '',
'message_entries' => '',
'error' => 'BC SOAP returned error code 400',
]);
$odataGateway = $this->createMock(BcODataGateway::class);
$odataGateway->method('getTicketLog')->with('T26-1204')->willReturn([
[
'Entry_No' => 19356,
'Created_By' => 'KT002967',
'Creation_Date' => '2026-04-02',
'Creation_Time' => '13:54:00.000',
'Type' => 'Nachricht',
'State_Subtype' => '',
'Record_ID' => 'Hallo',
],
]);
$odataGateway->method('getTicket')->with('T26-1204')->willReturn([
'No' => 'T26-1204',
'Company_Contact_Name' => 'Aero-Dienst GmbH',
'Current_Contact_Name' => 'Fallback Kontakt',
]);
$odataGateway->method('getContactsForCustomer')->with('', 'Aero-Dienst GmbH')->willReturn([
[
'No' => 'KT002967',
'Name' => 'Felix Schneider',
],
]);
$service = new TicketCommunicationService($soapGateway, $odataGateway);
$result = $service->loadTicketCommunicationFeed('T26-1204', 40);
$this->assertTrue($result['ok']);
$this->assertNotEmpty($result['entries']);
$this->assertSame('Felix Schneider', $result['entries'][0]['actor']);
}
}