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), ); } }