]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9186 make "query" optional for api/components/suggestions
authorDaniel Schwarz <daniel.schwarz@sonarsource.com>
Fri, 5 May 2017 14:42:11 +0000 (16:42 +0200)
committerDaniel Schwarz <bartfastiel@users.noreply.github.com>
Thu, 11 May 2017 07:31:11 +0000 (09:31 +0200)
server/sonar-server/src/main/java/org/sonar/server/component/index/ComponentHit.java
server/sonar-server/src/main/java/org/sonar/server/component/ws/SuggestionsAction.java
server/sonar-server/src/test/java/org/sonar/server/component/ws/SuggestionsActionTest.java

index 891edc797919a7aacecbcf98752701869b396fa9..4da8d6ef50eda2560bba9885adfb01a6d8879c78 100644 (file)
@@ -34,7 +34,12 @@ public class ComponentHit {
   private final String uuid;
   private final Optional<String> highlightedText;
 
-  private ComponentHit(SearchHit hit) {
+  public ComponentHit(String uuid) {
+    this.uuid = uuid;
+    highlightedText = Optional.empty();
+  }
+
+  public ComponentHit(SearchHit hit) {
     this.uuid = hit.getId();
     this.highlightedText = getHighlightedText(hit);
   }
index 8250205dc163b5b4e491bdbb7ffc7b0aea0982e4..5f18b74058dffaff9184ea50a0e881e05f023a26 100644 (file)
  */
 package org.sonar.server.component.ws;
 
+import com.google.common.collect.ListMultimap;
 import com.google.common.html.HtmlEscapers;
 import com.google.common.io.Resources;
 import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.annotation.Nullable;
+import org.sonar.api.server.ws.Change;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
@@ -43,6 +49,7 @@ import org.sonar.server.component.index.ComponentIndexQuery;
 import org.sonar.server.component.index.ComponentIndexResults;
 import org.sonar.server.es.textsearch.ComponentTextSearchFeature;
 import org.sonar.server.favorite.FavoriteFinder;
+import org.sonar.server.user.UserSession;
 import org.sonarqube.ws.WsComponents.SuggestionsWsResponse;
 import org.sonarqube.ws.WsComponents.SuggestionsWsResponse.Category;
 import org.sonarqube.ws.WsComponents.SuggestionsWsResponse.Project;
@@ -53,6 +60,7 @@ import static java.util.Arrays.stream;
 import static java.util.Collections.emptySet;
 import static java.util.Collections.singletonList;
 import static java.util.Optional.ofNullable;
+import static org.sonar.api.web.UserRole.USER;
 import static org.sonar.core.util.stream.MoreCollectors.toList;
 import static org.sonar.core.util.stream.MoreCollectors.toSet;
 import static org.sonar.server.es.DefaultIndexSettings.MINIMUM_NGRAM_LENGTH;
@@ -67,19 +75,21 @@ public class SuggestionsAction implements ComponentsWsAction {
   static final String PARAM_MORE = "more";
   static final String PARAM_RECENTLY_BROWSED = "recentlyBrowsed";
   static final String SHORT_INPUT_WARNING = "short_input";
-  private static final long MAXIMUM_RECENTLY_BROWSED = 50;
+  private static final int MAXIMUM_RECENTLY_BROWSED = 50;
 
   static final int EXTENDED_LIMIT = 20;
 
   private final ComponentIndex index;
   private final FavoriteFinder favoriteFinder;
+  private final UserSession userSession;
 
   private DbClient dbClient;
 
-  public SuggestionsAction(DbClient dbClient, ComponentIndex index, FavoriteFinder favoriteFinder) {
+  public SuggestionsAction(DbClient dbClient, ComponentIndex index, FavoriteFinder favoriteFinder, UserSession userSession) {
     this.dbClient = dbClient;
     this.index = index;
     this.favoriteFinder = favoriteFinder;
+    this.userSession = userSession;
   }
 
   @Override
@@ -97,10 +107,11 @@ public class SuggestionsAction implements ComponentsWsAction {
       .setSince("4.2")
       .setInternal(true)
       .setHandler(this)
-      .setResponseExample(Resources.getResource(this.getClass(), "components-example-suggestions.json"));
+      .setResponseExample(Resources.getResource(this.getClass(), "components-example-suggestions.json"))
+      .setChangelog(new Change("6.4", "Parameter 's' is optional"));
 
     action.createParam(PARAM_QUERY)
-      .setRequired(true)
+      .setRequired(false)
       .setDescription("Search query with a minimum of two characters. Can contain several search tokens, separated by spaces. " +
         "Search tokens with only one character will be ignored.")
       .setExampleValue("sonar");
@@ -115,99 +126,167 @@ public class SuggestionsAction implements ComponentsWsAction {
         + " items will be used. Order is not taken into account.")
       .setSince("6.4")
       .setExampleValue("org.sonarsource:sonarqube,some.other:project")
-      .setRequired(false);
+      .setRequired(false)
+      .setMaxValuesAllowed(MAXIMUM_RECENTLY_BROWSED);
   }
 
   @Override
   public void handle(Request wsRequest, Response wsResponse) throws Exception {
     String query = wsRequest.param(PARAM_QUERY);
     String more = wsRequest.param(PARAM_MORE);
+    Set<String> recentlyBrowsedKeys = getRecentlyBrowsedKeys(wsRequest);
+    List<String> qualifiers = getQualifiers(more);
+    SuggestionsWsResponse searchWsResponse = loadSuggestions(query, more, recentlyBrowsedKeys, qualifiers);
+    writeProtobuf(searchWsResponse, wsRequest, wsResponse);
+  }
+
+  private static Set<String> getRecentlyBrowsedKeys(Request wsRequest) {
     List<String> recentlyBrowsedParam = wsRequest.paramAsStrings(PARAM_RECENTLY_BROWSED);
-    Set<String> recentlyBrowsedKeys;
     if (recentlyBrowsedParam == null) {
-      recentlyBrowsedKeys = emptySet();
-    } else {
-      recentlyBrowsedKeys = recentlyBrowsedParam.stream().limit(MAXIMUM_RECENTLY_BROWSED).collect(Collectors.toSet());
+      return emptySet();
+    }
+    return new HashSet<>(recentlyBrowsedParam);
+  }
+
+  private SuggestionsWsResponse loadSuggestions(@Nullable String query, String more, Set<String> recentlyBrowsedKeys, List<String> qualifiers) {
+    if (query == null) {
+      return loadSuggestionsWithoutSearch(more, recentlyBrowsedKeys, qualifiers);
     }
-    Set<String> favoriteKeys = favoriteFinder.list().stream().map(ComponentDto::getKey).collect(Collectors.toSet());
+    return loadSuggestionsWithSearch(query, more, recentlyBrowsedKeys, qualifiers);
+  }
+
+  /**
+   * we are generating suggestions, by using (1) favorites and (2) recently browsed components (without searchin in Elasticsearch)
+   */
+  private SuggestionsWsResponse loadSuggestionsWithoutSearch(@Nullable String more, Set<String> recentlyBrowsedKeys, List<String> qualifiers) {
+    List<ComponentDto> favoriteDtos = favoriteFinder.list();
+    if (favoriteDtos.isEmpty() && recentlyBrowsedKeys.isEmpty()) {
+      return newBuilder().build();
+    }
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      Set<ComponentDto> componentDtos = new HashSet<>(favoriteDtos);
+      if (!recentlyBrowsedKeys.isEmpty()) {
+        componentDtos.addAll(dbClient.componentDao().selectByKeys(dbSession, recentlyBrowsedKeys));
+      }
+      ListMultimap<String, ComponentDto> componentsPerQualifier = componentDtos.stream()
+        .filter(c -> userSession.hasComponentPermission(USER, c))
+        .collect(MoreCollectors.index(ComponentDto::qualifier));
+      if (componentsPerQualifier.isEmpty()) {
+        return newBuilder().build();
+      }
+
+      Set<String> favoriteUuids = favoriteDtos.stream().map(ComponentDto::uuid).collect(MoreCollectors.toSet(favoriteDtos.size()));
+      Comparator<ComponentDto> favoriteComparator = Comparator.comparing(c -> favoriteUuids.contains(c.uuid()) ? -1 : +1);
+      Comparator<ComponentDto> comparator = favoriteComparator.thenComparing(ComponentDto::name);
+
+      int limit = more == null ? ComponentIndexQuery.DEFAULT_LIMIT : EXTENDED_LIMIT;
+      ComponentIndexResults componentsPerQualifiers = ComponentIndexResults.newBuilder().setQualifiers(
+        qualifiers.stream().map(q -> {
+          List<ComponentHit> hits = componentsPerQualifier.get(q)
+            .stream()
+            .sorted(comparator)
+            .limit(limit)
+            .map(ComponentDto::uuid)
+            .map(ComponentHit::new)
+            .collect(MoreCollectors.toList(limit));
+          int totalHits = componentsPerQualifier.size();
+          return new ComponentHitsPerQualifier(q, hits, totalHits);
+        })).build();
+      return buildResponse(recentlyBrowsedKeys, favoriteUuids, componentsPerQualifiers, dbSession, componentDtos.stream()).build();
+    }
+  }
 
+  private SuggestionsWsResponse loadSuggestionsWithSearch(String query, @Nullable String more, Set<String> recentlyBrowsedKeys, List<String> qualifiers) {
+    List<ComponentDto> favorites = favoriteFinder.list();
+    Set<String> favoriteKeys = favorites.stream().map(ComponentDto::getKey).collect(MoreCollectors.toSet(favorites.size()));
     ComponentIndexQuery.Builder queryBuilder = ComponentIndexQuery.builder()
       .setQuery(query)
       .setRecentlyBrowsedKeys(recentlyBrowsedKeys)
-      .setFavoriteKeys(favoriteKeys);
-
-    ComponentIndexResults componentsPerQualifiers = getComponentsPerQualifiers(more, queryBuilder);
-    String warning = getWarning(query);
-
-    SuggestionsWsResponse searchWsResponse = toResponse(componentsPerQualifiers, recentlyBrowsedKeys, favoriteKeys, warning);
-    writeProtobuf(searchWsResponse, wsRequest, wsResponse);
+      .setFavoriteKeys(favoriteKeys)
+      .setQualifiers(qualifiers);
+    if (more != null) {
+      queryBuilder.setLimit(EXTENDED_LIMIT);
+    }
+    ComponentIndexResults componentsPerQualifiers = searchInIndex(queryBuilder.build());
+    if (componentsPerQualifiers.isEmpty()) {
+      return newBuilder().build();
+    }
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      Set<String> componentUuids = componentsPerQualifiers.getQualifiers()
+        .map(ComponentHitsPerQualifier::getHits)
+        .flatMap(Collection::stream)
+        .map(ComponentHit::getUuid)
+        .collect(toSet());
+      Stream<ComponentDto> componentDtoStream = dbClient.componentDao().selectByUuids(dbSession, componentUuids).stream();
+      Set<String> favoriteUuids = favorites.stream().map(ComponentDto::uuid).collect(MoreCollectors.toSet(favorites.size()));
+      SuggestionsWsResponse.Builder searchWsResponse = buildResponse(recentlyBrowsedKeys, favoriteUuids, componentsPerQualifiers, dbSession, componentDtoStream);
+      getWarning(query).ifPresent(searchWsResponse::setWarning);
+      return searchWsResponse.build();
+    }
   }
 
-  private static String getWarning(String query) {
+  private static Optional<String> getWarning(String query) {
     List<String> tokens = ComponentTextSearchFeature.split(query).collect(Collectors.toList());
     if (tokens.stream().anyMatch(token -> token.length() < MINIMUM_NGRAM_LENGTH)) {
-      return SHORT_INPUT_WARNING;
+      return Optional.of(SHORT_INPUT_WARNING);
     }
-    return null;
+    return Optional.empty();
   }
 
-  private ComponentIndexResults getComponentsPerQualifiers(@Nullable String more, ComponentIndexQuery.Builder queryBuilder) {
-    List<String> qualifiers;
+  private static List<String> getQualifiers(@Nullable String more) {
     if (more == null) {
-      qualifiers = stream(SuggestionCategory.values()).map(SuggestionCategory::getQualifier).collect(Collectors.toList());
-    } else {
-      qualifiers = singletonList(SuggestionCategory.getByName(more).getQualifier());
-      queryBuilder.setLimit(EXTENDED_LIMIT);
+      return stream(SuggestionCategory.values()).map(SuggestionCategory::getQualifier).collect(Collectors.toList());
     }
-    queryBuilder.setQualifiers(qualifiers);
-    return searchInIndex(queryBuilder.build());
+    return singletonList(SuggestionCategory.getByName(more).getQualifier());
+  }
+
+  private SuggestionsWsResponse.Builder buildResponse(Set<String> recentlyBrowsedKeys, Set<String> favoriteUuids, ComponentIndexResults componentsPerQualifiers, DbSession dbSession,
+    Stream<ComponentDto> stream) {
+    Map<String, ComponentDto> componentsByUuids = stream
+      .collect(MoreCollectors.uniqueIndex(ComponentDto::uuid));
+    Map<String, OrganizationDto> organizationsByUuids = loadOrganizations(dbSession, componentsByUuids.values());
+    Map<String, ComponentDto> projectsByUuids = loadProjects(dbSession, componentsByUuids.values());
+    return toResponse(componentsPerQualifiers, recentlyBrowsedKeys, favoriteUuids, organizationsByUuids, componentsByUuids, projectsByUuids);
+  }
+
+  private Map<String, ComponentDto> loadProjects(DbSession dbSession, Collection<ComponentDto> components) {
+    Set<String> projectUuids = components.stream()
+      .filter(c -> !c.projectUuid().equals(c.uuid()))
+      .map(ComponentDto::projectUuid)
+      .collect(MoreCollectors.toSet());
+    return dbClient.componentDao().selectByUuids(dbSession, projectUuids).stream()
+      .collect(MoreCollectors.uniqueIndex(ComponentDto::uuid));
+  }
+
+  private Map<String, OrganizationDto> loadOrganizations(DbSession dbSession, Collection<ComponentDto> components) {
+    Set<String> organizationUuids = components.stream()
+      .map(ComponentDto::getOrganizationUuid)
+      .collect(MoreCollectors.toSet());
+    return dbClient.organizationDao().selectByUuids(dbSession, organizationUuids).stream()
+      .collect(MoreCollectors.uniqueIndex(OrganizationDto::getUuid));
   }
 
   private ComponentIndexResults searchInIndex(ComponentIndexQuery componentIndexQuery) {
     return index.search(componentIndexQuery);
   }
 
-  private SuggestionsWsResponse toResponse(ComponentIndexResults componentsPerQualifiers, Set<String> recentlyBrowsedKeys, Set<String> favoriteKeys, @Nullable String warning) {
-    SuggestionsWsResponse.Builder builder = newBuilder();
-    if (!componentsPerQualifiers.isEmpty()) {
-      Map<String, OrganizationDto> organizationsByUuids;
-      Map<String, ComponentDto> componentsByUuids;
-      Map<String, ComponentDto> projectsByUuids;
-      try (DbSession dbSession = dbClient.openSession(false)) {
-        Set<String> componentUuids = componentsPerQualifiers.getQualifiers()
-          .map(ComponentHitsPerQualifier::getHits)
-          .flatMap(Collection::stream)
-          .map(ComponentHit::getUuid)
-          .collect(toSet());
-        componentsByUuids = dbClient.componentDao().selectByUuids(dbSession, componentUuids).stream()
-          .collect(MoreCollectors.uniqueIndex(ComponentDto::uuid));
-        Set<String> organizationUuids = componentsByUuids.values().stream()
-          .map(ComponentDto::getOrganizationUuid)
-          .collect(toSet());
-        organizationsByUuids = dbClient.organizationDao().selectByUuids(dbSession, organizationUuids).stream()
-          .collect(MoreCollectors.uniqueIndex(OrganizationDto::getUuid));
-        Set<String> projectUuids = componentsByUuids.values().stream()
-          .filter(c -> !c.projectUuid().equals(c.uuid()))
-          .map(ComponentDto::projectUuid)
-          .collect(toSet());
-        projectsByUuids = dbClient.componentDao().selectByUuids(dbSession, projectUuids).stream()
-          .collect(MoreCollectors.uniqueIndex(ComponentDto::uuid));
-      }
-      builder
-        .addAllResults(toCategories(componentsPerQualifiers, recentlyBrowsedKeys, favoriteKeys, componentsByUuids, organizationsByUuids, projectsByUuids))
-        .addAllOrganizations(toOrganizations(organizationsByUuids))
-        .addAllProjects(toProjects(projectsByUuids));
+  private static SuggestionsWsResponse.Builder toResponse(ComponentIndexResults componentsPerQualifiers, Set<String> recentlyBrowsedKeys, Set<String> favoriteUuids,
+    Map<String, OrganizationDto> organizationsByUuids, Map<String, ComponentDto> componentsByUuids, Map<String, ComponentDto> projectsByUuids) {
+    if (componentsPerQualifiers.isEmpty()) {
+      return newBuilder();
     }
-    ofNullable(warning).ifPresent(builder::setWarning);
-    return builder.build();
+    return newBuilder()
+      .addAllResults(toCategories(componentsPerQualifiers, recentlyBrowsedKeys, favoriteUuids, componentsByUuids, organizationsByUuids, projectsByUuids))
+      .addAllOrganizations(toOrganizations(organizationsByUuids))
+      .addAllProjects(toProjects(projectsByUuids));
   }
 
-  private static List<Category> toCategories(ComponentIndexResults componentsPerQualifiers, Set<String> recentlyBrowsedKeys, Set<String> favoriteKeys,
+  private static List<Category> toCategories(ComponentIndexResults componentsPerQualifiers, Set<String> recentlyBrowsedKeys, Set<String> favoriteUuids,
     Map<String, ComponentDto> componentsByUuids, Map<String, OrganizationDto> organizationByUuids, Map<String, ComponentDto> projectsByUuids) {
     return componentsPerQualifiers.getQualifiers().map(qualifier -> {
 
       List<Suggestion> suggestions = qualifier.getHits().stream()
-        .map(hit -> toSuggestion(hit, recentlyBrowsedKeys, favoriteKeys, componentsByUuids, organizationByUuids, projectsByUuids))
+        .map(hit -> toSuggestion(hit, recentlyBrowsedKeys, favoriteUuids, componentsByUuids, organizationByUuids, projectsByUuids))
         .collect(toList());
 
       return Category.newBuilder()
@@ -218,7 +297,7 @@ public class SuggestionsAction implements ComponentsWsAction {
     }).collect(toList());
   }
 
-  private static Suggestion toSuggestion(ComponentHit hit, Set<String> recentlyBrowsedKeys, Set<String> favoriteKeys, Map<String, ComponentDto> componentsByUuids,
+  private static Suggestion toSuggestion(ComponentHit hit, Set<String> recentlyBrowsedKeys, Set<String> favoriteUuids, Map<String, ComponentDto> componentsByUuids,
     Map<String, OrganizationDto> organizationByUuids, Map<String, ComponentDto> projectsByUuids) {
     ComponentDto result = componentsByUuids.get(hit.getUuid());
     String organizationKey = organizationByUuids.get(result.getOrganizationUuid()).getKey();
@@ -231,7 +310,7 @@ public class SuggestionsAction implements ComponentsWsAction {
       .setName(result.longName())
       .setMatch(hit.getHighlightedText().orElse(HtmlEscapers.htmlEscaper().escape(result.longName())))
       .setIsRecentlyBrowsed(recentlyBrowsedKeys.contains(result.getKey()))
-      .setIsFavorite(favoriteKeys.contains(result.getKey()))
+      .setIsFavorite(favoriteUuids.contains(result.uuid()))
       .build();
   }
 
index c5ad241179594d3c312d851197a8d06605f575bb..ae89d6f6c5e0351baadf7bf01595df14dd382951 100644 (file)
@@ -29,6 +29,7 @@ import org.junit.Rule;
 import org.junit.Test;
 import org.sonar.api.config.MapSettings;
 import org.sonar.api.resources.Qualifiers;
+import org.sonar.api.server.ws.Change;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
 import org.sonar.db.DbTester;
@@ -49,6 +50,7 @@ import org.sonarqube.ws.WsComponents.SuggestionsWsResponse;
 import org.sonarqube.ws.WsComponents.SuggestionsWsResponse.Project;
 import org.sonarqube.ws.WsComponents.SuggestionsWsResponse.Suggestion;
 
+import static java.util.Arrays.asList;
 import static java.util.Collections.singletonList;
 import static java.util.Optional.ofNullable;
 import static java.util.stream.Collectors.joining;
@@ -58,6 +60,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.groups.Tuple.tuple;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
+import static org.sonar.api.web.UserRole.USER;
 import static org.sonar.db.component.ComponentTesting.newModuleDto;
 import static org.sonar.db.component.ComponentTesting.newPrivateProjectDto;
 import static org.sonar.server.component.index.ComponentIndexQuery.DEFAULT_LIMIT;
@@ -81,10 +84,10 @@ public class SuggestionsActionTest {
   private ComponentIndexer componentIndexer = new ComponentIndexer(db.getDbClient(), es.client());
   private FavoriteFinder favoriteFinder = mock(FavoriteFinder.class);
   private ComponentIndex index = new ComponentIndex(es.client(), new AuthorizationTypeSupport(userSessionRule));
-  private SuggestionsAction underTest = new SuggestionsAction(db.getDbClient(), index, favoriteFinder);
+  private SuggestionsAction underTest = new SuggestionsAction(db.getDbClient(), index, favoriteFinder, userSessionRule);
   private OrganizationDto organization;
   private PermissionIndexerTester authorizationIndexerTester = new PermissionIndexerTester(es, componentIndexer);
-  private WsActionTester actionTester = new WsActionTester(underTest);
+  private WsActionTester ws = new WsActionTester(underTest);
 
   @Before
   public void setUp() {
@@ -93,7 +96,7 @@ public class SuggestionsActionTest {
 
   @Test
   public void define_suggestions_action() {
-    WebService.Action action = actionTester.getDef();
+    WebService.Action action = ws.getDef();
     assertThat(action).isNotNull();
     assertThat(action.isInternal()).isTrue();
     assertThat(action.isPost()).isFalse();
@@ -103,12 +106,192 @@ public class SuggestionsActionTest {
       PARAM_MORE,
       PARAM_QUERY,
       PARAM_RECENTLY_BROWSED);
+    assertThat(action.changelog()).extracting(Change::getVersion, Change::getDescription).containsExactlyInAnyOrder(
+      tuple("6.4", "Parameter 's' is optional"));
 
     WebService.Param recentlyBrowsed = action.param(PARAM_RECENTLY_BROWSED);
     assertThat(recentlyBrowsed.since()).isEqualTo("6.4");
     assertThat(recentlyBrowsed.exampleValue()).isNotEmpty();
     assertThat(recentlyBrowsed.description()).isNotEmpty();
     assertThat(recentlyBrowsed.isRequired()).isFalse();
+
+    WebService.Param query = action.param(PARAM_QUERY);
+    assertThat(query.exampleValue()).isNotEmpty();
+    assertThat(query.description()).isNotEmpty();
+    assertThat(query.isRequired()).isFalse();
+  }
+
+  @Test
+  public void suggestions_without_query_should_contain_recently_browsed() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+
+    componentIndexer.indexOnStartup(null);
+    userSessionRule.addProjectPermission(USER, project);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .setParam(PARAM_RECENTLY_BROWSED, project.getKey())
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    // assert match in qualifier "TRK"
+    assertThat(response.getResultsList())
+      .filteredOn(q -> q.getItemsCount() > 0)
+      .extracting(Category::getQ)
+      .containsExactly(Qualifiers.PROJECT);
+
+    // assert correct id to be found
+    assertThat(response.getResultsList())
+      .flatExtracting(Category::getItemsList)
+      .extracting(Suggestion::getKey, Suggestion::getIsRecentlyBrowsed)
+      .containsExactly(tuple(project.getKey(), true));
+  }
+
+  @Test
+  public void suggestions_without_query_should_not_contain_recently_browsed_without_permission() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+
+    componentIndexer.indexOnStartup(null);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .setParam(PARAM_RECENTLY_BROWSED, project.getKey())
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    assertThat(response.getResultsList())
+      .flatExtracting(Category::getItemsList)
+      .isEmpty();
+  }
+
+  @Test
+  public void suggestions_without_query_should_contain_favorites() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+    doReturn(singletonList(project)).when(favoriteFinder).list();
+
+    componentIndexer.indexOnStartup(null);
+    userSessionRule.addProjectPermission(USER, project);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    // assert match in qualifier "TRK"
+    assertThat(response.getResultsList())
+      .filteredOn(q -> q.getItemsCount() > 0)
+      .extracting(Category::getQ)
+      .containsExactly(Qualifiers.PROJECT);
+
+    // assert correct id to be found
+    assertThat(response.getResultsList())
+      .flatExtracting(Category::getItemsList)
+      .extracting(Suggestion::getKey, Suggestion::getIsFavorite)
+      .containsExactly(tuple(project.getKey(), true));
+  }
+
+  @Test
+  public void suggestions_without_query_should_not_contain_favorites_without_permission() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+    doReturn(singletonList(project)).when(favoriteFinder).list();
+
+    componentIndexer.indexOnStartup(null);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    assertThat(response.getResultsList())
+      .flatExtracting(Category::getItemsList)
+      .isEmpty();
+  }
+
+  @Test
+  public void suggestions_without_query_should_contain_recently_browsed_favorites() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+    doReturn(singletonList(project)).when(favoriteFinder).list();
+
+    componentIndexer.indexOnStartup(null);
+    userSessionRule.addProjectPermission(USER, project);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .setParam(PARAM_RECENTLY_BROWSED, project.key())
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    // assert match in qualifier "TRK"
+    assertThat(response.getResultsList())
+      .filteredOn(q -> q.getItemsCount() > 0)
+      .extracting(Category::getQ)
+      .containsExactly(Qualifiers.PROJECT);
+
+    // assert correct id to be found
+    assertThat(response.getResultsList())
+      .flatExtracting(Category::getItemsList)
+      .extracting(Suggestion::getKey, Suggestion::getIsFavorite, Suggestion::getIsRecentlyBrowsed)
+      .containsExactly(tuple(project.getKey(), true, true));
+  }
+
+  @Test
+  public void suggestions_without_query_should_not_contain_matches_that_are_neither_favorites_nor_recently_browsed() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+
+    componentIndexer.indexOnStartup(null);
+    userSessionRule.addProjectPermission(USER, project);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    // assert match in qualifier "TRK"
+    assertThat(response.getResultsList())
+      .filteredOn(q -> q.getItemsCount() > 0)
+      .extracting(Category::getQ)
+      .isEmpty();
+  }
+
+  @Test
+  public void suggestions_without_query_should_order_results() throws Exception {
+    ComponentDto project1 = db.components().insertComponent(newPrivateProjectDto(organization).setName("Alpha").setLongName("Alpha"));
+    ComponentDto project2 = db.components().insertComponent(newPrivateProjectDto(organization).setName("Bravo").setLongName("Bravo"));
+    ComponentDto project3 = db.components().insertComponent(newPrivateProjectDto(organization).setName("Charlie").setLongName("Charlie"));
+    ComponentDto project4 = db.components().insertComponent(newPrivateProjectDto(organization).setName("Delta").setLongName("Delta"));
+    doReturn(asList(project4, project2)).when(favoriteFinder).list();
+
+    componentIndexer.indexOnStartup(null);
+    userSessionRule.addProjectPermission(USER, project1);
+    userSessionRule.addProjectPermission(USER, project2);
+    userSessionRule.addProjectPermission(USER, project3);
+    userSessionRule.addProjectPermission(USER, project4);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .setParam(PARAM_RECENTLY_BROWSED, Stream.of(project3, project1).map(ComponentDto::getKey).collect(joining(",")))
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    // assert order of keys
+    assertThat(response.getResultsList())
+      .flatExtracting(Category::getItemsList)
+      .extracting(Suggestion::getName, Suggestion::getIsFavorite, Suggestion::getIsRecentlyBrowsed)
+      .containsExactly(
+        tuple("Bravo", true, false),
+        tuple("Delta", true, false),
+        tuple("Alpha", false, true),
+        tuple("Charlie", false, true)
+    );
+  }
+
+  @Test
+  public void suggestions_without_query_should_return_empty_qualifiers() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+    componentIndexer.indexProject(project.projectUuid(), ProjectIndexer.Cause.PROJECT_CREATION);
+    userSessionRule.addProjectPermission(USER, project);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .setParam(PARAM_RECENTLY_BROWSED, project.key())
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    assertThat(response.getResultsList())
+      .extracting(Category::getQ, Category::getItemsCount)
+      .containsExactlyInAnyOrder(tuple("VW", 0), tuple("SVW", 0), tuple("TRK", 1), tuple("BRC", 0), tuple("FIL", 0), tuple("UTS", 0));
   }
 
   @Test
@@ -118,7 +301,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexOnStartup(null);
     authorizationIndexerTester.allowOnlyAnyone(project);
 
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, project.getKey())
       .executeProtobuf(SuggestionsWsResponse.class);
@@ -143,7 +326,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexOnStartup(null);
     authorizationIndexerTester.allowOnlyAnyone(project);
 
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, "S o")
       .executeProtobuf(SuggestionsWsResponse.class);
@@ -154,7 +337,7 @@ public class SuggestionsActionTest {
 
   @Test
   public void should_warn_about_short_inputs() throws Exception {
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, "validLongToken x")
       .executeProtobuf(SuggestionsWsResponse.class);
@@ -175,7 +358,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexProject(project2.projectUuid(), ProjectIndexer.Cause.PROJECT_CREATION);
     authorizationIndexerTester.allowOnlyAnyone(project2);
 
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, "Project")
       .executeProtobuf(SuggestionsWsResponse.class);
@@ -195,7 +378,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexProject(project.projectUuid(), ProjectIndexer.Cause.PROJECT_CREATION);
     authorizationIndexerTester.allowOnlyAnyone(project);
 
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, "Module")
       .executeProtobuf(SuggestionsWsResponse.class);
@@ -221,7 +404,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexProject(project.projectUuid(), ProjectIndexer.Cause.PROJECT_CREATION);
     authorizationIndexerTester.allowOnlyAnyone(project);
 
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, "Module")
       .setParam(PARAM_RECENTLY_BROWSED, Stream.of(module1.getKey()).collect(joining(",")))
@@ -245,7 +428,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexProject(project.projectUuid(), ProjectIndexer.Cause.PROJECT_CREATION);
     authorizationIndexerTester.allowOnlyAnyone(project);
 
-    SuggestionsWsResponse response = actionTester.newRequest()
+    SuggestionsWsResponse response = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, "Module")
       .executeProtobuf(SuggestionsWsResponse.class);
@@ -256,6 +439,22 @@ public class SuggestionsActionTest {
       .containsExactly(tuple(favorite.getKey(), true), tuple(nonFavorite.getKey(), false));
   }
 
+  @Test
+  public void should_return_empty_qualifiers() throws Exception {
+    ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organization));
+    componentIndexer.indexProject(project.projectUuid(), ProjectIndexer.Cause.PROJECT_CREATION);
+    authorizationIndexerTester.allowOnlyAnyone(project);
+
+    SuggestionsWsResponse response = ws.newRequest()
+      .setMethod("POST")
+      .setParam(PARAM_QUERY, project.name())
+      .executeProtobuf(SuggestionsWsResponse.class);
+
+    assertThat(response.getResultsList())
+      .extracting(Category::getQ, Category::getItemsCount)
+      .containsExactlyInAnyOrder(tuple("VW", 0), tuple("SVW", 0), tuple("TRK", 1), tuple("BRC", 0), tuple("FIL", 0), tuple("UTS", 0));
+  }
+
   @Test
   public void should_propose_to_show_more_results_if_7_projects_are_found() throws Exception {
     check_proposal_to_show_more_results(7, DEFAULT_LIMIT, 1L, null);
@@ -286,7 +485,7 @@ public class SuggestionsActionTest {
     componentIndexer.indexOnStartup(null);
     projects.forEach(authorizationIndexerTester::allowOnlyAnyone);
 
-    TestRequest request = actionTester.newRequest()
+    TestRequest request = ws.newRequest()
       .setMethod("POST")
       .setParam(PARAM_QUERY, namePrefix);
     ofNullable(more).ifPresent(c -> request.setParam(PARAM_MORE, c.getName()));