Skip to content

Commit 56cb227

Browse files
Marcinclaude
andcommitted
test: add contract tests against shared mock server
Spawns the mock via ProcessBuilder, points the filter at it with a real HttpClient, and asserts the recorded outgoing request shape (URL, required headers, Int-Type, Int-Version semver, Request-Id UUID format and per-request uniqueness, token omission). CI installs Node alongside JDK and fetches mock-server.mjs from prerender/integration-contract before running mvn test. Jackson added as test-scoped dependency for JSON parsing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 493331b commit 56cb227

4 files changed

Lines changed: 232 additions & 0 deletions

File tree

.github/workflows/pull-request.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,13 @@ jobs:
2222
java-version: ${{ matrix.java-version }}
2323
cache: maven
2424

25+
- name: Setup Node (for contract mock server)
26+
uses: actions/setup-node@v4
27+
with:
28+
node-version: 20.x
29+
30+
- name: Fetch contract mock server
31+
run: curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs
32+
2533
- name: Run tests
2634
run: mvn --batch-mode --no-transfer-progress test

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
target/
2+
*.iml
3+
.idea/
4+
.vscode/
5+
mock-server.mjs

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@
6464
<version>3.5.4</version>
6565
<scope>test</scope>
6666
</dependency>
67+
<dependency>
68+
<groupId>com.fasterxml.jackson.core</groupId>
69+
<artifactId>jackson-databind</artifactId>
70+
<version>2.17.0</version>
71+
<scope>test</scope>
72+
</dependency>
6773
</dependencies>
6874

6975
<build>
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package io.prerender;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import jakarta.servlet.FilterChain;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import org.junit.jupiter.api.AfterAll;
9+
import org.junit.jupiter.api.BeforeAll;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.extension.ExtendWith;
13+
import org.mockito.Mock;
14+
import org.mockito.junit.jupiter.MockitoExtension;
15+
16+
import java.io.PrintWriter;
17+
import java.io.StringWriter;
18+
import java.net.ServerSocket;
19+
import java.net.URI;
20+
import java.net.http.HttpClient;
21+
import java.net.http.HttpRequest;
22+
import java.net.http.HttpResponse;
23+
import java.nio.file.Files;
24+
import java.nio.file.Path;
25+
import java.nio.file.Paths;
26+
import java.util.regex.Pattern;
27+
28+
import static org.junit.jupiter.api.Assertions.*;
29+
import static org.mockito.Mockito.*;
30+
31+
/**
32+
* Contract tests against the shared mock server.
33+
* Spec: https://github.com/prerender/integration-contract
34+
*
35+
* CI fetches mock-server.mjs into the repo root; locally:
36+
* curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs
37+
*/
38+
@ExtendWith(MockitoExtension.class)
39+
class PrerenderFilterContractTest {
40+
41+
private static final String BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1)";
42+
private static final String BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
43+
private static final String TOKEN = "test-token-abc123";
44+
private static final Pattern UUID_V4 = Pattern.compile(
45+
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
46+
Pattern.CASE_INSENSITIVE
47+
);
48+
49+
private static Process mockProcess;
50+
private static String mockUrl;
51+
private static final HttpClient httpClient = HttpClient.newHttpClient();
52+
private static final ObjectMapper mapper = new ObjectMapper();
53+
54+
@Mock private HttpServletRequest request;
55+
@Mock private HttpServletResponse response;
56+
@Mock private FilterChain chain;
57+
58+
private StringWriter responseWriter;
59+
private PrerenderFilter filter;
60+
61+
@BeforeAll
62+
static void startMock() throws Exception {
63+
Path mockPath = Paths.get(System.getProperty(
64+
"mockServerPath",
65+
System.getenv().getOrDefault("MOCK_SERVER_PATH", "mock-server.mjs")
66+
));
67+
if (!Files.exists(mockPath)) {
68+
throw new IllegalStateException(
69+
"mock-server.mjs not found at " + mockPath.toAbsolutePath()
70+
+ "; fetch it via curl from prerender/integration-contract"
71+
);
72+
}
73+
int port;
74+
try (ServerSocket s = new ServerSocket(0)) {
75+
port = s.getLocalPort();
76+
}
77+
ProcessBuilder pb = new ProcessBuilder("node", mockPath.toString())
78+
.redirectErrorStream(true);
79+
pb.environment().put("PORT", String.valueOf(port));
80+
mockProcess = pb.start();
81+
mockUrl = "http://127.0.0.1:" + port;
82+
waitForHealth();
83+
}
84+
85+
@AfterAll
86+
static void stopMock() {
87+
if (mockProcess != null) mockProcess.destroy();
88+
}
89+
90+
@BeforeEach
91+
void resetAndBuildFilter() throws Exception {
92+
httpClient.send(
93+
HttpRequest.newBuilder(URI.create(mockUrl + "/__reset")).POST(HttpRequest.BodyPublishers.noBody()).build(),
94+
HttpResponse.BodyHandlers.discarding()
95+
);
96+
responseWriter = new StringWriter();
97+
lenient().when(response.getWriter()).thenReturn(new PrintWriter(responseWriter));
98+
}
99+
100+
private void useToken(String token) {
101+
filter = new PrerenderFilter(HttpClient.newHttpClient(), new PrerenderConfig(token, mockUrl));
102+
}
103+
104+
private static void waitForHealth() throws Exception {
105+
for (int i = 0; i < 50; i++) {
106+
try {
107+
HttpResponse<String> r = httpClient.send(
108+
HttpRequest.newBuilder(URI.create(mockUrl + "/__health")).GET().build(),
109+
HttpResponse.BodyHandlers.ofString()
110+
);
111+
if (r.statusCode() == 200) return;
112+
} catch (Exception ignored) {}
113+
Thread.sleep(100);
114+
}
115+
throw new IllegalStateException("mock server at " + mockUrl + " did not become ready");
116+
}
117+
118+
private JsonNode recordedRequests() throws Exception {
119+
HttpResponse<String> r = httpClient.send(
120+
HttpRequest.newBuilder(URI.create(mockUrl + "/__requests")).GET().build(),
121+
HttpResponse.BodyHandlers.ofString()
122+
);
123+
return mapper.readTree(r.body());
124+
}
125+
126+
private void stubBotRequest(String uri) {
127+
when(request.getMethod()).thenReturn("GET");
128+
when(request.getRequestURI()).thenReturn(uri);
129+
when(request.getParameter("_escaped_fragment_")).thenReturn(null);
130+
when(request.getHeader("X-Bufferbot")).thenReturn(null);
131+
when(request.getHeader("User-Agent")).thenReturn(BOT_UA);
132+
when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com" + uri));
133+
when(request.getQueryString()).thenReturn(null);
134+
}
135+
136+
@Test
137+
void botRequest_emitsOutgoingRequestWithRequiredHeaders() throws Exception {
138+
useToken(TOKEN);
139+
stubBotRequest("/blog/post-1");
140+
141+
filter.doFilter(request, response, chain);
142+
143+
JsonNode recorded = recordedRequests();
144+
assertEquals(1, recorded.size(), "exactly one request should reach the mock");
145+
JsonNode r = recorded.get(0);
146+
assertEquals("GET", r.get("method").asText());
147+
assertTrue(r.get("url").asText().endsWith("/blog/post-1"));
148+
JsonNode headers = r.get("headers");
149+
assertEquals(BOT_UA, headers.get("user-agent").asText());
150+
assertEquals(TOKEN, headers.get("x-prerender-token").asText());
151+
assertEquals("Java", headers.get("x-prerender-int-type").asText());
152+
assertTrue(
153+
headers.get("x-prerender-int-version").asText().matches("^\\d+\\.\\d+\\.\\d+.*"),
154+
"Int-Version should be semver"
155+
);
156+
assertTrue(
157+
UUID_V4.matcher(headers.get("x-prerender-request-id").asText()).matches(),
158+
"Request-Id should be a UUID v4"
159+
);
160+
}
161+
162+
@Test
163+
void browserRequest_emitsNoOutgoingRequest() throws Exception {
164+
useToken(TOKEN);
165+
when(request.getMethod()).thenReturn("GET");
166+
when(request.getRequestURI()).thenReturn("/");
167+
when(request.getParameter("_escaped_fragment_")).thenReturn(null);
168+
when(request.getHeader("X-Bufferbot")).thenReturn(null);
169+
when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA);
170+
171+
filter.doFilter(request, response, chain);
172+
173+
assertEquals(0, recordedRequests().size());
174+
}
175+
176+
@Test
177+
void staticAsset_emitsNoOutgoingRequest() throws Exception {
178+
useToken(TOKEN);
179+
when(request.getMethod()).thenReturn("GET");
180+
when(request.getRequestURI()).thenReturn("/styles.css");
181+
182+
filter.doFilter(request, response, chain);
183+
184+
assertEquals(0, recordedRequests().size());
185+
}
186+
187+
@Test
188+
void tokenOmitted_whenUnconfigured() throws Exception {
189+
useToken(null);
190+
stubBotRequest("/");
191+
192+
filter.doFilter(request, response, chain);
193+
194+
JsonNode headers = recordedRequests().get(0).get("headers");
195+
assertFalse(headers.has("x-prerender-token"), "X-Prerender-Token must not be sent when unconfigured");
196+
}
197+
198+
@Test
199+
void requestId_isUniquePerOutgoingRequest() throws Exception {
200+
useToken(TOKEN);
201+
stubBotRequest("/");
202+
203+
filter.doFilter(request, response, chain);
204+
filter.doFilter(request, response, chain);
205+
206+
JsonNode recorded = recordedRequests();
207+
assertEquals(2, recorded.size());
208+
assertNotEquals(
209+
recorded.get(0).get("headers").get("x-prerender-request-id").asText(),
210+
recorded.get(1).get("headers").get("x-prerender-request-id").asText()
211+
);
212+
}
213+
}

0 commit comments

Comments
 (0)