makeService(); $this->assertSame(['ok' => true, 'errors' => []], $service->validateForTenants([1], [], false)); } public function testValidateForTenantsSkipsWhenTenantsEmpty(): void { $service = $this->makeService(); $this->assertSame(['ok' => true, 'errors' => []], $service->validateForTenants([0, -1], [], true)); } public function testValidateForTenantsReturnsPreparedErrorsWhenInvalidValue(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Score', 'type' => 'date'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([]); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, ); $result = $service->validateForTenants([1], [ 'custom_field_values' => ['5' => 'not-a-date'], ], true); $this->assertFalse($result['ok']); $this->assertContains('Invalid value for Score', $result['errors']); } public function testValidateForTenantsReturnsOkWhenClean(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Nickname', 'type' => 'text'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([]); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, ); $result = $service->validateForTenants([1], [ 'custom_field_values' => ['5' => 'Fred'], ], true); $this->assertSame(['ok' => true, 'errors' => []], $result); } // --- buildDefinitionsByTenant ------------------------------------------ public function testBuildDefinitionsByTenantReturnsEmptyForEmptyTenantIds(): void { $service = $this->makeService(); $this->assertSame([], $service->buildDefinitionsByTenant([0, -1])); } public function testBuildDefinitionsByTenantReturnsEmptyWhenNoDefinitions(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->expects($this->once()) ->method('listByTenantIds') ->with([1, 2], true) ->willReturn([]); $service = $this->makeService(definitionRepository: $definitionRepo); $this->assertSame([], $service->buildDefinitionsByTenant([1, 2])); } public function testBuildDefinitionsByTenantGroupsByTenantAndMergesOptions(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 10, 'tenant_id' => 1, 'type' => 'select'], ['id' => 11, 'tenant_id' => 2, 'type' => 'text'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([ ['definition_id' => 10, 'id' => 100, 'option_key' => 'a', 'label' => 'A'], ]); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, ); $result = $service->buildDefinitionsByTenant([1, 2]); $this->assertArrayHasKey(1, $result); $this->assertArrayHasKey(2, $result); $this->assertCount(1, $result[1][0]['options']); $this->assertSame([], $result[2][0]['options']); } // --- buildUserValueMap ------------------------------------------------- public function testBuildUserValueMapReturnsEmptyForInvalidUserId(): void { $service = $this->makeService(); $this->assertSame([], $service->buildUserValueMap(0, [1])); } public function testBuildUserValueMapReturnsEmptyForEmptyDefinitionIds(): void { $service = $this->makeService(); $this->assertSame([], $service->buildUserValueMap(7, [0, -1])); } public function testBuildUserValueMapReturnsEmptyWhenRepoHasNoRows(): void { $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('listByUserAndDefinitionIds')->willReturn([]); $service = $this->makeService(valueRepository: $valueRepo); $this->assertSame([], $service->buildUserValueMap(7, [1, 2])); } public function testBuildUserValueMapMergesOptionIdsForMultiselectValues(): void { $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('listByUserAndDefinitionIds')->willReturn([ ['id' => 100, 'definition_id' => 10, 'value_text' => 'Fred', 'value_bool' => null, 'value_date' => null, 'option_id' => null], ['id' => 101, 'definition_id' => 11, 'value_text' => null, 'value_bool' => 1, 'value_date' => null, 'option_id' => null], ]); $valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class); $valueOptionRepo->method('listOptionIdsByValueIds')->willReturn([ 100 => [200, 201], ]); $service = $this->makeService( valueRepository: $valueRepo, valueOptionRepository: $valueOptionRepo, ); $result = $service->buildUserValueMap(7, [10, 11]); $this->assertSame([200, 201], $result[10]['option_ids']); $this->assertSame('Fred', $result[10]['value_text']); $this->assertSame([], $result[11]['option_ids']); $this->assertSame(1, $result[11]['value_bool']); } // --- buildPublicValuesByTenant ----------------------------------------- public function testBuildPublicValuesByTenantReturnsEmptyForInvalidUserId(): void { $service = $this->makeService(); $this->assertSame([], $service->buildPublicValuesByTenant(0, [['id' => 1]])); } public function testBuildPublicValuesByTenantFiltersByTenantScopeId(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); // Only tenant 2 is resolved because tenantScopeId=2 filters out tenant 1. $definitionRepo->expects($this->once()) ->method('listByTenantIds') ->with([2], true) ->willReturn([]); $service = $this->makeService(definitionRepository: $definitionRepo); $result = $service->buildPublicValuesByTenant(7, [ ['id' => 1, 'uuid' => 't1', 'description' => 'T1', 'status' => 'active'], ['id' => 2, 'uuid' => 't2', 'description' => 'T2', 'status' => 'active'], ], 2); $this->assertSame([], $result); } public function testBuildPublicValuesByTenantAssemblesFieldsWithValues(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 10, 'tenant_id' => 1, 'uuid' => self::UUID_A, 'field_key' => 'nick', 'label' => 'Nickname', 'type' => 'text', 'is_filterable' => 0], ['id' => 11, 'tenant_id' => 1, 'uuid' => self::UUID_B, 'field_key' => 'prio', 'label' => 'Priority', 'type' => 'select', 'is_filterable' => 1], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([ ['definition_id' => 11, 'id' => 200, 'option_key' => 'low', 'label' => 'Low'], ['definition_id' => 11, 'id' => 201, 'option_key' => 'high', 'label' => 'High'], ]); $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('listByUserAndDefinitionIds')->willReturn([ ['id' => 500, 'definition_id' => 10, 'value_text' => 'Fred', 'value_bool' => null, 'value_date' => null, 'option_id' => null], ['id' => 501, 'definition_id' => 11, 'value_text' => null, 'value_bool' => null, 'value_date' => null, 'option_id' => 201], ]); $valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class); $valueOptionRepo->method('listOptionIdsByValueIds')->willReturn([]); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, valueRepository: $valueRepo, valueOptionRepository: $valueOptionRepo, ); $result = $service->buildPublicValuesByTenant(7, [ ['id' => 1, 'uuid' => 't1', 'description' => 'Tenant 1', 'status' => 'active'], ]); $this->assertCount(1, $result); $this->assertSame('Tenant 1', $result[0]['tenant']['description']); $this->assertCount(2, $result[0]['fields']); $this->assertSame('Fred', $result[0]['fields'][0]['value']); $this->assertSame('high', $result[0]['fields'][1]['value']); $this->assertCount(2, $result[0]['fields'][1]['options']); } // --- syncForUser ------------------------------------------------------- public function testSyncForUserRejectsInvalidUserId(): void { $service = $this->makeService(); $result = $service->syncForUser(0, [1], [], true); $this->assertFalse($result['ok']); $this->assertSame(['User not found'], $result['errors']); } public function testSyncForUserFailsWhenOutsideCleanupFails(): void { $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(false); $service = $this->makeService(valueRepository: $valueRepo); $result = $service->syncForUser(7, [1], [], true); $this->assertFalse($result['ok']); $this->assertContains('Custom field values can not be saved', $result['errors']); } public function testSyncForUserSkipsEditsWhenNotEditable(): void { $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true); $valueRepo->expects($this->never())->method('upsertScalarValue'); $service = $this->makeService(valueRepository: $valueRepo); $result = $service->syncForUser(7, [1], ['custom_field_values' => ['5' => 'Fred']], false); $this->assertTrue($result['ok']); } public function testSyncForUserWritesTextValue(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Nickname', 'type' => 'text'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([]); $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true); $valueRepo->expects($this->once()) ->method('upsertScalarValue') ->with(7, 5, $this->callback(static fn (array $data): bool => ($data['value_text'] ?? null) === 'Fred')) ->willReturn(500); $valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class); $valueOptionRepo->expects($this->once()) ->method('replaceForValueId') ->with(500, []) ->willReturn(true); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, valueRepository: $valueRepo, valueOptionRepository: $valueOptionRepo, ); $result = $service->syncForUser(7, [1], [ 'custom_field_values' => ['5' => 'Fred'], ], true); $this->assertTrue($result['ok']); } public function testSyncForUserDeletesEmptyScalarValues(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Nickname', 'type' => 'text'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([]); $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true); $valueRepo->expects($this->once()) ->method('deleteByUserAndDefinitionIds') ->with(7, [5]) ->willReturn(true); $valueRepo->expects($this->never())->method('upsertScalarValue'); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, valueRepository: $valueRepo, ); $result = $service->syncForUser(7, [1], [ 'custom_field_values' => ['5' => ' '], ], true); $this->assertTrue($result['ok']); } public function testSyncForUserRejectsInvalidDateValue(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Due', 'type' => 'date'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([]); $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, valueRepository: $valueRepo, ); $result = $service->syncForUser(7, [1], [ 'custom_field_values' => ['5' => 'not-a-date'], ], true); $this->assertFalse($result['ok']); $this->assertContains('Invalid value for Due', $result['errors']); } public function testSyncForUserWritesMultiselectOptionIds(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Tags', 'type' => 'multiselect'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([ ['definition_id' => 5, 'id' => 200, 'active' => 1, 'option_key' => 'x', 'label' => 'X'], ['definition_id' => 5, 'id' => 201, 'active' => 1, 'option_key' => 'y', 'label' => 'Y'], ]); $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true); $valueRepo->expects($this->once()) ->method('upsertScalarValue') ->with(7, 5, $this->callback(static fn (array $d): bool => ($d['option_id'] ?? null) === null && ($d['value_text'] ?? null) === null)) ->willReturn(500); $valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class); $valueOptionRepo->expects($this->once()) ->method('replaceForValueId') ->with(500, [200, 201]) ->willReturn(true); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, valueRepository: $valueRepo, valueOptionRepository: $valueOptionRepo, ); $result = $service->syncForUser(7, [1], [ 'custom_field_values_multi' => ['5' => [200, 201]], ], true); $this->assertTrue($result['ok']); } public function testSyncForUserRejectsMultiselectValueNotInActiveOptions(): void { $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listByTenantIds')->willReturn([ ['id' => 5, 'tenant_id' => 1, 'label' => 'Tags', 'type' => 'multiselect'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([ ['definition_id' => 5, 'id' => 200, 'active' => 1], ]); $valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class); $valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true); $service = $this->makeService( definitionRepository: $definitionRepo, optionRepository: $optionRepo, valueRepository: $valueRepo, ); $result = $service->syncForUser(7, [1], [ 'custom_field_values_multi' => ['5' => [200, 999]], ], true); $this->assertFalse($result['ok']); $this->assertContains('Invalid value for Tags', $result['errors']); } // --- extractCustomFieldFilterSpec ------------------------------------- public function testExtractCustomFieldFilterSpecReturnsEmptyWhenNoFilterableDefinitions(): void { $scope = $this->createMock(TenantScopeService::class); $scope->method('getUserTenantIds')->willReturn([1]); $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listFilterableByTenantIds')->willReturn([]); $service = $this->makeService( userScopeGateway: $scope, definitionRepository: $definitionRepo, ); $this->assertSame( ['definitions' => [], 'filters' => [], 'query' => []], $service->extractCustomFieldFilterSpec(['cf_' . self::UUID_A => '123'], 7) ); } public function testExtractCustomFieldFilterSpecParsesSelectAndBooleanFilters(): void { $scope = $this->createMock(TenantScopeService::class); $scope->method('getUserTenantIds')->willReturn([1]); $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listFilterableByTenantIds')->willReturn([ ['id' => 10, 'uuid' => self::UUID_A, 'type' => 'select'], ['id' => 11, 'uuid' => self::UUID_B, 'type' => 'boolean'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([ ['definition_id' => 10, 'id' => 200], ]); $service = $this->makeService( userScopeGateway: $scope, definitionRepository: $definitionRepo, optionRepository: $optionRepo, ); $result = $service->extractCustomFieldFilterSpec([ 'cf_' . self::UUID_A => '200', 'cf_' . self::UUID_B => 'yes', 'cf_' . self::UUID_C => 'ignored', // UUID_C not among definitions ], 7); $this->assertSame(['10' => 200], $this->keyedAsStringInt($result['filters']['select'])); $this->assertSame(['11' => 1], $this->keyedAsStringInt($result['filters']['boolean'])); $this->assertArrayNotHasKey('cf_' . self::UUID_C, $result['query']); } public function testExtractCustomFieldFilterSpecParsesMultiselectAndDateRange(): void { $scope = $this->createMock(TenantScopeService::class); $scope->method('getUserTenantIds')->willReturn([1]); $definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class); $definitionRepo->method('listFilterableByTenantIds')->willReturn([ ['id' => 10, 'uuid' => self::UUID_A, 'type' => 'multiselect'], ['id' => 11, 'uuid' => self::UUID_B, 'type' => 'date'], ]); $optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class); $optionRepo->method('listByDefinitionIds')->willReturn([ ['definition_id' => 10, 'id' => 200], ['definition_id' => 10, 'id' => 201], ]); $service = $this->makeService( userScopeGateway: $scope, definitionRepository: $definitionRepo, optionRepository: $optionRepo, ); $result = $service->extractCustomFieldFilterSpec([ 'cfm_' . self::UUID_A => '200,201,999', // 999 not allowed — should be filtered 'cfd_' . self::UUID_B . '_from' => '2026-01-01', 'cfd_' . self::UUID_B . '_to' => 'not-a-date', // invalid — ignored ], 7); $this->assertSame([200, 201], $result['filters']['multiselect'][10]); $this->assertSame('2026-01-01', $result['filters']['date'][11]['from']); $this->assertNull($result['filters']['date'][11]['to']); } // --- helpers ----------------------------------------------------------- /** @param array $map @return array */ private function keyedAsStringInt(array $map): array { $out = []; foreach ($map as $k => $v) { $out[(string) $k] = (int) $v; } return $out; } private function makeService( ?TenantScopeService $userScopeGateway = null, ?TenantCustomFieldDefinitionRepositoryInterface $definitionRepository = null, ?TenantCustomFieldOptionRepositoryInterface $optionRepository = null, ?UserCustomFieldValueRepositoryInterface $valueRepository = null, ?UserCustomFieldValueOptionRepositoryInterface $valueOptionRepository = null, ): UserCustomFieldValueService { return new UserCustomFieldValueService( $userScopeGateway ?? $this->createMock(TenantScopeService::class), $definitionRepository ?? $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class), $optionRepository ?? $this->createMock(TenantCustomFieldOptionRepositoryInterface::class), $valueRepository ?? $this->createMock(UserCustomFieldValueRepositoryInterface::class), $valueOptionRepository ?? $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class), ); } }