feat(helpdesk): add handover protocol schema editor to software products

Add a JSON-based schema editor as a new tab on the software product edit
page. Admins can define protocol fields (heading, paragraph, text,
textarea, number, date, checkbox, select) via a structured UI with
add/remove/reorder controls. The schema is stored as a versioned JSON
column on the product row.

Includes DB migration, server-side validation (type whitelist, key
uniqueness, key format, max 50 fields, select option checks), i18n
(DE+EN), CSS, and 10 PHPUnit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 16:44:12 +02:00
parent 364bda67a9
commit 44153f513f
11 changed files with 862 additions and 3 deletions

View File

@@ -94,6 +94,176 @@ class SoftwareProductServiceTest extends TestCase
$this->assertTrue($result['ok']);
}
// --- Handover schema tests ---
private function createServiceWithProduct(array $product = []): array
{
$product = array_merge([
'id' => 1,
'code' => 'INFO',
'name' => 'Infopoints',
'active' => 1,
'handover_protocol_schema' => null,
], $product);
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn($product);
return [$this->createService($repository), $repository];
}
public function testSaveHandoverSchemaWithValidFields(): void
{
[$service, $repository] = $this->createServiceWithProduct();
$repository->expects($this->once())
->method('updateHandoverSchema')
->with('INFO', $this->callback(function ($json) {
$data = json_decode($json, true);
return $data['version'] === 1
&& count($data['fields']) === 2
&& $data['fields'][0]['key'] === 'handover_date'
&& $data['fields'][1]['key'] === 'notes';
}))
->willReturn(true);
$result = $service->saveHandoverSchema('INFO', [
['type' => 'date', 'key' => 'handover_date', 'label' => 'Handover date', 'required' => true],
['type' => 'textarea', 'key' => 'notes', 'label' => 'Notes', 'required' => false],
]);
$this->assertTrue($result['ok']);
$this->assertEmpty($result['errors']);
}
public function testSaveHandoverSchemaEmptyFieldsSetsNull(): void
{
[$service, $repository] = $this->createServiceWithProduct();
$repository->expects($this->once())
->method('updateHandoverSchema')
->with('INFO', null)
->willReturn(true);
$result = $service->saveHandoverSchema('INFO', []);
$this->assertTrue($result['ok']);
}
public function testSaveHandoverSchemaRejectsDuplicateKeys(): void
{
[$service] = $this->createServiceWithProduct();
$result = $service->saveHandoverSchema('INFO', [
['type' => 'text', 'key' => 'name', 'label' => 'Name 1'],
['type' => 'text', 'key' => 'name', 'label' => 'Name 2'],
]);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('field_2_key', $result['errors']);
}
public function testSaveHandoverSchemaRejectsInvalidKeyFormat(): void
{
[$service] = $this->createServiceWithProduct();
$result = $service->saveHandoverSchema('INFO', [
['type' => 'text', 'key' => '1invalid', 'label' => 'Bad key'],
]);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('field_1_key', $result['errors']);
}
public function testSaveHandoverSchemaRejectsUnknownType(): void
{
[$service] = $this->createServiceWithProduct();
$result = $service->saveHandoverSchema('INFO', [
['type' => 'unknown_type', 'key' => 'field', 'label' => 'Field'],
]);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('field_1_type', $result['errors']);
}
public function testSaveHandoverSchemaRejectsMoreThan50Fields(): void
{
[$service] = $this->createServiceWithProduct();
$fields = [];
for ($i = 0; $i < 51; $i++) {
$fields[] = ['type' => 'text', 'key' => 'field_' . $i, 'label' => 'Field ' . $i];
}
$result = $service->saveHandoverSchema('INFO', $fields);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('schema', $result['errors']);
}
public function testSaveHandoverSchemaRejectsSelectWithNoOptions(): void
{
[$service] = $this->createServiceWithProduct();
$result = $service->saveHandoverSchema('INFO', [
['type' => 'select', 'key' => 'choice', 'label' => 'Choice', 'options' => []],
]);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('field_1_options', $result['errors']);
}
public function testSaveHandoverSchemaAllowsHeadingWithoutKey(): void
{
[$service, $repository] = $this->createServiceWithProduct();
$repository->expects($this->once())
->method('updateHandoverSchema')
->willReturn(true);
$result = $service->saveHandoverSchema('INFO', [
['type' => 'heading', 'label' => 'Section Title'],
]);
$this->assertTrue($result['ok']);
}
public function testSaveHandoverSchemaIncrementsVersion(): void
{
$existingSchema = json_encode(['version' => 3, 'fields' => []], JSON_THROW_ON_ERROR);
[$service, $repository] = $this->createServiceWithProduct([
'handover_protocol_schema' => $existingSchema,
]);
$repository->expects($this->once())
->method('updateHandoverSchema')
->with('INFO', $this->callback(function ($json) {
$data = json_decode($json, true);
return $data['version'] === 4;
}))
->willReturn(true);
$result = $service->saveHandoverSchema('INFO', [
['type' => 'text', 'key' => 'name', 'label' => 'Name'],
]);
$this->assertTrue($result['ok']);
}
public function testSaveHandoverSchemaRejectsMissingProduct(): void
{
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn(null);
$service = $this->createService($repository);
$result = $service->saveHandoverSchema('NONEXISTENT', [
['type' => 'text', 'key' => 'name', 'label' => 'Name'],
]);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('schema', $result['errors']);
}
public function testListPagedDelegatesToRepository(): void
{
$expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]];