Skip to content

Commit 29f9c27

Browse files
jkmasselclaude
andcommitted
test: add instrumented fixture tests for on-device validation
Run the shared JSON test fixtures on an actual Android device via connectedDebugAndroidTest, validating the pure-Kotlin HTTP parser under ART in addition to the JVM unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85c5235 commit 29f9c27

1 file changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
package org.wordpress.gutenberg.http
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import com.google.gson.Gson
6+
import com.google.gson.JsonObject
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Assert.assertNotNull
9+
import org.junit.Assert.assertNull
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Assert.fail
12+
import org.junit.Test
13+
import org.junit.runner.RunWith
14+
import java.util.Base64
15+
16+
/**
17+
* Instrumented fixture tests for the pure-Kotlin HTTP parser.
18+
*
19+
* Runs the same shared JSON test fixtures as the JVM unit tests,
20+
* but executes on an actual Android device/emulator to validate
21+
* the parser under the Android runtime (ART).
22+
*/
23+
@RunWith(AndroidJUnit4::class)
24+
class InstrumentedFixtureTests {
25+
26+
// MARK: - Header Value Fixtures
27+
28+
@Test
29+
fun headerValueExtraction() {
30+
val fixtures = loadFixture("header-value-parsing")
31+
val tests = fixtures.getAsJsonArray("tests")
32+
33+
for (element in tests) {
34+
val test = element.asJsonObject
35+
val description = test.get("description").asString
36+
val parameter = test.get("parameter").asString
37+
val headerValue = test.get("headerValue").asString
38+
val expected = if (test.get("expected").isJsonNull) null else test.get("expected").asString
39+
40+
val result = HeaderValue.extractParameter(parameter, headerValue)
41+
assertEquals("$description: result mismatch", expected, result)
42+
}
43+
}
44+
45+
// MARK: - Request Parsing Fixtures
46+
47+
@Test
48+
fun requestParsingBasicCases() {
49+
val fixtures = loadFixture("request-parsing")
50+
val tests = fixtures.getAsJsonArray("tests")
51+
52+
for (element in tests) {
53+
val test = element.asJsonObject
54+
val description = test.get("description").asString
55+
val input = test.get("input").asString
56+
val expected = test.getAsJsonObject("expected")
57+
58+
val parser: HTTPRequestParser
59+
if (test.has("maxBodySize")) {
60+
val maxBodySize = test.get("maxBodySize").asLong
61+
parser = HTTPRequestParser(maxBodySize)
62+
parser.append(input.toByteArray(Charsets.UTF_8))
63+
} else {
64+
parser = HTTPRequestParser(input)
65+
}
66+
67+
if (test.has("appendAfterComplete")) {
68+
val extra = test.get("appendAfterComplete").asString
69+
parser.append(extra.toByteArray(Charsets.UTF_8))
70+
}
71+
72+
if (expected.has("isComplete") && !expected.get("isComplete").asBoolean &&
73+
expected.has("hasHeaders") && !expected.get("hasHeaders").asBoolean
74+
) {
75+
assertTrue("$description: should not have headers", !parser.state.hasHeaders)
76+
assertNull("$description: parseRequest should return null", parser.parseRequest())
77+
continue
78+
}
79+
80+
val request = parser.parseRequest()
81+
assertNotNull("$description: parseRequest returned null", request)
82+
request!!
83+
84+
if (expected.has("method")) {
85+
assertEquals("$description: method", expected.get("method").asString, request.method)
86+
}
87+
if (expected.has("target")) {
88+
assertEquals("$description: target", expected.get("target").asString, request.target)
89+
}
90+
if (expected.has("isComplete") && expected.get("isComplete").asBoolean) {
91+
assertTrue("$description: isComplete", parser.state.isComplete)
92+
}
93+
if (expected.has("headers")) {
94+
val expectedHeaders = expected.getAsJsonObject("headers")
95+
for (entry in expectedHeaders.entrySet()) {
96+
assertEquals(
97+
"$description: header ${entry.key}",
98+
entry.value.asString,
99+
request.header(entry.key)
100+
)
101+
}
102+
}
103+
if (expected.has("body")) {
104+
if (expected.get("body").isJsonNull) {
105+
assertNull("$description: body should be null", request.body)
106+
} else {
107+
val expectedBody = expected.get("body").asString
108+
assertNotNull("$description: body should not be null", request.body)
109+
assertEquals(
110+
"$description: body content",
111+
expectedBody,
112+
String(request.body!!.readBytes(), Charsets.UTF_8)
113+
)
114+
}
115+
}
116+
}
117+
}
118+
119+
@Test
120+
fun requestParsingErrorCases() {
121+
val fixtures = loadFixture("request-parsing")
122+
val errorTests = fixtures.getAsJsonArray("errorTests")
123+
124+
for (element in errorTests) {
125+
val test = element.asJsonObject
126+
val description = test.get("description").asString
127+
val expected = test.getAsJsonObject("expected")
128+
val expectedError = expected.get("error").asString
129+
130+
val parser: HTTPRequestParser
131+
132+
if (test.has("inputBase64")) {
133+
val base64 = test.get("inputBase64").asString
134+
val data = Base64.getDecoder().decode(base64)
135+
parser = if (test.has("maxBodySize")) {
136+
HTTPRequestParser(test.get("maxBodySize").asLong)
137+
} else {
138+
HTTPRequestParser()
139+
}
140+
parser.append(data)
141+
} else {
142+
val input = test.get("input").asString
143+
if (test.has("maxBodySize")) {
144+
parser = HTTPRequestParser(test.get("maxBodySize").asLong)
145+
parser.append(input.toByteArray(Charsets.UTF_8))
146+
} else {
147+
parser = HTTPRequestParser(input)
148+
}
149+
}
150+
151+
try {
152+
parser.parseRequest()
153+
fail("$description: expected error $expectedError but parsing succeeded")
154+
} catch (e: HTTPRequestParseException) {
155+
assertTrue(
156+
"$description: expected $expectedError but got ${e.error.errorId}",
157+
e.error.errorId.contains(expectedError) || expectedError.contains(e.error.errorId)
158+
)
159+
}
160+
}
161+
}
162+
163+
@Test
164+
fun requestParsingIncrementalCases() {
165+
val fixtures = loadFixture("request-parsing")
166+
val incrementalTests = fixtures.getAsJsonArray("incrementalTests")
167+
168+
for (element in incrementalTests) {
169+
val test = element.asJsonObject
170+
val description = test.get("description").asString
171+
val expected = test.getAsJsonObject("expected")
172+
173+
val parser = HTTPRequestParser()
174+
175+
if (test.has("input") && test.has("chunkSize")) {
176+
val input = test.get("input").asString
177+
val chunkSize = test.get("chunkSize").asInt
178+
val data = input.toByteArray(Charsets.UTF_8)
179+
var i = 0
180+
while (i < data.size) {
181+
val end = minOf(i + chunkSize, data.size)
182+
parser.append(data.copyOfRange(i, end))
183+
i = end
184+
}
185+
} else if (test.has("headers")) {
186+
val headers = test.get("headers").asString
187+
parser.append(headers.toByteArray(Charsets.UTF_8))
188+
189+
if (expected.has("afterHeaders")) {
190+
val afterHeaders = expected.getAsJsonObject("afterHeaders")
191+
if (afterHeaders.has("hasHeaders")) {
192+
assertEquals(
193+
"$description: hasHeaders after headers",
194+
afterHeaders.get("hasHeaders").asBoolean,
195+
parser.state.hasHeaders
196+
)
197+
}
198+
if (afterHeaders.has("isComplete")) {
199+
assertEquals(
200+
"$description: isComplete after headers",
201+
afterHeaders.get("isComplete").asBoolean,
202+
parser.state.isComplete
203+
)
204+
}
205+
if (afterHeaders.has("method") || afterHeaders.has("target")) {
206+
val partialRequest = parser.parseRequest()
207+
assertNotNull("$description: partial request should not be null", partialRequest)
208+
partialRequest!!
209+
if (afterHeaders.has("method")) {
210+
assertEquals(afterHeaders.get("method").asString, partialRequest.method)
211+
}
212+
if (afterHeaders.has("target")) {
213+
assertEquals(afterHeaders.get("target").asString, partialRequest.target)
214+
}
215+
}
216+
}
217+
218+
if (test.has("bodyChunks")) {
219+
for (chunkElement in test.getAsJsonArray("bodyChunks")) {
220+
parser.append(chunkElement.asString.toByteArray(Charsets.UTF_8))
221+
}
222+
}
223+
} else if (test.has("input")) {
224+
parser.append(test.get("input").asString.toByteArray(Charsets.UTF_8))
225+
}
226+
227+
if (expected.has("isComplete") && !expected.get("isComplete").asBoolean &&
228+
expected.has("hasHeaders") && !expected.get("hasHeaders").asBoolean
229+
) {
230+
assertTrue("$description: should not have headers", !parser.state.hasHeaders)
231+
assertNull("$description: parseRequest should return null", parser.parseRequest())
232+
continue
233+
}
234+
235+
val request = parser.parseRequest()
236+
assertNotNull("$description: parseRequest returned null", request)
237+
request!!
238+
239+
if (expected.has("method")) {
240+
assertEquals("$description: method", expected.get("method").asString, request.method)
241+
}
242+
if (expected.has("target")) {
243+
assertEquals("$description: target", expected.get("target").asString, request.target)
244+
}
245+
if (expected.has("isComplete") && expected.get("isComplete").asBoolean) {
246+
assertTrue("$description: isComplete", parser.state.isComplete)
247+
}
248+
if (expected.has("body")) {
249+
if (expected.get("body").isJsonNull) {
250+
assertNull("$description: body should be null", request.body)
251+
} else {
252+
val expectedBody = expected.get("body").asString
253+
assertNotNull("$description: body should not be null", request.body)
254+
assertEquals(
255+
"$description: body content",
256+
expectedBody,
257+
String(request.body!!.readBytes(), Charsets.UTF_8)
258+
)
259+
}
260+
}
261+
}
262+
}
263+
264+
// MARK: - Multipart Parsing Fixtures
265+
266+
@Test
267+
fun multipartParsingCases() {
268+
val fixtures = loadFixture("multipart-parsing")
269+
val tests = fixtures.getAsJsonArray("tests")
270+
271+
for (element in tests) {
272+
val test = element.asJsonObject
273+
val description = test.get("description").asString
274+
val boundary = test.get("boundary").asString
275+
val quotedBoundary = test.has("quotedBoundary") && test.get("quotedBoundary").asBoolean
276+
val rawBody = test.get("rawBody").asString
277+
278+
val request = buildRawMultipartRequest(rawBody, boundary, quotedBoundary)
279+
280+
val expected = test.getAsJsonObject("expected")
281+
if (expected.has("contentType")) {
282+
assertEquals(
283+
"$description: Content-Type",
284+
expected.get("contentType").asString,
285+
request.header("Content-Type")
286+
)
287+
}
288+
289+
val parts = request.multipartParts()
290+
val expectedParts = expected.getAsJsonArray("parts")
291+
assertEquals("$description: part count", expectedParts.size(), parts.size)
292+
293+
for (i in 0 until minOf(expectedParts.size(), parts.size)) {
294+
val exp = expectedParts[i].asJsonObject
295+
val part = parts[i]
296+
assertPart(description, i, exp, part)
297+
}
298+
}
299+
}
300+
301+
@Test
302+
fun multipartParsingErrorCases() {
303+
val fixtures = loadFixture("multipart-parsing")
304+
val errorTests = fixtures.getAsJsonArray("errorTests")
305+
306+
for (element in errorTests) {
307+
val test = element.asJsonObject
308+
val description = test.get("description").asString
309+
val expected = test.getAsJsonObject("expected")
310+
val expectedError = expected?.get("error")?.asString ?: test.get("expectedError").asString
311+
val contentType = test.get("contentType")?.asString ?: expected?.get("contentType")?.asString
312+
313+
val request: ParsedHTTPRequest
314+
315+
if (test.has("rawBody") && test.has("boundary")) {
316+
request = buildRawMultipartRequest(
317+
test.get("rawBody").asString,
318+
test.get("boundary").asString
319+
)
320+
} else if (contentType != null && test.has("body")) {
321+
val body = test.get("body").asString
322+
val raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n" +
323+
"Content-Type: $contentType\r\n" +
324+
"Content-Length: ${body.toByteArray(Charsets.UTF_8).size}\r\n\r\n$body"
325+
val parser = HTTPRequestParser(raw)
326+
val parsed = parser.parseRequest()
327+
assertNotNull("$description: parsing request failed", parsed)
328+
request = parsed!!
329+
} else if (contentType != null) {
330+
val raw = "GET /upload HTTP/1.1\r\nHost: localhost\r\n" +
331+
"Content-Type: $contentType\r\n\r\n"
332+
val parser = HTTPRequestParser(raw)
333+
val parsed = parser.parseRequest()
334+
assertNotNull("$description: parsing request failed", parsed)
335+
request = parsed!!
336+
} else {
337+
fail("$description: invalid error test case")
338+
return
339+
}
340+
341+
try {
342+
request.multipartParts()
343+
fail("$description: expected error $expectedError but succeeded")
344+
} catch (e: MultipartParseException) {
345+
assertTrue(
346+
"$description: expected $expectedError but got ${e.error.errorId}",
347+
e.error.errorId.contains(expectedError) || expectedError.contains(e.error.errorId)
348+
)
349+
}
350+
}
351+
}
352+
353+
// MARK: - Helpers
354+
355+
private fun loadFixture(name: String): JsonObject {
356+
val context = InstrumentationRegistry.getInstrumentation().context
357+
val json = context.assets.open("http/$name.json").bufferedReader().readText()
358+
return Gson().fromJson(json, JsonObject::class.java)
359+
}
360+
361+
private fun assertPart(description: String, i: Int, exp: JsonObject, part: MultipartPart) {
362+
assertEquals("$description: part[$i].name", exp.get("name").asString, part.name)
363+
if (exp.has("filename")) {
364+
if (exp.get("filename").isJsonNull) {
365+
assertNull("$description: part[$i].filename should be null", part.filename)
366+
} else {
367+
assertEquals("$description: part[$i].filename", exp.get("filename").asString, part.filename)
368+
}
369+
}
370+
if (exp.has("contentType")) {
371+
assertEquals("$description: part[$i].contentType", exp.get("contentType").asString, part.contentType)
372+
}
373+
if (exp.has("body")) {
374+
assertEquals(
375+
"$description: part[$i].body",
376+
exp.get("body").asString,
377+
String(part.body.readBytes(), Charsets.UTF_8)
378+
)
379+
}
380+
}
381+
382+
private fun buildRawMultipartRequest(
383+
body: String,
384+
boundary: String,
385+
quotedBoundary: Boolean = false
386+
): ParsedHTTPRequest {
387+
val boundaryParam = if (quotedBoundary) "\"$boundary\"" else boundary
388+
val raw = "POST /wp/v2/media HTTP/1.1\r\nHost: localhost\r\n" +
389+
"Content-Type: multipart/form-data; boundary=$boundaryParam\r\n" +
390+
"Content-Length: ${body.toByteArray(Charsets.UTF_8).size}\r\n\r\n$body"
391+
val parser = HTTPRequestParser(raw)
392+
return parser.parseRequest()!!
393+
}
394+
}

0 commit comments

Comments
 (0)