From 36d2bf5c1515e8679f85f7e266a7c31afed966eb Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 22 Apr 2026 19:39:37 +0200 Subject: [PATCH] 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) --- .../TenantCustomFieldServiceTest.php | 492 ++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 tests/Service/CustomField/TenantCustomFieldServiceTest.php diff --git a/tests/Service/CustomField/TenantCustomFieldServiceTest.php b/tests/Service/CustomField/TenantCustomFieldServiceTest.php new file mode 100644 index 0000000..b929b35 --- /dev/null +++ b/tests/Service/CustomField/TenantCustomFieldServiceTest.php @@ -0,0 +1,492 @@ +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), + ); + } +}