// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.search.handler; import com.yahoo.container.Container; import com.yahoo.container.core.config.testutil.HandlersConfigurerTestWrapper; import com.yahoo.container.jdisc.HttpRequest; import com.yahoo.container.jdisc.HttpResponse; import com.yahoo.container.jdisc.RequestHandlerTestDriver; import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; import com.yahoo.io.IOUtils; import com.yahoo.jdisc.Request; import com.yahoo.jdisc.handler.RequestHandler; import com.yahoo.jdisc.test.MockMetric; import com.yahoo.net.HostName; import com.yahoo.search.Query; import com.yahoo.search.Result; import com.yahoo.search.Searcher; import com.yahoo.search.grouping.result.Group; import com.yahoo.search.grouping.result.RootId; import com.yahoo.search.rendering.XmlRenderer; import com.yahoo.search.result.ErrorMessage; import com.yahoo.search.result.Hit; import com.yahoo.search.result.Relevance; import com.yahoo.search.searchchain.Execution; import com.yahoo.search.searchchain.config.test.SearchChainConfigurerTestCase; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.net.URI; import java.util.concurrent.Executors; import static com.yahoo.yolean.Exceptions.uncheckInterrupted; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; /** * @author bratseth */ public class SearchHandlerTest { private static final String testDir = "src/test/java/com/yahoo/search/handler/test/config"; private static final String myHostnameHeader = "my-hostname-header"; private static final String selfHostname = HostName.getLocalhost(); private static String tempDir = ""; @TempDir public File tempfolder; private RequestHandlerTestDriver driver = null; private HandlersConfigurerTestWrapper configurer = null; private MockMetric metric; private SearchHandler searchHandler; @BeforeEach public void startUp() throws IOException { File cfgDir = newFolder(tempfolder, "SearchHandlerTestCase"); tempDir = cfgDir.getAbsolutePath(); String configId = "dir:" + tempDir; IOUtils.copyDirectory(new File(testDir), cfgDir, 1); // make configs active generateComponentsConfigForActive(); configurer = new HandlersConfigurerTestWrapper(new Container(), configId); searchHandler = (SearchHandler)configurer.getRequestHandlerRegistry().getComponent(SearchHandler.class.getName()); metric = (MockMetric) searchHandler.metric(); driver = new RequestHandlerTestDriver(searchHandler); } @AfterEach public void shutDown() { if (configurer != null) configurer.shutdown(); if (driver != null) driver.close(); } private void generateComponentsConfigForActive() throws IOException { File activeConfig = new File(tempDir); SearchChainConfigurerTestCase. createComponentsConfig(new File(activeConfig, "chains.cfg").getPath(), new File(activeConfig, "handlers.cfg").getPath(), new File(activeConfig, "components.cfg").getPath()); } private SearchHandler fetchSearchHandler(HandlersConfigurerTestWrapper configurer) { return (SearchHandler) configurer.getRequestHandlerRegistry().getComponent(SearchHandler.class.getName()); } @Test void testNullQuery() { assertEquals("\n" + "\n" + " \n" + " 1.0\n" + " testHit\n" + " \n" + "\n", driver.sendRequest("http://localhost?format=xml").readAll() ); } @Test void testFailing() { assertTrue(driver.sendRequest("http://localhost?query=test&searchChain=classLoadingError").readAll().contains("NoClassDefFoundError")); } @Test void testTimeout() { // 1µs is truncated to 0ms, so this will always time out. assertTrue(driver.sendRequest("http://localhost?query=test&timeout=1µs").readAll().contains("Timed out")); } @Test synchronized void testPluginError() { assertTrue(driver.sendRequest("http://localhost?query=test&searchChain=exceptionInPlugin").readAll().contains("NullPointerException")); } @Test synchronized void testWorkingReconfiguration() throws Exception { assertJsonResult("http://localhost?query=abc", driver); // reconfiguration IOUtils.copyDirectory(new File(testDir, "handlers2"), new File(tempDir), 1); generateComponentsConfigForActive(); configurer.reloadConfig(); // ...and check the resulting config SearchHandler newSearchHandler = fetchSearchHandler(configurer); assertNotSame(searchHandler, newSearchHandler, "Have a new instance of the search handler"); assertNotNull(fetchSearchHandler(configurer).getSearchChainRegistry().getChain("hello"), "Have the new search chain"); assertNull(fetchSearchHandler(configurer).getSearchChainRegistry().getChain("classLoadingError"), "Don't have the new search chain"); try (RequestHandlerTestDriver newDriver = new RequestHandlerTestDriver(newSearchHandler)) { assertJsonResult("http://localhost?query=abc", newDriver); } } @Test @Disabled //TODO: Must be done at the ConfiguredApplication level, not handlers configurer? Also, this must be rewritten as the above synchronized void testFailedReconfiguration() throws Exception { assertXmlResult(driver); // attempt reconfiguration IOUtils.copyDirectory(new File(testDir, "handlersInvalid"), new File(tempDir), 1); generateComponentsConfigForActive(); configurer.reloadConfig(); SearchHandler newSearchHandler = fetchSearchHandler(configurer); RequestHandler newMockHandler = configurer.getRequestHandlerRegistry().getComponent("com.yahoo.search.handler.test.MockHandler"); assertSame(searchHandler, newSearchHandler, "Reconfiguration failed: Kept the existing instance of the search handler"); assertNull(newMockHandler, "Reconfiguration failed: No mock handler"); try (RequestHandlerTestDriver newDriver = new RequestHandlerTestDriver(searchHandler)) { assertXmlResult(newDriver); } } @Test void testResponseBasics() { Query q = new Query("?query=dummy&tracelevel=3"); q.trace("nalle", 1); Result r = new Result(q); r.hits().addError(ErrorMessage.createUnspecifiedError("bamse")); r.hits().add(new Hit("http://localhost/dummy", 0.5)); HttpSearchResponse s = new HttpSearchResponse(200, r, q, new XmlRenderer()); assertEquals("text/xml", s.getContentType()); assertNull(s.getCoverage()); assertEquals("query 'WEAKAND(100) dummy'", s.getParsedQuery()); assertEquals(500, s.getTiming().getTimeout()); } @Test void testInvalidYqlQuery() throws Exception { IOUtils.copyDirectory(new File(testDir, "config_yql"), new File(tempDir), 1); generateComponentsConfigForActive(); configurer.reloadConfig(); SearchHandler newSearchHandler = fetchSearchHandler(configurer); assertNotSame(searchHandler, newSearchHandler, "Have a new instance of the search handler"); try (RequestHandlerTestDriver newDriver = new RequestHandlerTestDriver(newSearchHandler)) { RequestHandlerTestDriver.MockResponseHandler responseHandler = newDriver.sendRequest( "http://localhost/search/?yql=select%20*%20from%20foo%20where%20bar%20%3E%201453501295%27%3B"); responseHandler.readAll(); assertEquals(400, responseHandler.getStatus()); assertEquals(Request.RequestType.READ, responseHandler.getResponse().getRequestType()); } } @Test void testRequestType() throws Exception { IOUtils.copyDirectory(new File(testDir, "config_yql"), new File(tempDir), 1); generateComponentsConfigForActive(); configurer.reloadConfig(); SearchHandler newSearchHandler = fetchSearchHandler(configurer); try (RequestHandlerTestDriver newDriver = new RequestHandlerTestDriver(newSearchHandler)) { RequestHandlerTestDriver.MockResponseHandler responseHandler = newDriver.sendRequest( "http://localhost/search/?query=foo"); responseHandler.readAll(); assertEquals(Request.RequestType.READ, responseHandler.getResponse().getRequestType()); } } // Query handling takes a different code path when a query profile is active, so we test both paths. @Test void testInvalidQueryParamWithQueryProfile() throws Exception { try (RequestHandlerTestDriver newDriver = driverWithConfig("config_invalid_param")) { testInvalidQueryParam(newDriver); } } @Test void testInvalidQueryParamWithoutQueryProfile() { testInvalidQueryParam(driver); } private void testInvalidQueryParam(final RequestHandlerTestDriver testDriver) { RequestHandlerTestDriver.MockResponseHandler responseHandler = testDriver.sendRequest("http://localhost/search/?query=status_code%3A0&hits=20&offset=-20"); String response = responseHandler.readAll(); assertEquals(400, responseHandler.getStatus()); assertTrue(response.contains("offset")); assertTrue(response.contains("\"code\":" + com.yahoo.container.protect.Error.ILLEGAL_QUERY.code)); } @Test void testResultStatus() { assertEquals(200, httpStatus(result().build())); assertEquals(200, httpStatus(result().withHit().build())); assertEquals(200, httpStatus(result().withGroups().build())); assertEquals(200, httpStatus(result().withGroups().withHit().build())); assertEquals(500, httpStatus(result().withError().build())); assertEquals(200, httpStatus(result().withError().withHit().build())); assertEquals(200, httpStatus(result().withError().withGroups().build())); assertEquals(200, httpStatus(result().withError().withGroups().withHit().build())); } @Test void testWebServiceStatus() { RequestHandlerTestDriver.MockResponseHandler responseHandler = driver.sendRequest("http://localhost/search/?query=web_service_status_code"); String response = responseHandler.readAll(); assertEquals(406, responseHandler.getStatus()); assertTrue(response.contains("\"code\":" + 406)); } @Test void testNormalResultImplicitDefaultRendering() { assertJsonResult("http://localhost?query=abc", driver); } @Test void testNormalResultExplicitDefaultRendering() { assertJsonResult("http://localhost?query=abc&format=default", driver); } @Test void testNormalResultXmlAliasRendering() { assertXmlResult("http://localhost?query=abc&format=xml", driver); } @Test void testNormalResultJsonAliasRendering() { assertJsonResult("http://localhost?query=abc&format=json", driver); } @Test void testNormalResultExplicitDefaultRenderingFullRendererName1() { assertXmlResult("http://localhost?query=abc&format=XmlRenderer", driver); } @Test void testNormalResultExplicitDefaultRenderingFullRendererName2() { assertJsonResult("http://localhost?query=abc&format=JsonRenderer", driver); } private static final String xmlResult = "\n" + "\n" + " \n" + " 1.0\n" + " testHit\n" + " \n" + "\n"; private void assertXmlResult(String request, RequestHandlerTestDriver driver) { assertOkResult(driver.sendRequest(request), xmlResult); } private void assertXmlResult(RequestHandlerTestDriver driver) { assertXmlResult("http://localhost?query=abc", driver); } private static final String jsonResult = "{\"root\":{" + "\"id\":\"toplevel\",\"relevance\":1.0,\"fields\":{\"totalCount\":0}," + "\"children\":[" + "{\"id\":\"testHit\",\"relevance\":1.0,\"fields\":{\"uri\":\"testHit\"}}" + "]}}"; private void assertJsonResult(String request, RequestHandlerTestDriver driver) { assertOkResult(driver.sendRequest(request), jsonResult); } private void assertMetricPresent(String key) { for (int i = 0; i < 200; i++) { if (metric.metrics().containsKey(key)) return; uncheckInterrupted(() -> Thread.sleep(1)); } fail(String.format("Could not find metric with key '%s' in '%s'", key, metric)); } private void assertOkResult(RequestHandlerTestDriver.MockResponseHandler response, String expected) { assertEquals(expected, response.readAll()); assertEquals(200, response.getStatus()); assertEquals(selfHostname, response.getResponse().headers().get(myHostnameHeader).get(0)); assertMetricPresent(SearchHandler.RENDER_LATENCY_METRIC); } @Test void testFaultyHandlers() throws Exception { assertHandlerResponse(500, null, "NullReturning"); assertHandlerResponse(500, null, "NullReturningAsync"); assertHandlerResponse(500, null, "Throwing"); assertHandlerResponse(500, null, "ThrowingAsync"); } @Test void testForwardingHandlers() throws Exception { assertHandlerResponse(200, jsonResult, "ForwardingAsync"); // Fails because we are forwarding from a sync to an async handler - // the sync handler will respond with status 500 because the async one has not produced a response yet. // Disabled because this fails due to a race and is therefore unstable // assertHandlerResponse(500, null, "Forwarding"); } private void assertHandlerResponse(int status, String responseData, String handlerName) throws Exception { RequestHandler forwardingHandler = configurer.getRequestHandlerRegistry().getComponent("com.yahoo.search.handler.SearchHandlerTest$" + handlerName + "Handler"); try (RequestHandlerTestDriver forwardingDriver = new RequestHandlerTestDriver(forwardingHandler)) { RequestHandlerTestDriver.MockResponseHandler response = forwardingDriver.sendRequest("http://localhost/" + handlerName + "?query=test"); response.awaitResponse(); assertEquals(status, response.getStatus(), "Expected HTTP status"); if (responseData == null) assertNull(response.read(), "Connection closed with no data"); else assertEquals(responseData, response.readAll()); } } private RequestHandlerTestDriver driverWithConfig(String configDirectory) throws Exception { IOUtils.copyDirectory(new File(testDir, configDirectory), new File(tempDir), 1); generateComponentsConfigForActive(); configurer.reloadConfig(); SearchHandler newSearchHandler = fetchSearchHandler(configurer); assertNotSame(searchHandler, newSearchHandler, "Should have a new instance of the search handler"); return new RequestHandlerTestDriver(newSearchHandler); } private int httpStatus(Result result) { var jDiscRequest = com.yahoo.jdisc.http.HttpRequest.newServerRequest(driver.jDiscDriver(), URI.create("ignored"), com.yahoo.jdisc.http.HttpRequest.Method.GET); try { var request = new HttpRequest(jDiscRequest, new ByteArrayInputStream(new byte[0])); return SearchHandler.getHttpResponseStatus(request, result); } finally { jDiscRequest.release(); } } /** Referenced from config */ public static class TestSearcher extends Searcher { @Override public Result search(Query query, Execution execution) { Result result = new Result(query); Hit hit = new Hit("testHit"); hit.setField("uri", "testHit"); result.hits().add(hit); if (query.getModel().getQueryString().contains("web_service_status_code")) result.hits().addError(new ErrorMessage(406, "Test web service code")); return result; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class ClassLoadingErrorSearcher extends Searcher { @Override public Result search(Query query, Execution execution) { throw new NoClassDefFoundError(); // Simulate typical OSGi problem } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class ExceptionInPluginSearcher extends Searcher { @Override public Result search(Query query, Execution execution) { Result result = execution.search(query); try { result.hits().add(null); // Trigger NullPointerException } catch (NullPointerException e) { throw new RuntimeException("Message", e); } return result; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class HelloWorldSearcher extends Searcher { @Override public Result search(Query query, Execution execution) { Result result = execution.search(query); result.hits().add(new Hit("HelloWorld")); return result; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class EchoingQuerySearcher extends Searcher { @Override public Result search(Query query, Execution execution) { Result result = execution.search(query); Hit hit = new Hit("Query"); hit.setField("query", query.yqlRepresentation()); result.hits().add(hit); return result; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class ForwardingHandler extends ThreadedHttpRequestHandler { private final SearchHandler searchHandler; public ForwardingHandler(SearchHandler searchHandler) { super(Executors.newSingleThreadExecutor(), null, false); this.searchHandler = searchHandler; } @Override public HttpResponse handle(HttpRequest httpRequest) { try { HttpRequest forwardRequest = new HttpRequest.Builder(httpRequest).uri(new URI("http://localhost/search/?query=test")).createDirectRequest(); return searchHandler.handle(forwardRequest); } catch (Exception e) { throw new RuntimeException(e); } } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class ForwardingAsyncHandler extends ThreadedHttpRequestHandler { private final SearchHandler searchHandler; public ForwardingAsyncHandler(SearchHandler searchHandler) { super(Executors.newSingleThreadExecutor(), null, true); this.searchHandler = searchHandler; } @Override public HttpResponse handle(HttpRequest httpRequest) { try { HttpRequest forwardRequest = new HttpRequest.Builder(httpRequest).uri(new URI("http://localhost/search/?query=test")).createDirectRequest(); return searchHandler.handle(forwardRequest); } catch (Exception e) { throw new RuntimeException(e); } } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class NullReturningHandler extends ThreadedHttpRequestHandler { public NullReturningHandler() { super(Executors.newSingleThreadExecutor(), null, false); } @Override public HttpResponse handle(HttpRequest httpRequest) { return null; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class NullReturningAsyncHandler extends ThreadedHttpRequestHandler { public NullReturningAsyncHandler() { super(Executors.newSingleThreadExecutor(), null, true); } @Override public HttpResponse handle(HttpRequest httpRequest) { return null; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class ThrowingHandler extends ThreadedHttpRequestHandler { public ThrowingHandler() { super(Executors.newSingleThreadExecutor(), null, false); } @Override public HttpResponse handle(HttpRequest httpRequest) { throw new RuntimeException(); } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } /** Referenced from config */ public static class ThrowingAsyncHandler extends ThreadedHttpRequestHandler { public ThrowingAsyncHandler() { super(Executors.newSingleThreadExecutor(), null, true); } @Override public HttpResponse handle(HttpRequest httpRequest) { throw new RuntimeException(); } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } private ResultBuilder result() { return new ResultBuilder(); } private static class ResultBuilder { Result result = new Result(new Query()); public ResultBuilder withHit() { result.hits().add(new Hit("regularHit:1")); return this; } public ResultBuilder withGroups() { result.hits().add(new Group(new RootId(1), new Relevance(1.0))); return this; } public ResultBuilder withError() { result.hits().addError(ErrorMessage.createUnspecifiedError("Test error")); return this; } public Result build() { return result; } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } } private static File newFolder(File root, String... subDirs) throws IOException { String subFolder = String.join("/", subDirs); File result = new File(root, subFolder); if (!result.mkdirs()) { throw new IOException("Couldn't create folders " + root); } return result; } }