Categories related client API's migrated to retrofit (#3053)

* Commit 1

* searchCategories migrated to retrofit

* SearchCategoriesFragment migrated to new API

* Removed unused code

* Created tests

* implemented searching by prefix
fixed SearchCategoryFragment behaviour where the same categories would be added to the list instead of new ones.

* added tests

* Migrated searchTitles to searchCategories, function behaviour seems identical
This commit is contained in:
Ilgaz Er 2019-07-05 17:12:58 +03:00 committed by Vivek Maskara
parent 78141cb609
commit d5198be3e3
8 changed files with 264 additions and 170 deletions

View file

@ -23,6 +23,7 @@ public class CategoriesModel{
private static final int SEARCH_CATS_LIMIT = 25;
private final MediaWikiApi mwApi;
private final CategoryClient categoryClient;
private final CategoryDao categoryDao;
private final JsonKvStore directKvStore;
@ -32,9 +33,11 @@ public class CategoriesModel{
@Inject GpsCategoryModel gpsCategoryModel;
@Inject
public CategoriesModel(MediaWikiApi mwApi,
CategoryClient categoryClient,
CategoryDao categoryDao,
@Named("default_preferences") JsonKvStore directKvStore) {
this.mwApi = mwApi;
this.categoryClient = categoryClient;
this.categoryDao = categoryDao;
this.directKvStore = directKvStore;
this.categoriesCache = new HashMap<>();
@ -121,8 +124,8 @@ public class CategoriesModel{
}
//otherwise, search API for matching categories
return mwApi
.allCategories(term, SEARCH_CATS_LIMIT)
return categoryClient
.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
.map(name -> new CategoryItem(name, false));
}
@ -185,7 +188,7 @@ public class CategoriesModel{
* @return
*/
private Observable<CategoryItem> getTitleCategories(String title) {
return mwApi.searchTitles(title, SEARCH_CATS_LIMIT)
return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT)
.map(name -> new CategoryItem(name, false));
}

View file

@ -0,0 +1,97 @@
package fr.free.nrw.commons.category;
import org.wikipedia.dataclient.mwapi.MwQueryPage;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import io.reactivex.Observable;
import timber.log.Timber;
/**
* Category Client to handle custom calls to Commons MediaWiki APIs
*/
@Singleton
public class CategoryClient {
private final CategoryInterface CategoryInterface;
@Inject
public CategoryClient(CategoryInterface CategoryInterface) {
this.CategoryInterface = CategoryInterface;
}
/**
* Searches for categories containing the specified string.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
* @return
*/
public Observable<String> searchCategories(String filter, int itemLimit, int offset) {
return responseToCategoryName(CategoryInterface.searchCategories(filter, itemLimit, offset));
}
/**
* Searches for categories containing the specified string.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @return
*/
public Observable<String> searchCategories(String filter, int itemLimit) {
return searchCategories(filter, itemLimit, 0);
}
/**
* Searches for categories starting with the specified string.
*
* @param prefix The prefix to be searched
* @param itemLimit How many results are returned
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
* @return
*/
public Observable<String> searchCategoriesForPrefix(String prefix, int itemLimit, int offset) {
return responseToCategoryName(CategoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset));
}
/**
* Searches for categories starting with the specified string.
*
* @param prefix The prefix to be searched
* @param itemLimit How many results are returned
* @return
*/
public Observable<String> searchCategoriesForPrefix(String prefix, int itemLimit) {
return searchCategoriesForPrefix(prefix, itemLimit, 0);
}
/**
* Internal function to reduce code reuse. Extracts the categories returned from MwQueryResponse.
*
* @param responseObservable The query response observable
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
*/
private Observable<String> responseToCategoryName(Observable<MwQueryResponse> responseObservable) {
return responseObservable
.flatMap(mwQueryResponse -> {
List<MwQueryPage> pages = mwQueryResponse.query().pages();
if (pages != null)
return Observable.fromIterable(pages);
else
Timber.d("No categories returned.");
return Observable.empty();
})
.map(MwQueryPage::title)
.doOnEach(s -> Timber.d("Category returned: %s", s))
.map(cat -> cat.replace("Category:", ""));
}
}

View file

@ -0,0 +1,31 @@
package fr.free.nrw.commons.category;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import io.reactivex.Observable;
import retrofit2.http.GET;
import retrofit2.http.Query;
/**
* Interface for interacting with Commons category related APIs
*/
public interface CategoryInterface {
/**
* Searches for categories with the specified name.
* Replaces ApacheHttpClientMediaWikiApi#allCategories
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @return
*/
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=search&gsrnamespace=14")
Observable<MwQueryResponse> searchCategories(@Query("gsrsearch") String filter,
@Query("gsrlimit") int itemLimit, @Query("gsroffset") int offset);
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=allcategories")
Observable<MwQueryResponse> searchCategoriesForPrefix(@Query("gacprefix") String prefix,
@Query("gaclimit") int itemLimit, @Query("gacoffset") int offset);
}

View file

@ -18,6 +18,7 @@ import androidx.annotation.NonNull;
import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.category.CategoryInterface;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.media.MediaInterface;
import fr.free.nrw.commons.mwapi.ApacheHttpClientMediaWikiApi;
@ -132,4 +133,10 @@ public class NetworkingModule {
public MediaInterface provideMediaInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, MediaInterface.class);
}
@Provides
@Singleton
public CategoryInterface provideCategoryInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, CategoryInterface.class);
}
}

View file

@ -25,6 +25,7 @@ import javax.inject.Named;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.category.CategoryClient;
import fr.free.nrw.commons.category.CategoryDetailsActivity;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.explore.recentsearches.RecentSearch;
@ -58,9 +59,12 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
String query;
@BindView(R.id.bottomProgressBar)
ProgressBar bottomProgressBar;
boolean isLoadingCategories;
@Inject RecentSearchesDao recentSearchesDao;
@Inject MediaWikiApi mwApi;
@Inject CategoryClient categoryClient;
@Inject
@Named("default_preferences")
JsonKvStore basicKvStore;
@ -135,33 +139,36 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
progressBar.setVisibility(GONE);
queryList.clear();
categoriesAdapter.clear();
compositeDisposable.add(Observable.fromCallable(() -> mwApi.searchCategory(query,queryList.size()))
compositeDisposable.add(categoryClient.searchCategories(query,25)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.doOnSubscribe(disposable -> saveQuery(query))
.collect(ArrayList<String>::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError));
}
/**
* Adds more results to existing search results
* Adds 25 more results to existing search results
*/
public void addCategoriesToList(String query) {
if(isLoadingCategories) return;
isLoadingCategories=true;
this.query = query;
bottomProgressBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(GONE);
compositeDisposable.add(Observable.fromCallable(() -> mwApi.searchCategory(query,queryList.size()))
compositeDisposable.add(categoryClient.searchCategories(query,25, queryList.size())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.collect(ArrayList<String>::new, ArrayList::add)
.subscribe(this::handlePaginationSuccess, this::handleError));
}
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
* @param mediaList
*/
private void handlePaginationSuccess(List<String> mediaList) {
queryList.addAll(mediaList);
@ -169,6 +176,7 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
bottomProgressBar.setVisibility(GONE);
categoriesAdapter.addAll(mediaList);
categoriesAdapter.notifyDataSetChanged();
isLoadingCategories=false;
}
@ -176,7 +184,6 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
* @param mediaList
*/
private void handleSuccess(List<String> mediaList) {
queryList = mediaList;
@ -194,7 +201,6 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
/**
* Logs and handles API error scenario
* @param throwable
*/
private void handleError(Throwable throwable) {
Timber.e(throwable, "Error occurred while loading queried categories");
@ -213,7 +219,7 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
private void initErrorView() {
progressBar.setVisibility(GONE);
categoriesNotFoundView.setVisibility(VISIBLE);
categoriesNotFoundView.setText(getString(R.string.categories_not_found, query));
categoriesNotFoundView.setText(getString(R.string.categories_not_found));
}
/**

View file

@ -152,7 +152,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
status.equals("UI")
&& loginCustomApiResult.getString("/api/clientlogin/requests/_v/@id").equals("TOTPAuthenticationRequest")
&& loginCustomApiResult.getString("/api/clientlogin/requests/_v/@provider").equals("Two-factor authentication (OATH).")
) {
) {
setAuthCookieOnLogin(false);
return "2FA";
}
@ -251,7 +251,6 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
}
@Override
@Nullable
public String appendEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
@ -305,71 +304,6 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
});
}
@Override
@NonNull
public Observable<String> searchCategories(String filterValue, int searchCatsLimit) {
List<String> categories = new ArrayList<>();
return Single.fromCallable(() -> {
List<CustomApiResult> categoryNodes = null;
try {
categoryNodes = api.action("query")
.param("format", "xml")
.param("list", "search")
.param("srwhat", "text")
.param("srnamespace", "14")
.param("srlimit", searchCatsLimit)
.param("srsearch", filterValue)
.get()
.getNodes("/api/query/search/p/@title");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchCategories");
}
if (categoryNodes == null) {
return new ArrayList<String>();
}
for (CustomApiResult categoryNode : categoryNodes) {
String cat = categoryNode.getDocument().getTextContent();
String catString = cat.replace("Category:", "");
if (!categories.contains(catString)) {
categories.add(catString);
}
}
return categories;
}).flatMapObservable(Observable::fromIterable);
}
@Override
@NonNull
public Observable<String> allCategories(String filterValue, int searchCatsLimit) {
return Single.fromCallable(() -> {
ArrayList<CustomApiResult> categoryNodes = null;
try {
categoryNodes = api.action("query")
.param("list", "allcategories")
.param("acprefix", filterValue)
.param("aclimit", searchCatsLimit)
.get()
.getNodes("/api/query/allcategories/c");
} catch (IOException e) {
Timber.e(e, "Failed to obtain allCategories");
}
if (categoryNodes == null) {
return new ArrayList<String>();
}
List<String> categories = new ArrayList<>();
for (CustomApiResult categoryNode : categoryNodes) {
categories.add(categoryNode.getDocument().getTextContent());
}
return categories;
}).flatMapObservable(Observable::fromIterable);
}
@Override
public String getWikidataCsrfToken() throws IOException {
String wikidataCsrfToken = wikidataApi.action("query")
@ -385,10 +319,11 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
/**
* Creates a new claim using the wikidata API
* https://www.mediawiki.org/wiki/Wikibase/API
*
* @param entityId the wikidata entity to be edited
* @param property the property to be edited, for eg P18 for images
* @param snaktype the type of value stored for that property
* @param value the actual value to be stored for the property, for eg filename in case of P18
* @param value the actual value to be stored for the property, for eg filename in case of P18
* @return returns revisionId if the claim is successfully created else returns null
* @throws IOException
*/
@ -422,6 +357,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
/**
* Adds the wikimedia-commons-app tag to the edits made on wikidata
*
* @param revisionId
* @return
* @throws IOException
@ -451,42 +387,6 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
return false;
}
@Override
@NonNull
public Observable<String> searchTitles(String title, int searchCatsLimit) {
return Single.fromCallable((Callable<List<String>>) () -> {
ArrayList<CustomApiResult> categoryNodes;
try {
categoryNodes = api.action("query")
.param("format", "xml")
.param("list", "search")
.param("srwhat", "text")
.param("srnamespace", "14")
.param("srlimit", searchCatsLimit)
.param("srsearch", title)
.get()
.getNodes("/api/query/search/p/@title");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchTitles");
return Collections.emptyList();
}
if (categoryNodes == null) {
return Collections.emptyList();
}
List<String> titleCategories = new ArrayList<>();
for (CustomApiResult categoryNode : categoryNodes) {
String cat = categoryNode.getDocument().getTextContent();
String catString = cat.replace("Category:", "");
titleCategories.add(catString);
}
return titleCategories;
}).flatMapObservable(Observable::fromIterable);
}
@Override
@NonNull
public LogEventResult logEvents(String user, String lastModified, String queryContinue, int limit) throws IOException {
@ -543,13 +443,13 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
try {
if (archived) {
notfilter = "read";
}else {
} else {
notfilter = "!read";
}
String language=Locale.getDefault().getLanguage();
if(StringUtils.isBlank(language)){
String language = Locale.getDefault().getLanguage();
if (StringUtils.isBlank(language)) {
//if no language is set we use the default user language defined on wikipedia
language="user";
language = "user";
}
notificationNode = api.action("query")
.param("notprop", "list")
@ -595,6 +495,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
* The method takes categoryName as input and returns a List of Subcategories
* It uses the generator query API to get the subcategories in a category, 500 at a time.
* Uses the query continue values for fetching paginated responses
*
* @param categoryName Category name as defined on commons
* @return
*/
@ -606,7 +507,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
CustomMwApi.RequestBuilder requestBuilder = api.action("query")
.param("generator", "categorymembers")
.param("format", "xml")
.param("gcmtype","subcat")
.param("gcmtype", "subcat")
.param("gcmtitle", categoryName)
.param("prop", "info")
.param("gcmlimit", "500")
@ -636,6 +537,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
/**
* The method takes categoryName as input and returns a List of parent categories
* It uses the generator query API to get the parent categories of a category, 500 at a time.
*
* @param categoryName Category name as defined on commons
* @return
*/
@ -673,48 +575,12 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
return CategoryImageUtils.getSubCategoryList(childNodes);
}
/**
* This method takes search keyword as input and returns a list of categories objects filtered using search query
* It uses the generator query API to get the categories searched using a query, 25 at a time.
* @param query keyword to search categories on commons
* @return
*/
@Override
@NonNull
public List<String> searchCategory(String query, int offset) {
List<CustomApiResult> categoryNodes = null;
try {
categoryNodes = api.action("query")
.param("format", "xml")
.param("list", "search")
.param("srwhat", "text")
.param("srnamespace", "14")
.param("srlimit", "25")
.param("sroffset",offset)
.param("srsearch", query)
.get()
.getNodes("/api/query/search/p/@title");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchCategories");
}
if (categoryNodes == null) {
return new ArrayList<>();
}
List<String> categories = new ArrayList<>();
for (CustomApiResult categoryNode : categoryNodes) {
String catName = categoryNode.getDocument().getTextContent();
categories.add(catName);
}
return categories;
}
/**
* For APIs that return paginated responses, MediaWiki APIs uses the QueryContinue to facilitate fetching of subsequent pages
* https://www.mediawiki.org/wiki/API:Raw_query_continue
* After fetching images a page of image for a particular category, shared defaultKvStore are updated with the latest QueryContinue Values
*
* @param keyword
* @param queryContinue
*/
@ -724,6 +590,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
/**
* Before making a paginated API call, this method is called to get the latest query continue values to be used
*
* @param keyword
* @return
*/
@ -751,7 +618,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
if (!resultStatus.equals("Success")) {
String errorCode = result.getString("/api/error/@code");
Timber.e(errorCode);
if (errorCode.equals(ERROR_CODE_BAD_TOKEN)) {
ViewUtil.showLongToast(context, R.string.bad_token_error_proposed_solution);
}
@ -799,8 +666,8 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
}
/**
* Checks to see if a user is currently blocked from Commons
*
* @return whether or not the user is blocked from Commons
*/
@Override

View file

@ -34,9 +34,6 @@ public interface MediaWikiApi {
List<String> getParentCategoryList(String categoryName);
@NonNull
List<String> searchCategory(String title, int offset);
@NonNull
Single<UploadStash> uploadFile(String filename, InputStream file,
long dataLength, Uri fileUri, Uri contentProviderUri,
@ -65,21 +62,12 @@ public interface MediaWikiApi {
@NonNull
Single<MediaResult> fetchMediaByFilename(String filename);
@NonNull
Observable<String> searchCategories(String filterValue, int searchCatsLimit);
@NonNull
Observable<String> allCategories(String filter, int searchCatsLimit);
@NonNull
List<Notification> getNotifications(boolean archived) throws IOException;
@NonNull
boolean markNotificationAsRead(Notification notification) throws IOException;
@NonNull
Observable<String> searchTitles(String title, int searchCatsLimit);
@Nullable
String revisionsByFilename(String filename) throws IOException;

View file

@ -0,0 +1,95 @@
package fr.free.nrw.commons.category
import io.reactivex.Observable
import junit.framework.Assert
import org.junit.Before
import org.junit.Test
import org.mockito.*
import org.wikipedia.dataclient.mwapi.MwQueryPage
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import org.wikipedia.dataclient.mwapi.MwQueryResult
class CategoryClientTest {
@Mock
internal var categoryInterface: CategoryInterface? = null
@InjectMocks
var categoryClient: CategoryClient? = null
@Before
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
}
@Test
fun searchCategoriesFound() {
val mwQueryPage = Mockito.mock(MwQueryPage::class.java)
Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test")
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage))
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult)
Mockito.`when`(categoryInterface!!.searchCategories(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt()))
.thenReturn(Observable.just(mockResponse))
val actualCategoryName = categoryClient!!.searchCategories("tes", 10).blockingFirst()
Assert.assertEquals("Test", actualCategoryName)
val actualCategoryName2 = categoryClient!!.searchCategories("tes", 10, 10).blockingFirst()
Assert.assertEquals("Test", actualCategoryName2)
}
@Test
fun searchCategoriesNull() {
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.pages()).thenReturn(null)
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult)
Mockito.`when`(categoryInterface!!.searchCategories(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt()))
.thenReturn(Observable.just(mockResponse))
categoryClient!!.searchCategories("tes", 10).subscribe(
{ Assert.fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
categoryClient!!.searchCategories("tes", 10, 10).subscribe(
{ Assert.fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
}
@Test
fun searchCategoriesForPrefixFound() {
val mwQueryPage = Mockito.mock(MwQueryPage::class.java)
Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test")
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage))
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult)
Mockito.`when`(categoryInterface!!.searchCategoriesForPrefix(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt()))
.thenReturn(Observable.just(mockResponse))
val actualCategoryName = categoryClient!!.searchCategoriesForPrefix("tes", 10).blockingFirst()
Assert.assertEquals("Test", actualCategoryName)
val actualCategoryName2 = categoryClient!!.searchCategoriesForPrefix("tes", 10, 10).blockingFirst()
Assert.assertEquals("Test", actualCategoryName2)
}
@Test
fun searchCategoriesForPrefixNull() {
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.pages()).thenReturn(null)
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult)
Mockito.`when`(categoryInterface!!.searchCategoriesForPrefix(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt()))
.thenReturn(Observable.just(mockResponse))
categoryClient!!.searchCategoriesForPrefix("tes", 10).subscribe(
{ Assert.fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
categoryClient!!.searchCategoriesForPrefix("tes", 10, 10).subscribe(
{ Assert.fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
}
}