/** * Copyright (c) Codice Foundation * <p> * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser * General Public License as published by the Free Software Foundation, either version 3 of the * License, or any later version. * <p> * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. A copy of the GNU Lesser General Public License * is distributed along with this program and can be found at * <http://www.gnu.org/licenses/lgpl.html>. **/ package org.codice.ddf.ui.searchui.query.controller; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.core.IsNull.nullValue; import static org.junit.Assert.fail; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyLong; import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; import org.codice.ddf.ui.searchui.query.actions.ActionRegistryImpl; import org.codice.ddf.ui.searchui.query.model.Search; import org.codice.ddf.ui.searchui.query.model.SearchRequest; import org.cometd.bayeux.server.BayeuxServer; import org.cometd.bayeux.server.ServerChannel; import org.cometd.bayeux.server.ServerMessage; import org.cometd.bayeux.server.ServerMessage.Mutable; import org.cometd.bayeux.server.ServerSession; import org.geotools.filter.text.cql2.CQLException; import org.geotools.filter.text.ecql.ECQL; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; import org.opengis.filter.Filter; import org.opengis.filter.sort.SortBy; import org.opengis.filter.sort.SortOrder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.util.concurrent.Futures; import ddf.catalog.CatalogFramework; import ddf.catalog.data.Result; import ddf.catalog.data.impl.BasicTypes; import ddf.catalog.data.impl.MetacardImpl; import ddf.catalog.federation.FederationException; import ddf.catalog.filter.impl.SortByImpl; import ddf.catalog.filter.proxy.adapter.GeotoolsFilterAdapterImpl; import ddf.catalog.operation.Query; import ddf.catalog.operation.QueryRequest; import ddf.catalog.operation.QueryResponse; import ddf.catalog.operation.impl.QueryImpl; import ddf.catalog.operation.impl.QueryResponseImpl; import ddf.catalog.source.SourceUnavailableException; import ddf.catalog.source.UnsupportedQueryException; /** * Test cases for {@link org.codice.ddf.ui.searchui.query.controller.SearchController} */ @RunWith(MockitoJUnitRunner.class) public class SearchControllerTest { private static final String REASON_UNSUPPORTED_QUERY = "Query was invalid"; private static final String REASON_SOURCE_UNAVAILABLE = "Query hit a source that was unavailable"; private static final String REASON_INTERRUPTED = "Query was interrupted"; private static final String REASON_TIMEOUT = "Query timed out"; private static final String REASON_INTERNAL = "Internal error"; private static final int MAX_EXCEPTION_SCAN_DEPTH = 10; // NOTE: The ServerSession ID == The ClientSession ID private static final String MOCK_SESSION_ID = "1234-5678-9012-3456"; private static final Date TIMESTAMP = new Date(); private static final Logger LOGGER = LoggerFactory.getLogger(SearchControllerTest.class); private static final String[] SOURCE_ID_LIST = {"any_source_id"}; private SearchController searchController; private CatalogFramework framework; private ServerSession mockServerSession; @Mock private ExecutorService mockExecutor; @Mock private BayeuxServer mockBayeuxServer; @Mock private ServerChannel mockServerChannel; @Mock private Future mockFuture; @Mock private SearchRequest mockSearchRequest; @Mock private Query mockQuery; @Before public void setUp() throws Exception { framework = createFramework(); searchController = new SearchController(framework, new ActionRegistryImpl(Collections.emptyList(), Collections.emptyList()), new GeotoolsFilterAdapterImpl(), new SequentialExecutorService()); mockServerSession = mock(ServerSession.class); when(mockServerSession.getId()).thenReturn(MOCK_SESSION_ID); when(mockSearchRequest.getId()).thenReturn("any_id"); when(mockSearchRequest.getSourceIds()).thenReturn(new HashSet<>(Arrays.asList(SOURCE_ID_LIST))); when(mockSearchRequest.getQuery()).thenReturn(mockQuery); when(mockQuery.getSortBy()).thenReturn(SortBy.NATURAL_ORDER); when(mockBayeuxServer.getChannel(anyString())).thenReturn(mockServerChannel); when(mockExecutor.submit(any(Runnable.class))).thenAnswer( (args) -> { Runnable runnable = (Runnable) args.getArguments()[0]; runnable.run(); return mockFuture; } ); } @Test public void testExceptionPassedOnBadQuery() throws Exception { runTestForReasonMessages(new ExecutionException(new UnsupportedQueryException()), REASON_UNSUPPORTED_QUERY); } @Test public void testExceptionPassedOnSourceError() throws Exception { runTestForReasonMessages(new ExecutionException(new SourceUnavailableException()), REASON_SOURCE_UNAVAILABLE); } @Test public void testExceptionPassedOnInterrupt() throws Exception { runTestForReasonMessages(new InterruptedException(), REASON_INTERRUPTED); } @Test public void testExceptionPassedOnTimeout() throws Exception { runTestForReasonMessages(new TimeoutException(), REASON_TIMEOUT); } @Test public void testExceptionPassedOnExecutionException() throws Exception { ExecutionException executionException = new ExecutionException(new ExecutionException(new Exception())); runTestForReasonMessages(executionException, REASON_INTERNAL); } @Test public void testExceptionScanDepthValid() throws Exception { runTestForExceptionScanDepth(2, REASON_TIMEOUT); } @Test public void testExceptionScanDepthTooDeep() throws Exception { runTestForExceptionScanDepth(1, REASON_INTERNAL); } @Test public void testMetacardTypeValuesCacheDisabled() throws Exception { final String ID = "id"; Set<String> srcIds = new HashSet<String>(); srcIds.add(ID); BayeuxServer bayeuxServer = mock(BayeuxServer.class); ServerChannel channel = mock(ServerChannel.class); ArgumentCaptor<ServerMessage.Mutable> reply = ArgumentCaptor.forClass(ServerMessage.Mutable.class); when(bayeuxServer.getChannel(any(String.class))).thenReturn(channel); SearchRequest request = new SearchRequest(srcIds, getQueryRequest("title LIKE 'Meta*'"), ID); searchController.setBayeuxServer(bayeuxServer); // Disable Cache searchController.setCacheDisabled(true); searchController.executeQuery(request, mockServerSession, null); verify(channel, timeout(1000).only()).publish(any(ServerSession.class), reply.capture()); List<Mutable> replies = reply.getAllValues(); assertReplies(replies); } @Test public void testMetacardTypeValuesCacheEnabled() throws Exception { final String ID = "id"; Set<String> srcIds = new HashSet<>(); srcIds.add(ID); BayeuxServer bayeuxServer = mock(BayeuxServer.class); ServerChannel channel = mock(ServerChannel.class); ArgumentCaptor<ServerMessage.Mutable> reply = ArgumentCaptor.forClass(ServerMessage.Mutable.class); when(bayeuxServer.getChannel(any(String.class))).thenReturn(channel); SearchRequest request = new SearchRequest(srcIds, getQueryRequest("title LIKE 'Meta*'"), ID); searchController.setBayeuxServer(bayeuxServer); searchController.setCacheDisabled(false); searchController.executeQuery(request, mockServerSession, null); verify(channel, timeout(1000).times(2)).publish(any(ServerSession.class), reply.capture()); List<Mutable> replies = reply.getAllValues(); assertReplies(replies); } private Query getQueryRequest(String cql) throws CQLException { Filter filter = ECQL.toFilter(cql); return new QueryImpl(filter, 1, 200, new SortByImpl(Result.TEMPORAL, SortOrder.DESCENDING), true, 1000); } @Test public void testExecuteQueryCacheEnabledWithSingleSource() throws Exception { Set<String> srcIds = new HashSet<>(1); srcIds.add("id"); List<String> modes = cacheQuery(srcIds, 2); assertThat(modes.size(), is(2)); assertThat(modes, hasItems("cache", "update")); } @Test public void testExecuteQueryCacheEnabledWithMultipleSources() throws Exception { Set<String> srcIds = new HashSet<>(2); srcIds.add("id1"); srcIds.add("id2"); List<String> modes = cacheQuery(srcIds, 3); assertThat(modes.size(), is(3)); assertThat(modes, hasItems("cache", "update", "update")); } @Test public void testFailingQuery() throws Exception { // Setup framework = mock(CatalogFramework.class); QueryResponse response = mock(QueryResponse.class); when(response.getResults()).thenThrow(new RuntimeException("Getting results failed")); when(framework.query(any(QueryRequest.class))).thenReturn(response); searchController = new SearchController(framework, new ActionRegistryImpl(Collections.emptyList(), Collections.emptyList()), new GeotoolsFilterAdapterImpl(), new SequentialExecutorService()); final String ID = "id"; Set<String> srcIds = new HashSet<>(); srcIds.add(ID); SearchRequest request = new SearchRequest(srcIds, getQueryRequest("anyText LIKE '*'"), "queryId"); BayeuxServer bayeuxServer = mock(BayeuxServer.class); ServerChannel channel = mock(ServerChannel.class); when(bayeuxServer.getChannel(any(String.class))).thenReturn(channel); searchController.setBayeuxServer(bayeuxServer); searchController.setCacheDisabled(true); // Perform Test searchController.executeQuery(request, mockServerSession, null); // Verify verify(channel, times(1)).publish(any(), any()); } private List<String> cacheQuery(Set<String> srcIds, int queryRequestCount) throws CQLException, UnsupportedQueryException, SourceUnavailableException, FederationException { SearchRequest request = new SearchRequest(srcIds, getQueryRequest("anyText LIKE '*'"), "queryId"); BayeuxServer bayeuxServer = mock(BayeuxServer.class); ServerChannel channel = mock(ServerChannel.class); when(bayeuxServer.getChannel(any(String.class))).thenReturn(channel); ArgumentCaptor<QueryRequest> queryRequestCaptor = ArgumentCaptor.forClass(QueryRequest.class); searchController.setCacheDisabled(false); searchController.setNormalizationDisabled(false); searchController.setBayeuxServer(bayeuxServer); // Perform Test searchController.executeQuery(request, mockServerSession, null); // Verify verify(framework, times(queryRequestCount)).query(queryRequestCaptor.capture()); List<QueryRequest> capturedQueryRequests = queryRequestCaptor.getAllValues(); List<String> modes = new ArrayList<>(queryRequestCount); for (QueryRequest queryRequest : capturedQueryRequests) { for (String key : queryRequest.getProperties() .keySet()) { modes.add((String) queryRequest.getPropertyValue(key)); } } return modes; } /** * Verify that the CatalogFramework does not use the cache (i.e. the CatalogFramework * is called WITHOUT the query request property mode=cache). */ @Test public void testExecuteQueryCacheDisabled() throws Exception { // Setup final String ID = "id"; Set<String> srcIds = new HashSet<>(1); srcIds.add(ID); SearchRequest request = new SearchRequest(srcIds, getQueryRequest("title LIKE 'Meta*'"), ID); BayeuxServer bayeuxServer = mock(BayeuxServer.class); ServerChannel channel = mock(ServerChannel.class); when(bayeuxServer.getChannel(any(String.class))).thenReturn(channel); ArgumentCaptor<QueryRequest> queryRequestCaptor = ArgumentCaptor.forClass(QueryRequest.class); // Enable Cache searchController.setCacheDisabled(true); searchController.setBayeuxServer(bayeuxServer); // Perform Test searchController.executeQuery(request, mockServerSession, null); // Verify verify(framework).query(queryRequestCaptor.capture()); assertThat(queryRequestCaptor.getValue() .getProperties() .size(), is(0)); } private void assertReplies(List<Mutable> replies) { for (Mutable reply : replies) { assertThat(reply, is(not(nullValue()))); assertThat(reply.getDataAsMap() .get(Search.METACARD_TYPES), is(not(nullValue()))); assertThat(reply.getDataAsMap() .get(Search.METACARD_TYPES), instanceOf(Map.class)); @SuppressWarnings("unchecked") Map<String, Object> types = (Map<String, Object>) reply.getDataAsMap() .get(Search.METACARD_TYPES); assertThat(types.get("ddf.metacard"), is(not(nullValue()))); assertThat(types.get("ddf.metacard"), instanceOf(Map.class)); @SuppressWarnings("unchecked") Map<String, Map<String, Object>> typeInfo = (Map<String, Map<String, Object>>) types.get("ddf.metacard"); assertThat((String) typeInfo.get("effective") .get("format"), is("DATE")); assertThat((String) typeInfo.get("modified") .get("format"), is("DATE")); assertThat((String) typeInfo.get("created") .get("format"), is("DATE")); assertThat((String) typeInfo.get("expiration") .get("format"), is("DATE")); assertThat((String) typeInfo.get("id") .get("format"), is("STRING")); assertThat((String) typeInfo.get("title") .get("format"), is("STRING")); assertThat((String) typeInfo.get("metadata-content-type") .get("format"), is("STRING")); assertThat((String) typeInfo.get("metadata-content-type-version") .get("format"), is("STRING")); assertThat((String) typeInfo.get("metadata-target-namespace") .get("format"), is("STRING")); assertThat((String) typeInfo.get("resource-uri") .get("format"), is("STRING")); assertThat((Boolean) typeInfo.get("resource-uri") .get("indexed"), is(true)); // since resource-size is not indexed, it should be filtered out assertThat((Boolean) typeInfo.get("resource-size") .get("indexed"), is(false)); assertThat((String) typeInfo.get("metadata") .get("format"), is("XML")); assertThat((String) typeInfo.get("location") .get("format"), is("GEOMETRY")); } } private CatalogFramework createFramework() { final long COUNT = 2; CatalogFramework framework = mock(CatalogFramework.class); List<Result> results = new ArrayList<Result>(); for (int i = 0; i < COUNT; i++) { Result result = mock(Result.class); MetacardImpl metacard = new MetacardImpl(); metacard.setId("Metacard_" + i); metacard.setTitle("Metacard " + i); metacard.setLocation("POINT(" + i + " " + i + ")"); metacard.setType(BasicTypes.BASIC_METACARD); metacard.setCreatedDate(TIMESTAMP); metacard.setEffectiveDate(TIMESTAMP); metacard.setExpirationDate(TIMESTAMP); metacard.setModifiedDate(TIMESTAMP); metacard.setContentTypeName("TEST"); metacard.setContentTypeVersion("1.0"); metacard.setTargetNamespace(URI.create(getClass().getPackage() .getName())); when(result.getDistanceInMeters()).thenReturn(100.0 * i); when(result.getRelevanceScore()).thenReturn(100.0 * (COUNT - i) / COUNT); when(result.getMetacard()).thenReturn(metacard); results.add(result); } QueryResponse response = new QueryResponseImpl(mock(QueryRequest.class), new ArrayList<Result>(), COUNT); response.getResults() .addAll(results); try { when(framework.query(any(QueryRequest.class))).thenReturn(response); } catch (UnsupportedQueryException e) { LOGGER.debug("Error querying framework", e); } catch (SourceUnavailableException e) { LOGGER.debug("Error querying framework", e); } catch (FederationException e) { LOGGER.debug("Error querying framework", e); } return framework; } /** * Helper methods for generating exception stacks */ private Exception generateExceptionStack(int targetSize) { return generateExceptionStack(targetSize, 0, null); } private Exception generateExceptionStack(int targetSize, int currentSize, @Nullable Exception cause) { Exception e; if (cause == null) { e = new Exception(); } else { e = new Exception(cause); } if (currentSize < targetSize) { return generateExceptionStack(targetSize, currentSize + 1, e); } else { return e; } } /** * Template method for testing exception depth and cause calculations */ private void runTestForExceptionScanDepth(int offset, String expectedReason) throws Exception { Exception e = generateExceptionStack(MAX_EXCEPTION_SCAN_DEPTH - offset, 0, new TimeoutException()); runTestForReasonMessages(new ExecutionException(e), expectedReason); } /** * Template method for testing the JSON responses for reasons */ private void runTestForReasonMessages(Exception exception, String expectedReasonMessage) throws Exception { searchController = new SearchController(framework, new ActionRegistryImpl(Collections.emptyList(), Collections.emptyList()), new GeotoolsFilterAdapterImpl(), mockExecutor); searchController.setBayeuxServer(mockBayeuxServer); ArgumentCaptor<ServerMessage.Mutable> serverMessageCaptor = ArgumentCaptor.forClass( ServerMessage.Mutable.class); when(mockFuture.get(anyLong(), any(TimeUnit.class))).thenThrow(exception); searchController.executeQuery(mockSearchRequest, mockServerSession, null); verify(mockServerChannel, times(3)).publish(any(ServerSession.class), serverMessageCaptor.capture()); validateReasonMessageInJson(serverMessageCaptor.getValue(), expectedReasonMessage); } private void validateReasonMessageInJson(ServerMessage.Mutable response, String expectedReasonMessage) { Map<String, Object> jsonDocument = response.getDataAsMap(); List<Map<String, Object>> statuses = (List<Map<String, Object>>) jsonDocument.get("status"); if (statuses.isEmpty()) { fail("Status list was empty"); } List<String> reasons = (List<String>) statuses.get(0) .get("reasons"); if (reasons.isEmpty()) { fail("Reasons list was empty"); } assertThat(String.format("Unexpected reason: %s", reasons.get(0)), reasons.get(0) .equals(expectedReasonMessage)); } /** * The SearchController spawns off threads to complete tasks. We cannot reliably test * multi-threaded code, so we use this Mock ExecutorService to ensure that all operations * for a test happen in the same thread as each JUnit test case. */ private class SequentialExecutorService implements ExecutorService { @Override public void execute(Runnable command) { submit(command); } @Override public void shutdown() { } @Override public List<Runnable> shutdownNow() { return null; } @Override public boolean isShutdown() { return false; } @Override public boolean isTerminated() { return false; } @Override public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { return false; } @Override public <T> Future<T> submit(final Callable<T> task) { try { return new Future<T>() { @Override public boolean cancel(boolean mayInterruptIfRunning) { return false; } @Override public boolean isCancelled() { return false; } @Override public boolean isDone() { return false; } @Override public T get() throws InterruptedException, ExecutionException { return null; } @Override public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { try { return task.call(); } catch (Exception e) { fail(e.getMessage()); } return null; } }; } catch (Exception e) { return null; } } @Override public <T> Future<T> submit(Runnable task, T result) { try { task.run(); } catch (Throwable t) { return Futures.immediateFailedFuture(t); } return Futures.immediateFuture(result); } @Override public Future<?> submit(Runnable task) { try { task.run(); } catch (Throwable t) { return Futures.immediateFailedFuture(t); } return new Future<Object>() { @Override public boolean cancel(boolean mayInterruptIfRunning) { return false; } @Override public boolean isCancelled() { return false; } @Override public boolean isDone() { return false; } @Override public Object get() throws InterruptedException, ExecutionException { return null; } @Override public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return null; } }; } @Override public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException { return null; } @Override public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException { return null; } @Override public <T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException { return null; } @Override public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return null; } } }