2026-04-15 20:42:41 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
|
test(helpdesk): add tests for assign, submitForReview, resendNotification and HandoverNotificationService
HandoverServiceTest: 30 new cases covering:
- assign(): happy path, draft→in_progress transition, permission check,
zero assignedTo, not found, email sent, due date passed to repo
- submitForReview(): assignee and manager paths, non-assignee denied,
wrong status (under_review/completed), revision created, email sent
- resendNotification(): happy path, permission denied, no assignee
- statusLabel/Variant for under_review
HandoverNotificationServiceTest: 9 new cases covering:
- notifyAssigned: sends correct template+vars, skips on empty/null email,
fallback dash for empty debitor and due_date
- notifyReviewRequested: sends to manager, skips on no email,
fallback dash for empty assignee name
48 tests total, all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:59:10 +02:00
|
|
|
use MintyPHP\Module\Helpdesk\Service\HandoverNotificationService;
|
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
|
|
|
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
|
2026-04-15 20:42:41 +02:00
|
|
|
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,
|
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
|
|
|
?HandoverRevisionService $revisionService = null,
|
test(helpdesk): add tests for assign, submitForReview, resendNotification and HandoverNotificationService
HandoverServiceTest: 30 new cases covering:
- assign(): happy path, draft→in_progress transition, permission check,
zero assignedTo, not found, email sent, due date passed to repo
- submitForReview(): assignee and manager paths, non-assignee denied,
wrong status (under_review/completed), revision created, email sent
- resendNotification(): happy path, permission denied, no assignee
- statusLabel/Variant for under_review
HandoverNotificationServiceTest: 9 new cases covering:
- notifyAssigned: sends correct template+vars, skips on empty/null email,
fallback dash for empty debitor and due_date
- notifyReviewRequested: sends to manager, skips on no email,
fallback dash for empty assignee name
48 tests total, all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:59:10 +02:00
|
|
|
?HandoverNotificationService $notificationService = null,
|
2026-04-15 20:42:41 +02:00
|
|
|
): HandoverService {
|
|
|
|
|
$repository = $repository ?? $this->createMock(HandoverRepository::class);
|
|
|
|
|
$productService = $productService ?? $this->createMock(SoftwareProductService::class);
|
|
|
|
|
|
test(helpdesk): add tests for assign, submitForReview, resendNotification and HandoverNotificationService
HandoverServiceTest: 30 new cases covering:
- assign(): happy path, draft→in_progress transition, permission check,
zero assignedTo, not found, email sent, due date passed to repo
- submitForReview(): assignee and manager paths, non-assignee denied,
wrong status (under_review/completed), revision created, email sent
- resendNotification(): happy path, permission denied, no assignee
- statusLabel/Variant for under_review
HandoverNotificationServiceTest: 9 new cases covering:
- notifyAssigned: sends correct template+vars, skips on empty/null email,
fallback dash for empty debitor and due_date
- notifyReviewRequested: sends to manager, skips on no email,
fallback dash for empty assignee name
48 tests total, all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:59:10 +02:00
|
|
|
return new HandoverService($repository, $productService, $revisionService, $notificationService);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function handoverRow(array $overrides = []): array
|
|
|
|
|
{
|
|
|
|
|
return array_merge([
|
|
|
|
|
'id' => 1,
|
|
|
|
|
'tenant_id' => self::TENANT_ID,
|
|
|
|
|
'status' => HandoverService::STATUS_IN_PROGRESS,
|
|
|
|
|
'assigned_to' => 0,
|
|
|
|
|
'assigned_by' => 0,
|
|
|
|
|
'debitor_name' => 'Acme Corp',
|
|
|
|
|
'domain_url' => 'example.com',
|
|
|
|
|
'due_date' => null,
|
|
|
|
|
'assigned_to_label' => null,
|
|
|
|
|
'assigned_to_email' => null,
|
|
|
|
|
'assigned_by_label' => null,
|
|
|
|
|
'assigned_by_email' => null,
|
|
|
|
|
], $overrides);
|
2026-04-15 20:42:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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());
|
2026-04-16 13:21:12 +02:00
|
|
|
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
|
2026-04-15 20:42:41 +02:00
|
|
|
|
|
|
|
|
$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));
|
2026-04-16 13:21:12 +02:00
|
|
|
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', 'DNS00001', 'example.com', self::USER_ID);
|
2026-04-15 20:42:41 +02:00
|
|
|
|
|
|
|
|
$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));
|
2026-04-16 13:21:12 +02:00
|
|
|
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
|
2026-04-15 20:42:41 +02:00
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertNull($result['id']);
|
|
|
|
|
$this->assertArrayHasKey('product_code', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testCreateWithEmptyDebitorNo(): void
|
|
|
|
|
{
|
|
|
|
|
$service = $this->createService();
|
2026-04-16 13:21:12 +02:00
|
|
|
$result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
|
2026-04-15 20:42:41 +02:00
|
|
|
|
|
|
|
|
$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']);
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
// ── 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);
|
2026-04-16 13:21:12 +02:00
|
|
|
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
|
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
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:21:12 +02:00
|
|
|
public function testCreateWithEmptyDomainNo(): void
|
|
|
|
|
{
|
|
|
|
|
$service = $this->createService(productService: $this->mockProductService());
|
|
|
|
|
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', '', 'example.com', self::USER_ID);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('domain_no', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testCreatePassesDomainToRepository(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->expects($this->once())
|
|
|
|
|
->method('insert')
|
|
|
|
|
->with($this->callback(function (array $data): bool {
|
|
|
|
|
return ($data['domain_no'] ?? '') === 'DNS00001'
|
|
|
|
|
&& ($data['domain_url'] ?? '') === 'example.com';
|
|
|
|
|
}))
|
|
|
|
|
->willReturn(99);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, $this->mockProductService());
|
|
|
|
|
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testCreateRevisionAfterSaveSetsBothWhenFieldsAndStatusChanged(): void
|
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
|
|
|
{
|
|
|
|
|
$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);
|
2026-04-16 13:21:12 +02:00
|
|
|
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', true, true, 1, self::USER_ID);
|
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
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:21:12 +02:00
|
|
|
public function testCreateRevisionAfterSaveSetsFieldsWhenOnlyFieldsChanged(): void
|
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
|
|
|
{
|
|
|
|
|
$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);
|
2026-04-16 13:21:12 +02:00
|
|
|
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', true, false, 1, self::USER_ID);
|
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
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:21:12 +02:00
|
|
|
public function testCreateRevisionAfterSaveSetsStatusWhenOnlyStatusChanged(): void
|
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
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
|
|
|
|
|
$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);
|
2026-04-16 13:21:12 +02:00
|
|
|
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'completed', false, true, 1, self::USER_ID);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testCreateRevisionAfterSaveDefaultsToFieldsWhenNoContentChanged(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
|
|
|
|
|
$revisionService = $this->createMock(HandoverRevisionService::class);
|
|
|
|
|
$revisionService->expects($this->once())
|
|
|
|
|
->method('createRevision')
|
|
|
|
|
->with(
|
|
|
|
|
self::TENANT_ID,
|
|
|
|
|
1,
|
|
|
|
|
['name' => 'Test'],
|
|
|
|
|
'draft',
|
|
|
|
|
1,
|
|
|
|
|
self::USER_ID,
|
|
|
|
|
HandoverRevisionService::CHANGE_TYPE_FIELDS
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, null, $revisionService);
|
|
|
|
|
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', false, false, 1, self::USER_ID);
|
|
|
|
|
}
|
|
|
|
|
|
test(helpdesk): add tests for assign, submitForReview, resendNotification and HandoverNotificationService
HandoverServiceTest: 30 new cases covering:
- assign(): happy path, draft→in_progress transition, permission check,
zero assignedTo, not found, email sent, due date passed to repo
- submitForReview(): assignee and manager paths, non-assignee denied,
wrong status (under_review/completed), revision created, email sent
- resendNotification(): happy path, permission denied, no assignee
- statusLabel/Variant for under_review
HandoverNotificationServiceTest: 9 new cases covering:
- notifyAssigned: sends correct template+vars, skips on empty/null email,
fallback dash for empty debitor and due_date
- notifyReviewRequested: sends to manager, skips on no email,
fallback dash for empty assignee name
48 tests total, all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:59:10 +02:00
|
|
|
// ── Assign tests ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function testAssignHappyPath(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow(['status' => HandoverService::STATUS_IN_PROGRESS]));
|
|
|
|
|
$repo->expects($this->once())->method('assign')->willReturn(true);
|
|
|
|
|
$repo->expects($this->never())->method('updateStatus');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertEmpty($result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAssignDraftHandoverTransitionsToInProgress(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow(['status' => HandoverService::STATUS_DRAFT]));
|
|
|
|
|
$repo->method('assign')->willReturn(true);
|
|
|
|
|
$repo->expects($this->once())
|
|
|
|
|
->method('updateStatus')
|
|
|
|
|
->with(self::TENANT_ID, 1, HandoverService::STATUS_IN_PROGRESS, self::USER_ID);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAssignDeniedForCreatePermission(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->expects($this->never())->method('assign');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('general', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAssignFailsWhenAssignedToIsZero(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow());
|
|
|
|
|
$repo->expects($this->never())->method('assign');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->assign(self::TENANT_ID, 1, 0, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('assigned_to', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAssignFailsWhenHandoverNotFound(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn(null);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('general', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAssignSendsNotificationEmail(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to_email' => 'employee@example.com',
|
|
|
|
|
'assigned_to_label' => 'John Doe',
|
|
|
|
|
]));
|
|
|
|
|
$repo->method('assign')->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$notificationService = $this->createMock(HandoverNotificationService::class);
|
|
|
|
|
$notificationService->expects($this->once())->method('notifyAssigned');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, notificationService: $notificationService);
|
|
|
|
|
$service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAssignWithDueDatePassesItToRepository(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow());
|
|
|
|
|
$repo->expects($this->once())
|
|
|
|
|
->method('assign')
|
|
|
|
|
->with(self::TENANT_ID, 1, 99, self::USER_ID, '2026-12-31', self::USER_ID)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, '2026-12-31', HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Submit for review tests ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewHappyPathByAssignee(): void
|
|
|
|
|
{
|
|
|
|
|
$assigneeId = 99;
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => $assigneeId,
|
|
|
|
|
'status' => HandoverService::STATUS_IN_PROGRESS,
|
|
|
|
|
]));
|
|
|
|
|
$repo->expects($this->once())
|
|
|
|
|
->method('updateStatus')
|
|
|
|
|
->with(self::TENANT_ID, 1, HandoverService::STATUS_UNDER_REVIEW, $assigneeId)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewAllowedForManager(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => 99,
|
|
|
|
|
'status' => HandoverService::STATUS_DRAFT,
|
|
|
|
|
]));
|
|
|
|
|
$repo->method('updateStatus')->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->submitForReview(self::TENANT_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewDeniedForNonAssignee(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => 99,
|
|
|
|
|
'status' => HandoverService::STATUS_IN_PROGRESS,
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->submitForReview(self::TENANT_ID, 1, self::USER_ID, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('general', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewDeniedWhenStatusIsUnderReview(): void
|
|
|
|
|
{
|
|
|
|
|
$assigneeId = 99;
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => $assigneeId,
|
|
|
|
|
'status' => HandoverService::STATUS_UNDER_REVIEW,
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('status', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewDeniedWhenStatusIsCompleted(): void
|
|
|
|
|
{
|
|
|
|
|
$assigneeId = 99;
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => $assigneeId,
|
|
|
|
|
'status' => HandoverService::STATUS_COMPLETED,
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('status', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewSendsReviewRequestedEmail(): void
|
|
|
|
|
{
|
|
|
|
|
$assigneeId = 99;
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => $assigneeId,
|
|
|
|
|
'status' => HandoverService::STATUS_IN_PROGRESS,
|
|
|
|
|
'assigned_by_email' => 'manager@example.com',
|
|
|
|
|
]));
|
|
|
|
|
$repo->method('updateStatus')->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$notificationService = $this->createMock(HandoverNotificationService::class);
|
|
|
|
|
$notificationService->expects($this->once())->method('notifyReviewRequested');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, notificationService: $notificationService);
|
|
|
|
|
$service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSubmitForReviewCreatesRevision(): void
|
|
|
|
|
{
|
|
|
|
|
$assigneeId = 99;
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow([
|
|
|
|
|
'assigned_to' => $assigneeId,
|
|
|
|
|
'status' => HandoverService::STATUS_IN_PROGRESS,
|
|
|
|
|
]));
|
|
|
|
|
$repo->method('updateStatus')->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$revisionService = $this->createMock(HandoverRevisionService::class);
|
|
|
|
|
$revisionService->expects($this->once())
|
|
|
|
|
->method('createRevision')
|
|
|
|
|
->with(
|
|
|
|
|
self::TENANT_ID,
|
|
|
|
|
1,
|
|
|
|
|
[],
|
|
|
|
|
HandoverService::STATUS_UNDER_REVIEW,
|
|
|
|
|
1,
|
|
|
|
|
$assigneeId,
|
|
|
|
|
HandoverRevisionService::CHANGE_TYPE_STATUS
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, revisionService: $revisionService);
|
|
|
|
|
$service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Resend notification tests ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function testResendNotificationHappyPath(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow(['assigned_to' => 99]));
|
|
|
|
|
|
|
|
|
|
$notificationService = $this->createMock(HandoverNotificationService::class);
|
|
|
|
|
$notificationService->expects($this->once())->method('notifyAssigned');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, notificationService: $notificationService);
|
|
|
|
|
$result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testResendNotificationDeniedForCreatePermission(): void
|
|
|
|
|
{
|
|
|
|
|
$notificationService = $this->createMock(HandoverNotificationService::class);
|
|
|
|
|
$notificationService->expects($this->never())->method('notifyAssigned');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService(notificationService: $notificationService);
|
|
|
|
|
$result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('general', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testResendNotificationFailsWhenNoAssignee(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->method('findById')->willReturn($this->handoverRow(['assigned_to' => 0]));
|
|
|
|
|
|
|
|
|
|
$notificationService = $this->createMock(HandoverNotificationService::class);
|
|
|
|
|
$notificationService->expects($this->never())->method('notifyAssigned');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo, notificationService: $notificationService);
|
|
|
|
|
$result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertArrayHasKey('general', $result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Status label / variant tests ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function testStatusLabelUnderReview(): void
|
|
|
|
|
{
|
|
|
|
|
$this->assertNotEmpty(HandoverService::statusLabel(HandoverService::STATUS_UNDER_REVIEW));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testStatusVariantUnderReview(): void
|
|
|
|
|
{
|
|
|
|
|
$this->assertSame('info', HandoverService::statusVariant(HandoverService::STATUS_UNDER_REVIEW));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:21:12 +02:00
|
|
|
// ── Delete tests ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function testDeleteByIdsHappyPath(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->expects($this->once())
|
|
|
|
|
->method('deleteByIds')
|
|
|
|
|
->with(self::TENANT_ID, [1, 2, 3])
|
|
|
|
|
->willReturn(3);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->deleteByIds(self::TENANT_ID, [1, 2, 3], HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame(3, $result['count']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testDeleteByIdsDeniedForCreatePermission(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->expects($this->never())->method('deleteByIds');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->deleteByIds(self::TENANT_ID, [1], HandoverService::PERMISSION_CREATE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(0, $result['count']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testDeleteByIdsWithEmptyArray(): void
|
|
|
|
|
{
|
|
|
|
|
$repo = $this->createMock(HandoverRepository::class);
|
|
|
|
|
$repo->expects($this->never())->method('deleteByIds');
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($repo);
|
|
|
|
|
$result = $service->deleteByIds(self::TENANT_ID, [], HandoverService::PERMISSION_MANAGE);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
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
|
|
|
}
|
2026-04-15 20:42:41 +02:00
|
|
|
}
|