forked from fa/breadcrumb-the-shire
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>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HandoverRevisionServiceTest extends TestCase
|
||||
{
|
||||
private const TENANT_ID = 1;
|
||||
private const USER_ID = 42;
|
||||
private const HANDOVER_ID = 10;
|
||||
|
||||
private function createService(
|
||||
?HandoverRevisionRepository $revisionRepo = null,
|
||||
?HandoverRepository $handoverRepo = null,
|
||||
): HandoverRevisionService {
|
||||
$revisionRepo = $revisionRepo ?? $this->createMock(HandoverRevisionRepository::class);
|
||||
$handoverRepo = $handoverRepo ?? $this->createMock(HandoverRepository::class);
|
||||
|
||||
return new HandoverRevisionService($revisionRepo, $handoverRepo);
|
||||
}
|
||||
|
||||
// ── createRevision ───────────────────────────────────────────────
|
||||
|
||||
public function testCreateRevisionIncrementsNumber(): void
|
||||
{
|
||||
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
|
||||
$revisionRepo->method('getLatestRevisionNumber')->willReturn(3);
|
||||
$revisionRepo->expects($this->once())
|
||||
->method('insert')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return $data['revision'] === 4;
|
||||
}))
|
||||
->willReturn(100);
|
||||
|
||||
$service = $this->createService($revisionRepo);
|
||||
$result = $service->createRevision(self::TENANT_ID, self::HANDOVER_ID, ['name' => 'Test'], 'draft', 1, self::USER_ID);
|
||||
|
||||
$this->assertSame(100, $result);
|
||||
}
|
||||
|
||||
public function testCreateRevisionStoresSnapshot(): void
|
||||
{
|
||||
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
|
||||
$revisionRepo->method('getLatestRevisionNumber')->willReturn(0);
|
||||
$revisionRepo->expects($this->once())
|
||||
->method('insert')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return $data['status'] === 'in_progress'
|
||||
&& $data['schema_version'] === 2
|
||||
&& $data['change_type'] === 'fields'
|
||||
&& json_decode($data['field_values'], true) === ['name' => 'Acme'];
|
||||
}))
|
||||
->willReturn(1);
|
||||
|
||||
$service = $this->createService($revisionRepo);
|
||||
$service->createRevision(self::TENANT_ID, self::HANDOVER_ID, ['name' => 'Acme'], 'in_progress', 2, self::USER_ID);
|
||||
}
|
||||
|
||||
// ── computeDiff ──────────────────────────────────────────────────
|
||||
|
||||
public function testComputeDiffDetectsAddedFields(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$diff = $service->computeDiff([], ['name' => 'New']);
|
||||
|
||||
$this->assertArrayHasKey('name', $diff);
|
||||
$this->assertSame('added', $diff['name']['type']);
|
||||
$this->assertNull($diff['name']['old']);
|
||||
$this->assertSame('New', $diff['name']['new']);
|
||||
}
|
||||
|
||||
public function testComputeDiffDetectsRemovedFields(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$diff = $service->computeDiff(['name' => 'Old'], []);
|
||||
|
||||
$this->assertArrayHasKey('name', $diff);
|
||||
$this->assertSame('removed', $diff['name']['type']);
|
||||
$this->assertSame('Old', $diff['name']['old']);
|
||||
$this->assertNull($diff['name']['new']);
|
||||
}
|
||||
|
||||
public function testComputeDiffDetectsChangedFields(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$diff = $service->computeDiff(['name' => 'Old', 'priority' => 'low'], ['name' => 'New', 'priority' => 'low']);
|
||||
|
||||
$this->assertArrayHasKey('name', $diff);
|
||||
$this->assertSame('changed', $diff['name']['type']);
|
||||
$this->assertSame('Old', $diff['name']['old']);
|
||||
$this->assertSame('New', $diff['name']['new']);
|
||||
$this->assertArrayNotHasKey('priority', $diff);
|
||||
}
|
||||
|
||||
public function testComputeDiffHandlesEmptyArrays(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$diff = $service->computeDiff([], []);
|
||||
|
||||
$this->assertSame([], $diff);
|
||||
}
|
||||
|
||||
// ── restoreRevision ──────────────────────────────────────────────
|
||||
|
||||
public function testRestoreRevisionRequiresManagePermission(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 1, self::USER_ID, HandoverService::PERMISSION_CREATE);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('general', $result['errors']);
|
||||
}
|
||||
|
||||
public function testRestoreRevisionRejectsInvalidRevision(): void
|
||||
{
|
||||
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
|
||||
$revisionRepo->method('findByRevision')->willReturn(null);
|
||||
|
||||
$service = $this->createService($revisionRepo);
|
||||
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 999, self::USER_ID, HandoverService::PERMISSION_MANAGE);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testRestoreRevisionCreatesNewRevision(): void
|
||||
{
|
||||
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
|
||||
$revisionRepo->method('findByRevision')->willReturn([
|
||||
'id' => 1,
|
||||
'revision' => 2,
|
||||
'field_values' => '{"name":"Old value"}',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
$revisionRepo->method('getLatestRevisionNumber')->willReturn(5);
|
||||
$revisionRepo->expects($this->once())
|
||||
->method('insert')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return $data['revision'] === 6
|
||||
&& $data['change_type'] === 'restore'
|
||||
&& json_decode($data['field_values'], true) === ['name' => 'Old value'];
|
||||
}))
|
||||
->willReturn(200);
|
||||
|
||||
$handoverRepo = $this->createMock(HandoverRepository::class);
|
||||
$handoverRepo->method('findById')->willReturn([
|
||||
'id' => self::HANDOVER_ID,
|
||||
'status' => 'in_progress',
|
||||
'field_values' => '{"name":"Current value"}',
|
||||
'schema_snapshot' => '{"version":1,"fields":[]}',
|
||||
]);
|
||||
$handoverRepo->expects($this->once())->method('updateFieldValues');
|
||||
$handoverRepo->expects($this->once())->method('updateStatus');
|
||||
|
||||
$service = $this->createService($revisionRepo, $handoverRepo);
|
||||
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 2, self::USER_ID, HandoverService::PERMISSION_MANAGE);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testRestoreRevisionSkipsStatusUpdateWhenSame(): void
|
||||
{
|
||||
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
|
||||
$revisionRepo->method('findByRevision')->willReturn([
|
||||
'id' => 1,
|
||||
'revision' => 1,
|
||||
'field_values' => '{"name":"Old"}',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
$revisionRepo->method('getLatestRevisionNumber')->willReturn(3);
|
||||
$revisionRepo->method('insert')->willReturn(100);
|
||||
|
||||
$handoverRepo = $this->createMock(HandoverRepository::class);
|
||||
$handoverRepo->method('findById')->willReturn([
|
||||
'id' => self::HANDOVER_ID,
|
||||
'status' => 'draft',
|
||||
'field_values' => '{"name":"Current"}',
|
||||
'schema_snapshot' => '{"version":1,"fields":[]}',
|
||||
]);
|
||||
$handoverRepo->expects($this->once())->method('updateFieldValues');
|
||||
$handoverRepo->expects($this->never())->method('updateStatus');
|
||||
|
||||
$service = $this->createService($revisionRepo, $handoverRepo);
|
||||
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user