1
0

test(customfield): cover TenantCustomFieldService (5 public methods)

Cluster B1 der Testabdeckungs-Initiative. Baut auf dem DI-Refactor aus
CUSTOMFIELD-DI-REFACTOR-001 auf und deckt den 332-Zeilen-Service mit
24 Unit-Tests / 57 Assertions ab.

Abdeckung aller 5 public methods:
- listForTenant (3 Tests: invalid id, leere Definitions, Merge mit Options)
- createForTenant (8 Tests: id/tenant-Validierung, Label/Typ-Validierung,
  key-Kollision-Fallback, select/text happy paths, insert failure)
- updateByUuid (5 Tests: empty/unknown uuid, same/different-key, select
  vs. text Sync, update failure, duplicate key von anderem record)
- deleteByUuid (4 Tests)
- definitionTenantIdByUuid (2 Tests)

Tenant-Scope-Edges (GR-SEC-009) explizit abgedeckt.

Gates: QG-001 (1969 Tests) / QG-002 / QG-006 pass.

Workflow: .agents/runs/TEST-CUSTOMFIELD-TENANT-001/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 19:39:37 +02:00
parent 6b05bbfe9c
commit 36d2bf5c15

View File

@@ -0,0 +1,492 @@
<?php
namespace MintyPHP\Tests\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepositoryInterface;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\CustomField\TenantCustomFieldService;
use PHPUnit\Framework\TestCase;
final class TenantCustomFieldServiceTest extends TestCase
{
public function testListForTenantReturnsEmptyForInvalidTenantId(): void
{
$service = $this->makeService();
$this->assertSame([], $service->listForTenant(0));
$this->assertSame([], $service->listForTenant(-5));
}
public function testListForTenantReturnsEmptyWhenNoDefinitions(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->expects($this->once())
->method('listByTenantId')
->with(10, false)
->willReturn([]);
$service = $this->makeService(definitionRepository: $definitionRepo);
$this->assertSame([], $service->listForTenant(10));
}
public function testListForTenantMergesOptionsIntoDefinitions(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantId')->willReturn([
['id' => 1, 'label' => 'Alpha', 'type' => 'select'],
['id' => 2, 'label' => 'Beta', 'type' => 'text'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->expects($this->once())
->method('listByDefinitionIds')
->with([1, 2], false)
->willReturn([
['definition_id' => 1, 'option_key' => 'a', 'label' => 'A'],
['definition_id' => 1, 'option_key' => 'b', 'label' => 'B'],
]);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->listForTenant(10);
$this->assertCount(2, $result);
$this->assertCount(2, $result[0]['options']);
$this->assertSame('a', $result[0]['options'][0]['option_key']);
$this->assertSame([], $result[1]['options']);
}
public function testCreateForTenantRejectsInvalidTenantId(): void
{
$service = $this->makeService();
$result = $service->createForTenant(0, ['label' => 'Foo'], 1);
$this->assertFalse($result['ok']);
$this->assertSame(['Tenant not found'], $result['errors']);
}
public function testCreateForTenantRejectsUnknownTenant(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->expects($this->once())->method('find')->with(99)->willReturn(null);
$service = $this->makeService(tenantRepository: $tenantRepo);
$result = $service->createForTenant(99, ['label' => 'Foo'], 1);
$this->assertFalse($result['ok']);
$this->assertSame(['Tenant not found'], $result['errors']);
}
public function testCreateForTenantReturnsValidationErrorsForEmptyLabel(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByTenantIdAndKey')->willReturn(null);
$definitionRepo->expects($this->never())->method('create');
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
);
$result = $service->createForTenant(10, ['label' => '', 'type' => 'text'], 1);
$this->assertFalse($result['ok']);
$this->assertContains('Label cannot be empty', $result['errors']);
}
public function testCreateForTenantRejectsInvalidFieldType(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByTenantIdAndKey')->willReturn(null);
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
);
$result = $service->createForTenant(10, ['label' => 'Foo', 'type' => 'rocket'], 1);
$this->assertFalse($result['ok']);
$this->assertContains('Field type is invalid', $result['errors']);
}
public function testCreateForTenantReturnsEmptyKeyErrorWhenAllCandidatesCollide(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
// Every candidate hits a different existing record, exhausting the 10000-candidate guard;
// resolveFieldKey then returns '' and validateForm flags the empty key.
$definitionRepo->method('findByTenantIdAndKey')->willReturnCallback(
static fn (int $tenantId, string $key): array => ['id' => 999, 'tenant_id' => $tenantId, 'field_key' => $key]
);
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
);
$result = $service->createForTenant(10, ['label' => 'Foo', 'type' => 'text'], 1);
$this->assertFalse($result['ok']);
$this->assertContains('Field key cannot be empty', $result['errors']);
}
public function testUpdateByUuidRejectsDuplicateFieldKeyFromDifferentRecord(): void
{
$existing = ['id' => 5, 'tenant_id' => 10, 'field_key' => 'priority'];
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn($existing);
// resolveFieldKey sees id=5 for 'priority' (== excludeId) so returns 'priority' unchanged.
// validateForm re-queries and gets the SAME key owned by a DIFFERENT record id (7) — duplicate.
$definitionRepo->method('findByTenantIdAndKey')
->willReturnOnConsecutiveCalls(
['id' => 5, 'tenant_id' => 10, 'field_key' => 'priority'],
['id' => 7, 'tenant_id' => 10, 'field_key' => 'priority'],
);
$definitionRepo->expects($this->never())->method('update');
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->updateByUuid('uuid-of-5', [
'label' => 'Priority',
'type' => 'text',
'field_key' => 'priority',
], 7);
$this->assertFalse($result['ok']);
$this->assertContains('Field key already exists', $result['errors']);
}
public function testCreateForTenantRejectsSelectWithoutOptions(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByTenantIdAndKey')->willReturn(null);
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
);
$result = $service->createForTenant(10, [
'label' => 'Prio',
'type' => 'select',
'options_text' => '',
], 1);
$this->assertFalse($result['ok']);
$this->assertContains('Options are required for this field type', $result['errors']);
}
public function testCreateForTenantInsertsTextFieldWithoutReplacingOptions(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByTenantIdAndKey')->willReturn(null);
$definitionRepo->method('listByTenantId')->willReturn([]);
$definitionRepo->expects($this->once())
->method('create')
->with($this->callback(static function (array $data): bool {
return ($data['tenant_id'] ?? null) === 10
&& ($data['label'] ?? null) === 'Nickname'
&& ($data['type'] ?? null) === 'text'
&& ($data['sort_order'] ?? null) === 100
&& ($data['created_by'] ?? null) === 7;
}))
->willReturn(42);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->expects($this->never())->method('replaceForDefinition');
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->createForTenant(10, [
'label' => 'Nickname',
'type' => 'text',
], 7);
$this->assertTrue($result['ok']);
$this->assertSame(42, $result['id']);
}
public function testCreateForTenantInsertsSelectFieldAndReplacesOptions(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByTenantIdAndKey')->willReturn(null);
$definitionRepo->method('listByTenantId')->willReturn([
['id' => 1, 'sort_order' => 200],
]);
$definitionRepo->method('create')->willReturn(99);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->expects($this->once())
->method('replaceForDefinition')
->with(99, $this->callback(static function (array $options): bool {
return count($options) === 2
&& $options[0]['option_key'] === 'low'
&& $options[1]['option_key'] === 'high';
}))
->willReturn(true);
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->createForTenant(10, [
'label' => 'Priority',
'type' => 'select',
'is_filterable' => 1,
'options_text' => "low|Low\nhigh|High",
], 7);
$this->assertTrue($result['ok']);
$this->assertSame(99, $result['id']);
// No explicit sort_order input → form keeps null; createForTenant fills the insert via nextSortOrder (200+10).
$this->assertNull($result['form']['sort_order']);
}
public function testCreateForTenantReturnsInsertFailureWhenRepoReturnsFalse(): void
{
$tenantRepo = $this->createMock(TenantRepository::class);
$tenantRepo->method('find')->willReturn(['id' => 10]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByTenantIdAndKey')->willReturn(null);
$definitionRepo->method('listByTenantId')->willReturn([]);
$definitionRepo->method('create')->willReturn(false);
$service = $this->makeService(
tenantRepository: $tenantRepo,
definitionRepository: $definitionRepo,
);
$result = $service->createForTenant(10, ['label' => 'Foo', 'type' => 'text'], 1);
$this->assertFalse($result['ok']);
$this->assertContains('Custom field can not be created', $result['errors']);
}
public function testUpdateByUuidRejectsEmptyUuid(): void
{
$service = $this->makeService();
$result = $service->updateByUuid(' ', [], 1);
$this->assertFalse($result['ok']);
$this->assertSame(['Custom field not found'], $result['errors']);
}
public function testUpdateByUuidRejectsUnknownUuid(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn(null);
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->updateByUuid('missing-uuid', [], 1);
$this->assertFalse($result['ok']);
$this->assertSame(['Custom field not found'], $result['errors']);
}
public function testUpdateByUuidPreservesExistingFieldKeyWhenSameRecord(): void
{
$existing = ['id' => 5, 'tenant_id' => 10, 'field_key' => 'priority', 'sort_order' => 30];
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn($existing);
$definitionRepo->method('findByTenantIdAndKey')->willReturnCallback(
static fn (int $tenantId, string $key): array => ['id' => 5, 'tenant_id' => $tenantId, 'field_key' => $key]
);
$definitionRepo->expects($this->once())
->method('update')
->with(5, $this->callback(static function (array $data): bool {
return ($data['field_key'] ?? null) === 'priority'
&& ($data['type'] ?? null) === 'text'
&& ($data['modified_by'] ?? null) === 7;
}))
->willReturn(true);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->expects($this->once())
->method('deleteByDefinitionId')
->with(5)
->willReturn(true);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->updateByUuid('uuid-of-5', [
'label' => 'Priority',
'type' => 'text',
'field_key' => 'priority',
], 7);
$this->assertTrue($result['ok']);
}
public function testUpdateByUuidReplacesOptionsWhenSelectType(): void
{
$existing = ['id' => 5, 'tenant_id' => 10, 'field_key' => 'priority', 'sort_order' => 30];
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn($existing);
$definitionRepo->method('findByTenantIdAndKey')->willReturn($existing);
$definitionRepo->method('update')->willReturn(true);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->expects($this->once())
->method('replaceForDefinition')
->with(5, $this->callback(static fn ($options): bool => is_array($options)))
->willReturn(true);
$optionRepo->expects($this->never())->method('deleteByDefinitionId');
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->updateByUuid('uuid-of-5', [
'label' => 'Priority',
'type' => 'select',
'options_text' => 'low|Low',
'field_key' => 'priority',
], 7);
$this->assertTrue($result['ok']);
}
public function testUpdateByUuidReturnsUpdateFailureWhenRepoReturnsFalse(): void
{
$existing = ['id' => 5, 'tenant_id' => 10, 'field_key' => 'priority'];
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn($existing);
$definitionRepo->method('findByTenantIdAndKey')->willReturn($existing);
$definitionRepo->method('update')->willReturn(false);
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->updateByUuid('uuid-of-5', [
'label' => 'Priority',
'type' => 'text',
'field_key' => 'priority',
], 7);
$this->assertFalse($result['ok']);
$this->assertContains('Custom field can not be updated', $result['errors']);
}
public function testDeleteByUuidRejectsEmptyUuid(): void
{
$service = $this->makeService();
$result = $service->deleteByUuid('');
$this->assertSame(['ok' => false, 'status' => 404, 'error' => 'not_found'], $result);
}
public function testDeleteByUuidRejectsUnknownUuid(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn(null);
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->deleteByUuid('unknown');
$this->assertSame(['ok' => false, 'status' => 404, 'error' => 'not_found'], $result);
}
public function testDeleteByUuidReturnsFailureWhenRepoReturnsFalse(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn(['id' => 7]);
$definitionRepo->expects($this->once())->method('delete')->with(7)->willReturn(false);
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->deleteByUuid('uuid-7');
$this->assertSame(['ok' => false, 'status' => 500, 'error' => 'delete_failed'], $result);
}
public function testDeleteByUuidReturnsDefinitionOnSuccess(): void
{
$existing = ['id' => 7, 'tenant_id' => 10, 'label' => 'Gone'];
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn($existing);
$definitionRepo->expects($this->once())->method('delete')->with(7)->willReturn(true);
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->deleteByUuid('uuid-7');
$this->assertTrue($result['ok']);
$this->assertSame($existing, $result['definition']);
}
public function testDefinitionTenantIdByUuidReturnsZeroWhenNotFound(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('findByUuid')->willReturn(null);
$service = $this->makeService(definitionRepository: $definitionRepo);
$this->assertSame(0, $service->definitionTenantIdByUuid(' '));
}
public function testDefinitionTenantIdByUuidReturnsTenantIdWhenFound(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->expects($this->once())->method('findByUuid')->with('abc')->willReturn(['tenant_id' => 42]);
$service = $this->makeService(definitionRepository: $definitionRepo);
$this->assertSame(42, $service->definitionTenantIdByUuid('abc'));
}
private function makeService(
?TenantRepository $tenantRepository = null,
?TenantCustomFieldDefinitionRepositoryInterface $definitionRepository = null,
?TenantCustomFieldOptionRepositoryInterface $optionRepository = null,
): TenantCustomFieldService {
return new TenantCustomFieldService(
$tenantRepository ?? $this->createMock(TenantRepository::class),
$definitionRepository ?? $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class),
$optionRepository ?? $this->createMock(TenantCustomFieldOptionRepositoryInterface::class),
);
}
}