feat(helpdesk): add handover protocol management
Implement handover protocols as a new entity in the helpdesk module, allowing users to create fillable protocol records from admin-defined software product schemas. Key additions: - DB migration (helpdesk_handovers table) with tenant scope - HandoverService with status workflow (draft/in_progress/completed/archived) - Three-tier permissions (view/create/manage) - Two-step creation wizard (Stripe-style assistant) - Grid.js list page with search and status filter - Edit/detail page with aside metadata and status controls - Reusable core autocomplete lookup component (app-lookup-field) - Debitor lookup data endpoint for autocomplete - Dynamic form rendering from schema snapshots - 11 PHPUnit tests for HandoverService - DE+EN i18n translations (48 keys each) Also includes: PHPStan fixes (dead code removal, stale baseline cleanup), software product edit title improvement, fieldset simplification, and Stripe-style hover for schema preview. Workflow: HD-HANDOVERS-001 (.agents/runs/HD-HANDOVERS-001/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
|
||||
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,
|
||||
): HandoverService {
|
||||
$repository = $repository ?? $this->createMock(HandoverRepository::class);
|
||||
$productService = $productService ?? $this->createMock(SoftwareProductService::class);
|
||||
|
||||
return new HandoverService($repository, $productService);
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user