diff --git a/tests/Service/CustomField/UserCustomFieldValueServiceTest.php b/tests/Service/CustomField/UserCustomFieldValueServiceTest.php new file mode 100644 index 0000000..7e9bf20 --- /dev/null +++ b/tests/Service/CustomField/UserCustomFieldValueServiceTest.php @@ -0,0 +1,561 @@ +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), + ); + } +}