Files
breadcrumb-the-shire/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php
fs 03e15eaf08 feat(helpdesk): add handover revision history with timeline, diff, and restore
Every save creates an immutable revision snapshot. The aside shows a
commit-graph-style timeline (newest first) with vertical line, circle
nodes, version numbers, change type, user, and date. Clicking a past
revision renders it readonly with inline git-diff-style highlights
(changed/added/removed with tinted backgrounds). Compare mode allows
diffing any two arbitrary revisions. MANAGE users can restore old
versions, which creates a new revision preserving full history.

New: HandoverRevisionService, HandoverRevisionRepository,
migration 006, 14 PHPUnit tests, DE/EN translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:14:40 +02:00

294 lines
12 KiB
PHP

<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
use PHPUnit\Framework\TestCase;
class HandoverServiceTest extends TestCase
{
private const TENANT_ID = 1;
private const USER_ID = 42;
private const VALID_SCHEMA_JSON = '{"version":1,"fields":[{"type":"text","label":"Name","key":"name","required":true},{"type":"select","label":"Priority","key":"priority","required":false,"options":[{"value":"high","label":"High"},{"value":"low","label":"Low"}]}]}';
private function createService(
?HandoverRepository $repository = null,
?SoftwareProductService $productService = null,
?HandoverRevisionService $revisionService = null,
): HandoverService {
$repository = $repository ?? $this->createMock(HandoverRepository::class);
$productService = $productService ?? $this->createMock(SoftwareProductService::class);
return new HandoverService($repository, $productService, $revisionService);
}
private function mockProductService(bool $exists = true, bool $hasSchema = true): SoftwareProductService
{
$mock = $this->createMock(SoftwareProductService::class);
if (!$exists) {
$mock->method('findByCode')->willReturn(null);
} else {
$mock->method('findByCode')->willReturn([
'id' => 1,
'code' => 'PROD-A',
'name' => 'Product A',
'active' => 1,
'handover_protocol_schema' => $hasSchema ? self::VALID_SCHEMA_JSON : null,
]);
}
return $mock;
}
// ── Create tests ─────────────────────────────────────────────────
public function testCreateHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->once())->method('insert')->willReturn(99);
$service = $this->createService($repo, $this->mockProductService());
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$this->assertTrue($result['ok']);
$this->assertSame(99, $result['id']);
$this->assertEmpty($result['errors']);
}
public function testCreateWithInvalidProductCode(): void
{
$service = $this->createService(productService: $this->mockProductService(exists: false));
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertNull($result['id']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateWithProductWithoutSchema(): void
{
$service = $this->createService(productService: $this->mockProductService(hasSchema: false));
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertNull($result['id']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateWithEmptyDebitorNo(): void
{
$service = $this->createService();
$result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('debitor_no', $result['errors']);
}
// ── Update fields tests ──────────────────────────────────────────
public function testUpdateFieldsHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'draft',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$repo->expects($this->once())->method('updateFieldValues')->willReturn(true);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test', 'priority' => 'high'], self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertTrue($result['ok']);
}
public function testUpdateFieldsRejectsUnknownKeys(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'draft',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['unknown_field' => 'value'], self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('unknown_field', $result['errors']);
}
// ── Status transition tests ──────────────────────────────────────
public function testChangeStatusByCreateUser(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'status' => 'draft']);
$repo->method('updateStatus')->willReturn(true);
$service = $this->createService($repo);
// Can set in_progress
$result = $service->changeStatus(self::TENANT_ID, 1, 'in_progress', self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertTrue($result['ok']);
}
public function testChangeStatusCreateUserCannotArchive(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'status' => 'completed']);
$service = $this->createService($repo);
$result = $service->changeStatus(self::TENANT_ID, 1, 'archived', self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('status', $result['errors']);
}
public function testChangeStatusManageUserCanArchive(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'status' => 'completed']);
$repo->method('updateStatus')->willReturn(true);
$service = $this->createService($repo);
$result = $service->changeStatus(self::TENANT_ID, 1, 'archived', self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
// ── Edit permission tests ────────────────────────────────────────
public function testEditDeniedForCreateUserOnCompletedHandover(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'completed',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test'], self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testEditAllowedForManageUserOnCompletedHandover(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'completed',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$repo->expects($this->once())->method('updateFieldValues')->willReturn(true);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test'], self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
// ── Revision integration tests ──────────────────────────────────
public function testCreateCreatesInitialRevision(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->once())->method('insert')->willReturn(99);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
99,
[],
HandoverService::STATUS_DRAFT,
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_INITIAL
);
$service = $this->createService($repo, $this->mockProductService(), $revisionService);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$this->assertTrue($result['ok']);
}
public function testCreateRevisionAfterSaveDeterminesChangeType(): void
{
$repo = $this->createMock(HandoverRepository::class);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
1,
['name' => 'Test'],
'in_progress',
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_BOTH
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', 'draft', 1, self::USER_ID);
}
public function testCreateRevisionAfterSaveFieldsOnlyChangeType(): void
{
$repo = $this->createMock(HandoverRepository::class);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
HandoverRevisionService::CHANGE_TYPE_FIELDS
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', 'draft', 1, self::USER_ID);
}
public function testCreateRevisionForStatusChange(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'in_progress',
'field_values' => '{"name":"Test"}',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
1,
['name' => 'Test'],
'completed',
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_STATUS
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionForStatusChange(self::TENANT_ID, 1, 'completed', 1, self::USER_ID);
}
}