From e5aae6ca24f22f9e2ef967d087b92bbcff5f856f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:11 -0300 Subject: [PATCH 1/9] feat(search): register unified profile search provider Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/AppInfo/Application.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index f511b15..4fc1bec 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -16,6 +16,7 @@ use OCA\ProfileFields\Listener\RegisterWorkflowOperationListener; use OCA\ProfileFields\Listener\UserDeletedCleanupListener; use OCA\ProfileFields\Notification\Notifier; +use OCA\ProfileFields\Search\ProfileFieldSearchProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -42,6 +43,7 @@ public function __construct() { #[\Override] public function register(IRegistrationContext $context): void { $context->registerNotifierService(Notifier::class); + $context->registerSearchProvider(ProfileFieldSearchProvider::class); $context->registerEventListener('\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent', BeforeTemplateRenderedListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedCleanupListener::class); $context->registerEventListener(RegisterEntitiesEvent::class, RegisterWorkflowEntityListener::class); From db7cab965c62e37075f84693e76448e4ae910196 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:18 -0300 Subject: [PATCH 2/9] test(app): cover search provider registration Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/AppInfo/ApplicationTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/php/Unit/AppInfo/ApplicationTest.php b/tests/php/Unit/AppInfo/ApplicationTest.php index 1b0b450..fb938b3 100644 --- a/tests/php/Unit/AppInfo/ApplicationTest.php +++ b/tests/php/Unit/AppInfo/ApplicationTest.php @@ -14,6 +14,7 @@ use OCA\ProfileFields\Listener\RegisterWorkflowCheckListener; use OCA\ProfileFields\Listener\RegisterWorkflowEntityListener; use OCA\ProfileFields\Listener\RegisterWorkflowOperationListener; +use OCA\ProfileFields\Search\ProfileFieldSearchProvider; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\IRequest; @@ -27,8 +28,14 @@ class ApplicationTest extends TestCase { public function testRegisterAddsWorkflowListeners(): void { $registrations = []; + $searchProviders = []; $registrationContext = $this->createMock(IRegistrationContext::class); + $registrationContext->expects($this->once()) + ->method('registerSearchProvider') + ->willReturnCallback(static function (string $provider) use (&$searchProviders): void { + $searchProviders[] = $provider; + }); $registrationContext->expects($this->exactly(6)) ->method('registerEventListener') ->willReturnCallback(static function (string $event, string $listener, int $priority = 0) use (&$registrations): void { @@ -38,6 +45,7 @@ public function testRegisterAddsWorkflowListeners(): void { $application = new Application(); $application->register($registrationContext); + self::assertContains(ProfileFieldSearchProvider::class, $searchProviders); self::assertContains(['\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent', 'OCA\\ProfileFields\\Listener\\BeforeTemplateRenderedListener', 0], $registrations); self::assertContains([UserDeletedEvent::class, 'OCA\\ProfileFields\\Listener\\UserDeletedCleanupListener', 0], $registrations); self::assertContains([RegisterEntitiesEvent::class, RegisterWorkflowEntityListener::class, 0], $registrations); From 1a133a452d1255324518ce8f4bdc1f0029872d49 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:24 -0300 Subject: [PATCH 3/9] feat(search): add profile directory search service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../ProfileFieldDirectorySearchService.php | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 lib/Search/ProfileFieldDirectorySearchService.php diff --git a/lib/Search/ProfileFieldDirectorySearchService.php b/lib/Search/ProfileFieldDirectorySearchService.php new file mode 100644 index 0000000..cc2fac1 --- /dev/null +++ b/lib/Search/ProfileFieldDirectorySearchService.php @@ -0,0 +1,150 @@ + + * }>} + */ + public function search(?IUser $actor, string $term, int $limit, int $offset): array { + if ($limit < 1) { + throw new InvalidArgumentException('limit must be greater than 0'); + } + + if ($offset < 0) { + throw new InvalidArgumentException('offset must be greater than or equal to 0'); + } + + $normalizedTerm = trim(mb_strtolower($term)); + if ($normalizedTerm === '') { + return ['total' => 0, 'items' => []]; + } + + $actorUid = $actor?->getUID(); + $actorIsAdmin = $actorUid !== null && $this->groupManager->isAdmin($actorUid); + $definitionsById = []; + foreach ($this->fieldDefinitionService->findActiveOrdered() as $definition) { + $definitionsById[$definition->getId()] = $definition; + } + + if ($definitionsById === []) { + return ['total' => 0, 'items' => []]; + } + + $matchesByUserUid = []; + foreach ($this->fieldValueMapper->findAllOrdered() as $value) { + $definition = $definitionsById[$value->getFieldDefinitionId()] ?? null; + if ($definition === null) { + continue; + } + + if (!$this->isSearchableForActor($definition->getUserVisible(), $value->getCurrentVisibility(), $actorIsAdmin, $actorUid !== null)) { + continue; + } + + $scalarValue = $this->extractScalarValue($value); + if ($scalarValue === null || !str_contains(mb_strtolower($scalarValue), $normalizedTerm)) { + continue; + } + + $userUid = $value->getUserUid(); + if (!isset($matchesByUserUid[$userUid])) { + $user = $this->userManager->get($userUid); + $matchesByUserUid[$userUid] = [ + 'user_uid' => $userUid, + 'display_name' => $this->resolveDisplayName($user, $userUid), + 'matched_fields' => [], + ]; + } + + if (count($matchesByUserUid[$userUid]['matched_fields']) >= self::MAX_MATCHES_PER_USER) { + continue; + } + + $matchesByUserUid[$userUid]['matched_fields'][] = [ + 'field_key' => $definition->getFieldKey(), + 'field_label' => $definition->getLabel(), + 'value' => $scalarValue, + ]; + } + + $matches = array_values($matchesByUserUid); + usort($matches, static function (array $left, array $right): int { + return [$left['display_name'], $left['user_uid']] <=> [$right['display_name'], $right['user_uid']]; + }); + + return [ + 'total' => count($matches), + 'items' => array_slice($matches, $offset, $limit), + ]; + } + + private function extractScalarValue(FieldValue $value): ?string { + $decoded = json_decode($value->getValueJson(), true); + $scalar = $decoded['value'] ?? null; + if (is_array($scalar) || is_object($scalar) || $scalar === null) { + return null; + } + + return trim((string)$scalar); + } + + private function isSearchableForActor(bool $fieldIsUserVisible, string $currentVisibility, bool $actorIsAdmin, bool $actorIsAuthenticated): bool { + if ($actorIsAdmin) { + return true; + } + + if (!$fieldIsUserVisible) { + return false; + } + + return match (FieldVisibility::from($currentVisibility)) { + FieldVisibility::PUBLIC => true, + FieldVisibility::USERS => $actorIsAuthenticated, + FieldVisibility::PRIVATE => false, + }; + } + + private function resolveDisplayName(?IUser $user, string $fallbackUserUid): string { + if ($user === null) { + return $fallbackUserUid; + } + + $displayName = trim($user->getDisplayName()); + return $displayName !== '' ? $displayName : $fallbackUserUid; + } +} From 79a6f7a2125f2d74256f1ace4016adecd2b97124 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:29 -0300 Subject: [PATCH 4/9] feat(search): add profile field search provider Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Search/ProfileFieldSearchProvider.php | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 lib/Search/ProfileFieldSearchProvider.php diff --git a/lib/Search/ProfileFieldSearchProvider.php b/lib/Search/ProfileFieldSearchProvider.php new file mode 100644 index 0000000..0d5be32 --- /dev/null +++ b/lib/Search/ProfileFieldSearchProvider.php @@ -0,0 +1,121 @@ +l10n->t('Profile directory'); + } + + #[\Override] + public function getOrder(string $route, array $routeParameters): ?int { + return str_starts_with($route, 'settings.Users.usersList') ? 35 : 65; + } + + #[\Override] + public function search(IUser $user, ISearchQuery $query): SearchResult { + $term = trim($query->getTerm()); + if (mb_strlen($term) < self::MIN_SEARCH_LENGTH) { + return SearchResult::complete($this->getName(), []); + } + + $cursor = $this->normalizeCursor($query->getCursor()); + $result = $this->searchService->search($user, $term, $query->getLimit(), $cursor); + $entries = array_map(fn (array $item): SearchResultEntry => $this->buildEntry($item), $result['items']); + if ($cursor + count($entries) >= $result['total']) { + return SearchResult::complete($this->getName(), $entries); + } + + return SearchResult::paginated( + $this->getName(), + $entries, + $cursor + count($entries), + ); + } + + private function normalizeCursor(int|string|null $cursor): int { + if ($cursor === null || $cursor === '') { + return 0; + } + + if (is_int($cursor)) { + return $cursor; + } + + if (preg_match('/^-?\d+$/', $cursor) === 1) { + return (int)$cursor; + } + + return 0; + } + + /** + * @param array{ + * user_uid: string, + * display_name: string, + * matched_fields: list + * } $item + */ + private function buildEntry(array $item): SearchResultEntry { + $thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', [ + 'userId' => $item['user_uid'], + 'size' => 64, + ]); + $resourceUrl = $this->urlGenerator->linkToRouteAbsolute('settings.Users.usersList') . '?search=' . rawurlencode($item['user_uid']); + + return new SearchResultEntry( + $thumbnailUrl, + $item['display_name'], + $this->buildSubline($item['matched_fields']), + $resourceUrl, + 'icon-user', + true, + ); + } + + /** + * @param list $matchedFields + */ + private function buildSubline(array $matchedFields): string { + $parts = array_map( + static fn (array $match): string => sprintf('%s: %s', $match['field_label'], $match['value']), + $matchedFields, + ); + + return implode(' • ', $parts); + } +} From a98a00e7141c3232fad12c8d14a3986777aedf26 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:35 -0300 Subject: [PATCH 5/9] test(search): cover profile directory search service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- ...ProfileFieldDirectorySearchServiceTest.php | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/php/Unit/Search/ProfileFieldDirectorySearchServiceTest.php diff --git a/tests/php/Unit/Search/ProfileFieldDirectorySearchServiceTest.php b/tests/php/Unit/Search/ProfileFieldDirectorySearchServiceTest.php new file mode 100644 index 0000000..667f43a --- /dev/null +++ b/tests/php/Unit/Search/ProfileFieldDirectorySearchServiceTest.php @@ -0,0 +1,204 @@ +fieldDefinitionService = $this->createMock(FieldDefinitionService::class); + $this->fieldValueMapper = $this->createMock(FieldValueMapper::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + + $this->service = new ProfileFieldDirectorySearchService( + $this->fieldDefinitionService, + $this->fieldValueMapper, + $this->userManager, + $this->groupManager, + ); + } + + public function testSearchReturnsVisibleMatchesGroupedByUser(): void { + $actor = $this->createMock(IUser::class); + $actor->method('getUID')->willReturn('analyst'); + + $region = $this->buildDefinition(10, 'region', 'Region'); + $team = $this->buildDefinition(20, 'team', 'Team'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findActiveOrdered') + ->willReturn([$region, $team]); + $this->fieldValueMapper->expects($this->once()) + ->method('findAllOrdered') + ->willReturn([ + $this->buildValue(10, 'alice', 'public', 'LATAM'), + $this->buildValue(20, 'alice', 'users', 'Platform'), + $this->buildValue(10, 'bob', 'private', 'LATAM'), + ]); + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('analyst') + ->willReturn(false); + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($this->buildUser('Alice Doe')); + + $result = $this->service->search($actor, 'latam', 10, 0); + + $this->assertSame(1, $result['total']); + $this->assertSame('alice', $result['items'][0]['user_uid']); + $this->assertSame('Alice Doe', $result['items'][0]['display_name']); + $this->assertSame([ + [ + 'field_key' => 'region', + 'field_label' => 'Region', + 'value' => 'LATAM', + ], + ], $result['items'][0]['matched_fields']); + } + + public function testSearchDoesNotExposePrivateOrHiddenFieldsToNonAdmins(): void { + $actor = $this->createMock(IUser::class); + $actor->method('getUID')->willReturn('alice'); + + $publicDefinition = $this->buildDefinition(10, 'region', 'Region'); + $hiddenDefinition = $this->buildDefinition(20, 'secret_region', 'Secret Region', userVisible: false); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findActiveOrdered') + ->willReturn([$publicDefinition, $hiddenDefinition]); + $this->fieldValueMapper->expects($this->once()) + ->method('findAllOrdered') + ->willReturn([ + $this->buildValue(10, 'alice', 'private', 'LATAM - Private'), + $this->buildValue(10, 'bob', 'users', 'LATAM - Users'), + $this->buildValue(20, 'carol', 'public', 'LATAM - Hidden'), + ]); + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('alice') + ->willReturn(false); + $this->userManager->expects($this->once()) + ->method('get') + ->with('bob') + ->willReturn($this->buildUser('Bob Doe')); + + $result = $this->service->search($actor, 'latam', 10, 0); + + $this->assertSame(1, $result['total']); + $this->assertSame('bob', $result['items'][0]['user_uid']); + $this->assertSame('LATAM - Users', $result['items'][0]['matched_fields'][0]['value']); + } + + public function testSearchAllowsAdminToFindPrivateAndHiddenFields(): void { + $actor = $this->createMock(IUser::class); + $actor->method('getUID')->willReturn('admin'); + + $hiddenDefinition = $this->buildDefinition(10, 'secret_region', 'Secret Region', userVisible: false); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findActiveOrdered') + ->willReturn([$hiddenDefinition]); + $this->fieldValueMapper->expects($this->once()) + ->method('findAllOrdered') + ->willReturn([ + $this->buildValue(10, 'alice', 'private', 'LATAM - Private'), + ]); + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('admin') + ->willReturn(true); + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($this->buildUser('Alice Doe')); + + $result = $this->service->search($actor, 'latam', 10, 0); + + $this->assertSame(1, $result['total']); + $this->assertSame('alice', $result['items'][0]['user_uid']); + $this->assertSame('LATAM - Private', $result['items'][0]['matched_fields'][0]['value']); + } + + public function testSearchSupportsPagination(): void { + $region = $this->buildDefinition(10, 'region', 'Region'); + + $this->fieldDefinitionService->method('findActiveOrdered')->willReturn([$region]); + $this->fieldValueMapper->method('findAllOrdered')->willReturn([ + $this->buildValue(10, 'alice', 'public', 'LATAM'), + $this->buildValue(10, 'bruno', 'public', 'LATAM'), + ]); + $this->userManager->method('get')->willReturnCallback(fn (string $uid): IUser => $this->buildUser(strtoupper($uid))); + + $result = $this->service->search(null, 'latam', 1, 1); + + $this->assertSame(2, $result['total']); + $this->assertCount(1, $result['items']); + $this->assertSame('bruno', $result['items'][0]['user_uid']); + } + + private function buildDefinition(int $id, string $fieldKey, string $label, bool $userVisible = true): FieldDefinition { + $definition = new FieldDefinition(); + $definition->setId($id); + $definition->setFieldKey($fieldKey); + $definition->setLabel($label); + $definition->setType('text'); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible($userVisible); + $definition->setInitialVisibility('private'); + $definition->setSortOrder(0); + $definition->setActive(true); + $definition->setCreatedAt(new DateTime()); + $definition->setUpdatedAt(new DateTime()); + + return $definition; + } + + private function buildValue(int $fieldDefinitionId, string $userUid, string $visibility, string $value): FieldValue { + $fieldValue = new FieldValue(); + $fieldValue->setId(random_int(1, 9999)); + $fieldValue->setFieldDefinitionId($fieldDefinitionId); + $fieldValue->setUserUid($userUid); + $fieldValue->setValueJson(json_encode(['value' => $value], JSON_THROW_ON_ERROR)); + $fieldValue->setCurrentVisibility($visibility); + $fieldValue->setUpdatedByUid('admin'); + $fieldValue->setUpdatedAt(new DateTime()); + + return $fieldValue; + } + + private function buildUser(string $displayName): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getDisplayName')->willReturn($displayName); + + return $user; + } +} From e464cda2101740dda1c498035a8df2fdc99204eb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:41 -0300 Subject: [PATCH 6/9] test(search): cover profile field search provider Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Search/ProfileFieldSearchProviderTest.php | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/php/Unit/Search/ProfileFieldSearchProviderTest.php diff --git a/tests/php/Unit/Search/ProfileFieldSearchProviderTest.php b/tests/php/Unit/Search/ProfileFieldSearchProviderTest.php new file mode 100644 index 0000000..42ce187 --- /dev/null +++ b/tests/php/Unit/Search/ProfileFieldSearchProviderTest.php @@ -0,0 +1,168 @@ +buildL10n(), + $this->createMock(IURLGenerator::class), + $this->createMock(ProfileFieldDirectorySearchService::class), + ); + $query = $this->createMock(ISearchQuery::class); + $query->method('getTerm')->willReturn('a'); + $query->method('getLimit')->willReturn(10); + $query->method('getCursor')->willReturn(0); + + $result = $provider->search($this->createMock(IUser::class), $query); + + $this->assertSame([], $result->jsonSerialize()['entries']); + } + + public function testSearchNormalizesInitialNullCursorToZero(): void { + $l10n = $this->buildL10n(); + $urlGenerator = $this->createMock(IURLGenerator::class); + $searchService = $this->createMock(ProfileFieldDirectorySearchService::class); + $user = $this->createMock(IUser::class); + $query = $this->createMock(ISearchQuery::class); + $query->method('getTerm')->willReturn('latam'); + $query->method('getLimit')->willReturn(1); + $query->method('getCursor')->willReturn(null); + + $searchService->expects($this->once()) + ->method('search') + ->with($user, 'latam', 1, 0) + ->willReturn([ + 'total' => 2, + 'items' => [[ + 'user_uid' => 'alice', + 'display_name' => 'Alice Doe', + 'matched_fields' => [[ + 'field_key' => 'region', + 'field_label' => 'Region', + 'value' => 'LATAM', + ]], + ]], + ]); + + $urlGenerator->expects($this->exactly(2)) + ->method('linkToRouteAbsolute') + ->willReturnMap([ + ['core.avatar.getAvatar', ['userId' => 'alice', 'size' => 64], 'https://cloud.test/avatar/alice'], + ['settings.Users.usersList', [], 'https://cloud.test/settings/users'], + ]); + + $provider = new ProfileFieldSearchProvider($l10n, $urlGenerator, $searchService); + $result = $provider->search($user, $query)->jsonSerialize(); + + $this->assertTrue($result['isPaginated']); + $this->assertSame(1, $result['cursor']); + } + + public function testSearchNormalizesNumericStringCursor(): void { + $l10n = $this->buildL10n(); + $urlGenerator = $this->createMock(IURLGenerator::class); + $searchService = $this->createMock(ProfileFieldDirectorySearchService::class); + $user = $this->createMock(IUser::class); + $query = $this->createMock(ISearchQuery::class); + $query->method('getTerm')->willReturn('latam'); + $query->method('getLimit')->willReturn(1); + $query->method('getCursor')->willReturn('1'); + + $searchService->expects($this->once()) + ->method('search') + ->with($user, 'latam', 1, 1) + ->willReturn([ + 'total' => 2, + 'items' => [[ + 'user_uid' => 'bruno', + 'display_name' => 'Bruno Doe', + 'matched_fields' => [[ + 'field_key' => 'region', + 'field_label' => 'Region', + 'value' => 'LATAM', + ]], + ]], + ]); + + $urlGenerator->expects($this->exactly(2)) + ->method('linkToRouteAbsolute') + ->willReturnMap([ + ['core.avatar.getAvatar', ['userId' => 'bruno', 'size' => 64], 'https://cloud.test/avatar/bruno'], + ['settings.Users.usersList', [], 'https://cloud.test/settings/users'], + ]); + + $provider = new ProfileFieldSearchProvider($l10n, $urlGenerator, $searchService); + $result = $provider->search($user, $query)->jsonSerialize(); + + $this->assertFalse($result['isPaginated']); + } + + public function testSearchBuildsAvatarBackedEntries(): void { + $l10n = $this->buildL10n(); + $urlGenerator = $this->createMock(IURLGenerator::class); + $searchService = $this->createMock(ProfileFieldDirectorySearchService::class); + $user = $this->createMock(IUser::class); + $query = $this->createMock(ISearchQuery::class); + $query->method('getTerm')->willReturn('latam'); + $query->method('getLimit')->willReturn(10); + $query->method('getCursor')->willReturn(0); + + $searchService->expects($this->once()) + ->method('search') + ->with($user, 'latam', 10, 0) + ->willReturn([ + 'total' => 1, + 'items' => [[ + 'user_uid' => 'alice', + 'display_name' => 'Alice Doe', + 'matched_fields' => [[ + 'field_key' => 'region', + 'field_label' => 'Region', + 'value' => 'LATAM', + ]], + ]], + ]); + + $urlGenerator->expects($this->exactly(2)) + ->method('linkToRouteAbsolute') + ->willReturnMap([ + ['core.avatar.getAvatar', ['userId' => 'alice', 'size' => 64], 'https://cloud.test/avatar/alice'], + ['settings.Users.usersList', [], 'https://cloud.test/settings/users'], + ]); + + $provider = new ProfileFieldSearchProvider($l10n, $urlGenerator, $searchService); + $result = $provider->search($user, $query)->jsonSerialize(); + $entry = $result['entries'][0]->jsonSerialize(); + + $this->assertSame('Profile directory', $result['name']); + $this->assertCount(1, $result['entries']); + $this->assertFalse($result['isPaginated']); + $this->assertSame('Alice Doe', $entry['title']); + $this->assertSame('Region: LATAM', $entry['subline']); + $this->assertSame('https://cloud.test/settings/users?search=alice', $entry['resourceUrl']); + } + + private function buildL10n(): IL10N&MockObject { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => $text); + + return $l10n; + } +} From ceb562ffa19da911ef97c56515014c5c38f0aad9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:54 -0300 Subject: [PATCH 7/9] test(behat): add unified search integration scenarios Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../api/profile_fields_unified_search.feature | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/integration/features/api/profile_fields_unified_search.feature diff --git a/tests/integration/features/api/profile_fields_unified_search.feature b/tests/integration/features/api/profile_fields_unified_search.feature new file mode 100644 index 0000000..a6c7e62 --- /dev/null +++ b/tests/integration/features/api/profile_fields_unified_search.feature @@ -0,0 +1,147 @@ +Feature: profile fields unified search + Background: + Given user "profileuser" exists + And user "profileuser2" exists + And user "profileuser3" exists + And user "profileviewer" exists + And user "directoryviewer" exists + And run the command "profile_fields:developer:reset --all" with result code 0 + + Scenario: admins can use unified search without an explicit cursor and continue with a numeric cursor + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | unified_region_admin | + | label | Unified Region Admin | + | type | text | + | adminOnly | false | + | userEditable | false | + | userVisible | true | + | initialVisibility | private | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 201 + And fetch field "(UNIFIED_REGION_ADMIN_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser/values/" + | value | LATAM - South | + | currentVisibility | private | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser2/values/" + | value | LATAM - North | + | currentVisibility | private | + Then the response should have a status code 200 + When sending "get" to ocs "/search/providers/profile_fields.directory/search?term=latam&limit=1&from=/settings/users" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.name | Profile directory | + | (jq).ocs.data.isPaginated | true | + | (jq).ocs.data.cursor | 1 | + | (jq).ocs.data.entries | (jq)length == 1 | + | (jq).ocs.data.entries[0].title | profileuser-displayname | + | (jq).ocs.data.entries[0].subline | Unified Region Admin: LATAM - South | + | (jq).ocs.data.entries[0].icon | icon-user | + | (jq).ocs.data.entries[0].rounded | true | + When sending "get" to ocs "/search/providers/profile_fields.directory/search?term=latam&limit=1&cursor=1&from=/settings/users" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.name | Profile directory | + | (jq).ocs.data.isPaginated | false | + | (jq).ocs.data.cursor | null | + | (jq).ocs.data.entries | (jq)length == 1 | + | (jq).ocs.data.entries[0].title | profileuser2-displayname | + | (jq).ocs.data.entries[0].subline | Unified Region Admin: LATAM - North | + + Scenario: authenticated users can use unified search and it only returns exposed values + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | unified_region_visibility | + | label | Unified Region Visibility | + | type | text | + | adminOnly | false | + | userEditable | false | + | userVisible | true | + | initialVisibility | private | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 201 + And fetch field "(UNIFIED_REGION_VISIBILITY_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser/values/" + | value | LATAM - Public | + | currentVisibility | public | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser2/values/" + | value | LATAM - Users | + | currentVisibility | users | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser3/values/" + | value | LATAM - Private | + | currentVisibility | private | + Then the response should have a status code 200 + Given as user "profileviewer" + When sending "get" to ocs "/search/providers/profile_fields.directory/search?term=latam&from=/settings/users" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.name | Profile directory | + | (jq).ocs.data.isPaginated | false | + | (jq).ocs.data.entries | (jq)length == 2 | + | (jq).ocs.data.entries[0].title | profileuser-displayname | + | (jq).ocs.data.entries[0].subline | Unified Region Visibility: LATAM - Public | + | (jq).ocs.data.entries[1].title | profileuser2-displayname | + | (jq).ocs.data.entries[1].subline | Unified Region Visibility: LATAM - Users | + + Scenario: hidden fields and private values are only searchable by admins + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | unified_region_hidden | + | label | Unified Region Hidden | + | type | text | + | adminOnly | false | + | userEditable | false | + | userVisible | false | + | initialVisibility | public | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 201 + And fetch field "(UNIFIED_REGION_HIDDEN_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser/values/" + | value | LATAM - Hidden | + | currentVisibility | public | + Then the response should have a status code 200 + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | unified_region_private | + | label | Unified Region Private | + | type | text | + | adminOnly | false | + | userEditable | false | + | userVisible | true | + | initialVisibility | private | + | sortOrder | 20 | + | active | true | + Then the response should have a status code 201 + And fetch field "(UNIFIED_REGION_PRIVATE_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileviewer/values/" + | value | LATAM - Private | + | currentVisibility | private | + Then the response should have a status code 200 + Given as user "profileviewer" + When sending "get" to ocs "/search/providers/profile_fields.directory/search?term=latam&from=/settings/users" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.name | Profile directory | + | (jq).ocs.data.isPaginated | false | + | (jq).ocs.data.entries | (jq)length == 0 | + Given as user "admin" + When sending "get" to ocs "/search/providers/profile_fields.directory/search?term=latam&from=/settings/users" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.name | Profile directory | + | (jq).ocs.data.isPaginated | false | + | (jq).ocs.data.entries | (jq)length == 2 | + | (jq).ocs.data.entries[0].title | profileuser-displayname | + | (jq).ocs.data.entries[0].subline | Unified Region Hidden: LATAM - Hidden | + | (jq).ocs.data.entries[1].title | profileviewer-displayname | + | (jq).ocs.data.entries[1].subline | Unified Region Private: LATAM - Private | From 1bacd5494f0cb28ff21f43cdb3ebdc342d8c6f30 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:46:39 -0300 Subject: [PATCH 8/9] docs(readme): add feature overview Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 84fd8fe..b48b171 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ Profile fields lets teams add organization-specific profile data that does not f This makes the app useful for internal directories, support operations, partner programs and other corporate deployments that need richer account metadata without leaving Nextcloud. +## Features + +- Central field catalog for organization-specific profile data. +- Per-field governance for admin-managed and self-service updates. +- Per-value visibility controls for public, authenticated-user, and private exposure. +- User administration tools for reviewing and updating profile field values. +- Workflow integration based on custom profile metadata. +- Global search results built only from fields and values exposed to the current user. + ## API documentation The public API contract for this app is published as [openapi-full.json](https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json). From e70b0d1091dc2e09e7bfbdaef67b9b98503c035e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:46:49 -0300 Subject: [PATCH 9/9] docs(admin): explain global search exposure Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/AdminSettings.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 28ab723..6a965d7 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -118,6 +118,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

Behavior

Choose how the field is stored, displayed and exposed by default.

+

Fields visible to users can appear in global search when stored values are public or visible to authenticated users. Hidden fields and private values stay searchable only for administrators.