diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..642c7cc --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,34 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java-version: ['17', '21'] + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + cache: maven + + - name: Setup Node (for contract mock server) + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Fetch contract mock server + run: curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs + + - name: Run tests + run: mvn --batch-mode --no-transfer-progress test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff81f0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +*.iml +.idea/ +.vscode/ +mock-server.mjs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f93d383 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Prerender + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7aca8c7 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# prerender-java + +Jakarta Servlet Filter for [Prerender.io](https://prerender.io). Intercepts requests from bots and crawlers and serves prerendered HTML, so your JavaScript-rendered app is fully indexable by search engines and social media scrapers. + +Compatible with any **Jakarta EE** application server — Tomcat 10+, Jetty 11+, Spring Boot 3+, Quarkus, Micronaut. + +Requires **Java 17+**. + +## Installation + +### Maven + +```xml + + io.prerender + prerender-java + 1.0.0 + +``` + +### Gradle + +```groovy +implementation 'io.prerender:prerender-java:1.0.0' +``` + +## Setup + +### Option 1: Environment variables (recommended) + +```bash +export PRERENDER_TOKEN=your-token +``` + +Register the filter in `web.xml`: + +```xml + + PrerenderFilter + io.prerender.PrerenderFilter + + + PrerenderFilter + /* + +``` + +### Option 2: web.xml init-params + +```xml + + PrerenderFilter + io.prerender.PrerenderFilter + + prerenderToken + your-token + + + + PrerenderFilter + /* + +``` + +### Spring Boot + +```java +@Bean +public FilterRegistrationBean prerenderFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new PrerenderFilter()); + registration.addUrlPatterns("/*"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; +} +``` + +Set `PRERENDER_TOKEN` as an environment variable before starting the app. + +## Settings + +| Setting | Init-param | Env var | Default | +|---------|------------|---------|---------| +| Token | `prerenderToken` | `PRERENDER_TOKEN` | none | +| Service URL | `prerenderServiceUrl` | `PRERENDER_SERVICE_URL` | `https://service.prerender.io/` | + +Init-params take precedence over environment variables. + +## Self-hosted Prerender + +```bash +export PRERENDER_SERVICE_URL=http://your-prerender-server:3000 +``` + +## How it works + +Requests are prerendered when **all** of the following are true: + +- The HTTP method is `GET` +- The `User-Agent` matches a known bot/crawler (Googlebot, Bingbot, Twitterbot, GPTBot, ClaudeBot, etc.) + — OR the URL contains `_escaped_fragment_` + — OR the `X-Bufferbot` header is present +- The URL does not end with a static asset extension (`.js`, `.css`, `.png`, etc.) + +Everything else passes through to your normal servlet chain. + +If the Prerender service is unreachable, the filter falls back gracefully and serves the normal response. + +## License + +MIT diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1ac460d --- /dev/null +++ b/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + io.prerender + prerender-java + 1.0.0 + jar + + prerender-java + Jakarta Servlet Filter for prerendering JavaScript-rendered pages via Prerender.io + https://github.com/prerender/integrations + + + + MIT License + https://opensource.org/licenses/MIT + + + + + scm:git:git@github.com:prerender/integrations.git + scm:git:git@github.com:prerender/integrations.git + https://github.com/prerender/integrations + + + + 17 + 17 + UTF-8 + + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + provided + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.11.0 + test + + + org.mockito + mockito-junit-jupiter + 5.11.0 + test + + + org.wiremock + wiremock + 3.5.4 + test + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/src/main/java/io/prerender/PrerenderConfig.java b/src/main/java/io/prerender/PrerenderConfig.java new file mode 100644 index 0000000..5a85c97 --- /dev/null +++ b/src/main/java/io/prerender/PrerenderConfig.java @@ -0,0 +1,59 @@ +package io.prerender; + +import java.util.List; + +class PrerenderConfig { + + static final List CRAWLER_USER_AGENTS = List.of( + "googlebot", "yahoo", "bingbot", "baiduspider", + "facebookexternalhit", "twitterbot", "rogerbot", "linkedinbot", + "embedly", "quora link preview", "showyoubot", "outbrain", + "pinterest", "slackbot", "w3c_validator", "perplexity", + "oai-searchbot", "chatgpt-user", "gptbot", "claudebot", "amazonbot" + ); + + static final List EXTENSIONS_TO_IGNORE = List.of( + ".js", ".css", ".xml", ".less", ".png", ".jpg", ".jpeg", ".gif", + ".pdf", ".doc", ".txt", ".ico", ".rss", ".zip", ".mp3", ".rar", + ".exe", ".wmv", ".avi", ".ppt", ".mpg", ".mpeg", ".tif", ".wav", + ".mov", ".psd", ".ai", ".xls", ".mp4", ".m4a", ".swf", ".dat", + ".dmg", ".iso", ".flv", ".m4v", ".torrent", ".ttf", ".woff", ".svg" + ); + + private static final String DEFAULT_SERVICE_URL = "https://service.prerender.io/"; + + private final String token; + private final String serviceUrl; + + PrerenderConfig(String token, String serviceUrl) { + this.token = token; + this.serviceUrl = (serviceUrl != null && !serviceUrl.isBlank()) + ? serviceUrl + : DEFAULT_SERVICE_URL; + } + + static PrerenderConfig fromInitParams(String initToken, String initServiceUrl) { + return new PrerenderConfig( + resolve(initToken, "PRERENDER_TOKEN"), + resolve(initServiceUrl, "PRERENDER_SERVICE_URL") + ); + } + + private static String resolve(String initParam, String envVar) { + return (initParam != null && !initParam.isBlank()) ? initParam : System.getenv(envVar); + } + + String getToken() { return token; } + + String getServiceUrl() { return serviceUrl; } + + static boolean isBot(String userAgent) { + String ua = userAgent.toLowerCase(); + return CRAWLER_USER_AGENTS.stream().anyMatch(ua::contains); + } + + static boolean isStaticAsset(String path) { + String lower = path.toLowerCase(); + return EXTENSIONS_TO_IGNORE.stream().anyMatch(lower::endsWith); + } +} diff --git a/src/main/java/io/prerender/PrerenderFilter.java b/src/main/java/io/prerender/PrerenderFilter.java new file mode 100644 index 0000000..c46846a --- /dev/null +++ b/src/main/java/io/prerender/PrerenderFilter.java @@ -0,0 +1,111 @@ +package io.prerender; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class PrerenderFilter implements Filter { + + public static final String VERSION = "1.0.0"; + + private static final Logger logger = Logger.getLogger(PrerenderFilter.class.getName()); + + private HttpClient httpClient; + private PrerenderConfig config; + + public PrerenderFilter() {} + + PrerenderFilter(HttpClient httpClient, PrerenderConfig config) { + this.httpClient = httpClient; + this.config = config; + } + + @Override + public void init(FilterConfig filterConfig) { + this.httpClient = HttpClient.newHttpClient(); + this.config = PrerenderConfig.fromInitParams( + filterConfig.getInitParameter("prerenderToken"), + filterConfig.getInitParameter("prerenderServiceUrl") + ); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpReq = (HttpServletRequest) request; + HttpServletResponse httpRes = (HttpServletResponse) response; + + if (!shouldPrerender(httpReq)) { + chain.doFilter(request, response); + return; + } + + try { + sendPrerendered(httpReq, httpRes); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + chain.doFilter(request, response); + } catch (IOException e) { + logger.log(Level.WARNING, "Prerender service unreachable, falling back", e); + chain.doFilter(request, response); + } + } + + @Override + public void destroy() {} + + private boolean shouldPrerender(HttpServletRequest request) { + if (!"GET".equalsIgnoreCase(request.getMethod())) return false; + if (PrerenderConfig.isStaticAsset(request.getRequestURI())) return false; + if (request.getParameter("_escaped_fragment_") != null) return true; + if (request.getHeader("X-Bufferbot") != null) return true; + String ua = request.getHeader("User-Agent"); + return ua != null && !ua.isBlank() && PrerenderConfig.isBot(ua); + } + + private void sendPrerendered(HttpServletRequest request, HttpServletResponse response) + throws IOException, InterruptedException { + HttpResponse prerenderResponse = httpClient.send( + buildPrerenderRequest(buildApiUrl(request), request.getHeader("User-Agent")), + HttpResponse.BodyHandlers.ofString() + ); + response.setStatus(prerenderResponse.statusCode()); + response.getWriter().write(prerenderResponse.body()); + } + + private String buildApiUrl(HttpServletRequest request) { + String serviceUrl = config.getServiceUrl(); + if (!serviceUrl.endsWith("/")) serviceUrl += "/"; + String url = request.getRequestURL().toString(); + String qs = request.getQueryString(); + return serviceUrl + (qs != null && !qs.isBlank() ? url + "?" + qs : url); + } + + private HttpRequest buildPrerenderRequest(String apiUrl, String userAgent) { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .header("User-Agent", userAgent != null ? userAgent : "") + .GET(); + if (config.getToken() != null && !config.getToken().isBlank()) { + builder.header("X-Prerender-Token", config.getToken()); + } + builder.header("X-Prerender-Int-Type", "Java"); + builder.header("X-Prerender-Int-Version", VERSION); + builder.header("X-Prerender-Request-Id", UUID.randomUUID().toString()); + return builder.build(); + } +} diff --git a/src/test/java/io/prerender/PrerenderFilterContractTest.java b/src/test/java/io/prerender/PrerenderFilterContractTest.java new file mode 100644 index 0000000..4282ab8 --- /dev/null +++ b/src/test/java/io/prerender/PrerenderFilterContractTest.java @@ -0,0 +1,213 @@ +package io.prerender; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Contract tests against the shared mock server. + * Spec: https://github.com/prerender/integration-contract + * + * CI fetches mock-server.mjs into the repo root; locally: + * curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs + */ +@ExtendWith(MockitoExtension.class) +class PrerenderFilterContractTest { + + private static final String BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1)"; + private static final String BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"; + private static final String TOKEN = "test-token-abc123"; + private static final Pattern UUID_V4 = Pattern.compile( + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + Pattern.CASE_INSENSITIVE + ); + + private static Process mockProcess; + private static String mockUrl; + private static final HttpClient httpClient = HttpClient.newHttpClient(); + private static final ObjectMapper mapper = new ObjectMapper(); + + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + @Mock private FilterChain chain; + + private StringWriter responseWriter; + private PrerenderFilter filter; + + @BeforeAll + static void startMock() throws Exception { + Path mockPath = Paths.get(System.getProperty( + "mockServerPath", + System.getenv().getOrDefault("MOCK_SERVER_PATH", "mock-server.mjs") + )); + if (!Files.exists(mockPath)) { + throw new IllegalStateException( + "mock-server.mjs not found at " + mockPath.toAbsolutePath() + + "; fetch it via curl from prerender/integration-contract" + ); + } + int port; + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + ProcessBuilder pb = new ProcessBuilder("node", mockPath.toString()) + .redirectErrorStream(true); + pb.environment().put("PORT", String.valueOf(port)); + mockProcess = pb.start(); + mockUrl = "http://127.0.0.1:" + port; + waitForHealth(); + } + + @AfterAll + static void stopMock() { + if (mockProcess != null) mockProcess.destroy(); + } + + @BeforeEach + void resetAndBuildFilter() throws Exception { + httpClient.send( + HttpRequest.newBuilder(URI.create(mockUrl + "/__reset")).POST(HttpRequest.BodyPublishers.noBody()).build(), + HttpResponse.BodyHandlers.discarding() + ); + responseWriter = new StringWriter(); + lenient().when(response.getWriter()).thenReturn(new PrintWriter(responseWriter)); + } + + private void useToken(String token) { + filter = new PrerenderFilter(HttpClient.newHttpClient(), new PrerenderConfig(token, mockUrl)); + } + + private static void waitForHealth() throws Exception { + for (int i = 0; i < 50; i++) { + try { + HttpResponse r = httpClient.send( + HttpRequest.newBuilder(URI.create(mockUrl + "/__health")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + if (r.statusCode() == 200) return; + } catch (Exception ignored) {} + Thread.sleep(100); + } + throw new IllegalStateException("mock server at " + mockUrl + " did not become ready"); + } + + private JsonNode recordedRequests() throws Exception { + HttpResponse r = httpClient.send( + HttpRequest.newBuilder(URI.create(mockUrl + "/__requests")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + return mapper.readTree(r.body()); + } + + private void stubBotRequest(String uri) { + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn(uri); + when(request.getParameter("_escaped_fragment_")).thenReturn(null); + when(request.getHeader("X-Bufferbot")).thenReturn(null); + when(request.getHeader("User-Agent")).thenReturn(BOT_UA); + when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com" + uri)); + when(request.getQueryString()).thenReturn(null); + } + + @Test + void botRequest_emitsOutgoingRequestWithRequiredHeaders() throws Exception { + useToken(TOKEN); + stubBotRequest("/blog/post-1"); + + filter.doFilter(request, response, chain); + + JsonNode recorded = recordedRequests(); + assertEquals(1, recorded.size(), "exactly one request should reach the mock"); + JsonNode r = recorded.get(0); + assertEquals("GET", r.get("method").asText()); + assertTrue(r.get("url").asText().endsWith("/blog/post-1")); + JsonNode headers = r.get("headers"); + assertEquals(BOT_UA, headers.get("user-agent").asText()); + assertEquals(TOKEN, headers.get("x-prerender-token").asText()); + assertEquals("Java", headers.get("x-prerender-int-type").asText()); + assertTrue( + headers.get("x-prerender-int-version").asText().matches("^\\d+\\.\\d+\\.\\d+.*"), + "Int-Version should be semver" + ); + assertTrue( + UUID_V4.matcher(headers.get("x-prerender-request-id").asText()).matches(), + "Request-Id should be a UUID v4" + ); + } + + @Test + void browserRequest_emitsNoOutgoingRequest() throws Exception { + useToken(TOKEN); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/"); + when(request.getParameter("_escaped_fragment_")).thenReturn(null); + when(request.getHeader("X-Bufferbot")).thenReturn(null); + when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA); + + filter.doFilter(request, response, chain); + + assertEquals(0, recordedRequests().size()); + } + + @Test + void staticAsset_emitsNoOutgoingRequest() throws Exception { + useToken(TOKEN); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/styles.css"); + + filter.doFilter(request, response, chain); + + assertEquals(0, recordedRequests().size()); + } + + @Test + void tokenOmitted_whenUnconfigured() throws Exception { + useToken(null); + stubBotRequest("/"); + + filter.doFilter(request, response, chain); + + JsonNode headers = recordedRequests().get(0).get("headers"); + assertFalse(headers.has("x-prerender-token"), "X-Prerender-Token must not be sent when unconfigured"); + } + + @Test + void requestId_isUniquePerOutgoingRequest() throws Exception { + useToken(TOKEN); + stubBotRequest("/"); + + filter.doFilter(request, response, chain); + filter.doFilter(request, response, chain); + + JsonNode recorded = recordedRequests(); + assertEquals(2, recorded.size()); + assertNotEquals( + recorded.get(0).get("headers").get("x-prerender-request-id").asText(), + recorded.get(1).get("headers").get("x-prerender-request-id").asText() + ); + } +} diff --git a/src/test/java/io/prerender/PrerenderFilterTest.java b/src/test/java/io/prerender/PrerenderFilterTest.java new file mode 100644 index 0000000..3cd41ff --- /dev/null +++ b/src/test/java/io/prerender/PrerenderFilterTest.java @@ -0,0 +1,163 @@ +package io.prerender; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.http.HttpClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PrerenderFilterTest { + + private static final String BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1)"; + private static final String BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"; + private static final String PRERENDERED_HTML = "prerendered"; + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + @Mock private FilterChain chain; + + private StringWriter responseWriter; + private PrerenderFilter filter; + + @BeforeEach + void setUp() throws Exception { + wireMock.resetAll(); + responseWriter = new StringWriter(); + lenient().when(response.getWriter()).thenReturn(new PrintWriter(responseWriter)); + PrerenderConfig config = new PrerenderConfig(null, "http://localhost:" + wireMock.getPort()); + filter = new PrerenderFilter(HttpClient.newHttpClient(), config); + } + + @Test + void browserRequest_passesThrough() throws Exception { + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/"); + when(request.getParameter("_escaped_fragment_")).thenReturn(null); + when(request.getHeader("X-Bufferbot")).thenReturn(null); + when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(response, never()).setStatus(anyInt()); + } + + @Test + void botRequest_receivesPrerenderedResponse() throws Exception { + wireMock.stubFor(get(anyUrl()) + .willReturn(aResponse().withStatus(200).withBody(PRERENDERED_HTML))); + + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/"); + when(request.getParameter("_escaped_fragment_")).thenReturn(null); + when(request.getHeader("X-Bufferbot")).thenReturn(null); + when(request.getHeader("User-Agent")).thenReturn(BOT_UA); + when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/")); + when(request.getQueryString()).thenReturn(null); + + filter.doFilter(request, response, chain); + + verify(response).setStatus(200); + verify(chain, never()).doFilter(any(), any()); + assertEquals(PRERENDERED_HTML, responseWriter.toString()); + } + + @Test + void botRequest_staticAsset_passesThrough() throws Exception { + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/styles.css"); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(response, never()).setStatus(anyInt()); + } + + @Test + void escapedFragment_triggersPrerender() throws Exception { + wireMock.stubFor(get(anyUrl()) + .willReturn(aResponse().withStatus(200).withBody(PRERENDERED_HTML))); + + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/"); + when(request.getParameter("_escaped_fragment_")).thenReturn(""); + when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA); + when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/")); + when(request.getQueryString()).thenReturn("_escaped_fragment_="); + + filter.doFilter(request, response, chain); + + verify(response).setStatus(200); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + void xBufferbot_triggersPrerender() throws Exception { + wireMock.stubFor(get(anyUrl()) + .willReturn(aResponse().withStatus(200).withBody(PRERENDERED_HTML))); + + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/"); + when(request.getParameter("_escaped_fragment_")).thenReturn(null); + when(request.getHeader("X-Bufferbot")).thenReturn("true"); + when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA); + when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/")); + when(request.getQueryString()).thenReturn(null); + + filter.doFilter(request, response, chain); + + verify(response).setStatus(200); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + void postRequest_passesThrough() throws Exception { + when(request.getMethod()).thenReturn("POST"); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(response, never()).setStatus(anyInt()); + } + + @Test + void networkError_fallsBackToNormalResponse() throws Exception { + wireMock.stubFor(get(anyUrl()) + .willReturn(aResponse().withFault(com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER))); + + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/"); + when(request.getParameter("_escaped_fragment_")).thenReturn(null); + when(request.getHeader("X-Bufferbot")).thenReturn(null); + when(request.getHeader("User-Agent")).thenReturn(BOT_UA); + when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/")); + when(request.getQueryString()).thenReturn(null); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + } +}