Copilot commented on code in PR #15478:
URL: https://github.com/apache/grails-core/pull/15478#discussion_r2890562593
##########
grails-testing-support-httpclient/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule:
##########
@@ -0,0 +1,3 @@
+moduleName=grails-testing-support-httpclient
+moduleVersion=1.0
+extensionClasses=org.apache.grails.testing.httpclient.HttpResponseExtensions
Review Comment:
`moduleVersion` is hard-coded to `1.0` even though the Gradle project
version is `projectVersion`. This will quickly drift and makes it harder to
diagnose which extension module version is loaded. Consider generating this
file during `processResources` (expanding `${projectVersion}`) so the module
descriptor stays in sync with the published artifact version.
##########
grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/api/NamespacedBookSpec.groovy:
##########
@@ -16,142 +16,103 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package functional.tests.api
-import com.fasterxml.jackson.databind.ObjectMapper
-import functional.tests.Application
-import functional.tests.HttpClientSpec
-import grails.testing.mixin.integration.Integration
-import grails.testing.spock.RunOnce
-import grails.web.http.HttpHeaders
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
-import io.micronaut.http.MediaType
-import org.junit.jupiter.api.BeforeEach
import spock.lang.Issue
-import spock.lang.Shared
-
-@Integration(applicationClass = Application)
-class NamespacedBookSpec extends HttpClientSpec {
-
- @Shared
- ObjectMapper objectMapper
+import spock.lang.Specification
- def setup() {
- objectMapper = new ObjectMapper()
- }
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.httpclient.HttpClientSupport
- @RunOnce
- @BeforeEach
- void init() {
- super.init()
- }
+@Integration
+class NamespacedBookSpec extends Specification implements HttpClientSupport {
void 'test view rendering with a namespace'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse is correct'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ def response = http('/api/book')
+
+ then: 'The response is correct'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced)',
+ title: 'API - The Shining'
+ ])
Review Comment:
This `expectJson(...)` call order doesn’t match the currently defined
`HttpResponseExtensions` overloads (they take the headers `Map` first and
`status` after, e.g. `expectJson(Map headers, int status, Map body)`). As
written, this will not resolve to an extension method and will fail to compile
unless you add status-first overloads. Either reorder arguments at the call
site, or add overloads in `HttpResponseExtensions` that accept `(int status,
Map headers, Map/CharSequence body)` to match the refactored tests.
##########
grails-test-examples/app1/src/integration-test/groovy/functionaltests/flow/FlashChainForwardSpec.groovy:
##########
@@ -40,95 +38,69 @@ import grails.testing.mixin.integration.Integration
* flash scope and chain model which rely on HTTP session state.
*/
@Integration
-class FlashChainForwardSpec extends Specification {
-
- @Shared
- HttpClient client
+class FlashChainForwardSpec extends Specification implements HttpClientSupport
{
@Shared
- HttpClient noRedirectClient
-
- def setup() {
- client = client ?: HttpClient.create(new
URL("http://localhost:$serverPort"))
- if (!noRedirectClient) {
- def config = new DefaultHttpClientConfiguration()
- config.setFollowRedirects(false)
- noRedirectClient = HttpClient.create(new
URL("http://localhost:$serverPort"), config)
- }
- }
-
- def cleanupSpec() {
- client?.close()
- noRedirectClient?.close()
- }
-
- /**
- * Helper to extract session cookie from Set-Cookie header.
- */
- private String extractSessionCookie(String setCookieHeader) {
- if (!setCookieHeader) return null
- setCookieHeader.split(';')[0]
+ HttpClient noRedirectClient = httpClientWith {
+ followRedirects(HttpClient.Redirect.NEVER)
}
/**
* Helper to follow redirect manually with session cookie.
*/
- private Map followRedirectWithSession(String path) {
+ private HttpResponse<String> followRedirectWithSession(String path) {
// First request - get redirect and session cookie
- def response1 = noRedirectClient.toBlocking().exchange(
- HttpRequest.GET(path),
- String
- )
+ def response1 = http(path, noRedirectClient)
- def sessionCookie =
extractSessionCookie(response1.header('Set-Cookie'))
- def location = response1.header('Location')
+ def sessionCookie = response1.headerValue('Set-Cookie')
+ def location = response1.headerValue('Location')
if (!location) {
// Not a redirect, parse body
- return new JsonSlurper().parseText(response1.body())
+ return response1
}
// Follow redirect with session cookie
def redirectPath = location.startsWith('http') ?
- new URL(location).path + (new URL(location).query ? "?${new
URL(location).query}" : '') :
- location
+ new URL(location).path + (new URL(location).query ? "?${new
URL(location).query}" : '') :
+ location
- def request2 = HttpRequest.GET(redirectPath)
+ def headers = [:] as Map<String, String>
if (sessionCookie) {
- request2 = request2.header('Cookie', sessionCookie)
+ headers.put('Cookie', sessionCookie)
}
Review Comment:
`Set-Cookie` contains cookie attributes (Path, HttpOnly, etc). Passing the
full header value back as the `Cookie` request header is incorrect and can
break session propagation, which these tests depend on. Parse the cookie value
down to the `name=value` pair (or use a cookie manager) before setting the
`Cookie` header.
##########
grails-test-examples/cache/src/integration-test/groovy/com/demo/AdvancedCachingIntegrationSpec.groovy:
##########
@@ -67,257 +60,146 @@ class AdvancedCachingIntegrationSpec extends
ContainerGebSpec {
// ========== collection caching integration tests ==========
def "list data is cached via HTTP"() {
- given:
- def client = createClient()
-
when: "fetching list data twice"
- HttpResponse<String> response1 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/listData?category=books'),
- String
- )
- HttpResponse<String> response2 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/listData?category=books'),
- String
- )
+ def response1 = http('/advancedCaching/listData?category=books')
+ def response2 = http('/advancedCaching/listData?category=books')
then: "both calls return same data (cached)"
- response1.status == HttpStatus.OK
- response2.status == HttpStatus.OK
- def json1 = new JsonSlurper().parseText(response1.body())
- def json2 = new JsonSlurper().parseText(response2.body())
+ response1.expectStatus(200)
+ response2.expectStatus(200)
+ def json1 = response1.json()
+ def json2 = response2.json()
json1.data == json2.data
json1.data.size() == 3
json1.data[0].startsWith('Item 1 for books')
-
- cleanup:
- client?.close()
}
def "map data is cached via HTTP"() {
- given:
- def client = createClient()
-
when: "fetching map data twice"
- HttpResponse<String> response1 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/mapData?key=mykey'),
- String
- )
- HttpResponse<String> response2 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/mapData?key=mykey'),
- String
- )
+ def response1 = http('/advancedCaching/mapData?key=mykey')
+ def response2 = http('/advancedCaching/mapData?key=mykey')
then: "both calls return same data (cached)"
- response1.status == HttpStatus.OK
- response2.status == HttpStatus.OK
- def json1 = new JsonSlurper().parseText(response1.body())
- def json2 = new JsonSlurper().parseText(response2.body())
+ response1.expectStatus(200)
+ response2.expectStatus(200)
+ def json1 = response1.json()
+ def json2 = response2.json()
json1.data == json2.data
json1.data.key == 'mykey'
json1.data.value == 'Value for mykey'
json1.data.nested.a == 1
-
- cleanup:
- client?.close()
}
def "different categories have separate list cache entries via HTTP"() {
- given:
- def client = createClient()
-
when: "fetching different categories"
- HttpResponse<String> booksResponse = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/listData?category=books'),
- String
- )
- HttpResponse<String> moviesResponse = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/listData?category=movies'),
- String
- )
+ def booksResponse = http('/advancedCaching/listData?category=books')
+ def moviesResponse = http('/advancedCaching/listData?category=movies')
then: "different categories return different data"
- booksResponse.status == HttpStatus.OK
- moviesResponse.status == HttpStatus.OK
- def books = new JsonSlurper().parseText(booksResponse.body())
- def movies = new JsonSlurper().parseText(moviesResponse.body())
+ booksResponse.expectStatus(200)
+ moviesResponse.expectStatus(200)
+ def books = booksResponse.json()
+ def movies = moviesResponse.json()
books.data != movies.data
books.data[0].startsWith('Item 1 for books')
movies.data[0].startsWith('Item 1 for movies')
-
- cleanup:
- client?.close()
}
// ========== exception handling integration tests ==========
def "exception is thrown and not cached via HTTP"() {
- given:
- def client = createClient()
-
when: "calling endpoint that throws exception"
- client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/dataOrThrow?input=error'),
- String
- )
+ def response = http('/advancedCaching/dataOrThrow?input=error')
then: "exception results in error response"
- thrown(Exception)
-
- cleanup:
- client?.close()
+ response.expectStatus(500)
}
def "successful calls are cached even after exceptions via HTTP"() {
- given:
- def client = createClient()
-
when: "calling with normal value twice"
- HttpResponse<String> response1 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/dataOrThrow?input=normal'),
- String
- )
- HttpResponse<String> response2 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/dataOrThrow?input=normal'),
- String
- )
+ def response1 = http('/advancedCaching/dataOrThrow?input=normal')
+ def response2 = http('/advancedCaching/dataOrThrow?input=normal')
then: "second call returns cached result"
- response1.status == HttpStatus.OK
- response2.status == HttpStatus.OK
- def json1 = new JsonSlurper().parseText(response1.body())
- def json2 = new JsonSlurper().parseText(response2.body())
+ response1.expectStatus(200)
+ response2.expectStatus(200)
+ def json1 = response1.json()
+ def json2 = response2.json()
json1.data == json2.data
-
- cleanup:
- client?.close()
}
// ========== eviction integration tests ==========
def "eviction clears list cache via HTTP"() {
given:
- def client = createClient()
// First call to populate cache
- def first = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/listData?category=books'),
- String
- )
- def firstData = new JsonSlurper().parseText(first.body()).data
+ def first = http('/advancedCaching/listData?category=books')
+ def firstData = first.json().data
when: "evicting cache and fetching again"
-
client.toBlocking().exchange(HttpRequest.GET('/advancedCaching/evictListCache'),
String)
- HttpResponse<String> second = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/listData?category=books'),
- String
- )
- def secondData = new JsonSlurper().parseText(second.body()).data
+ http('/advancedCaching/evictListCache')
+ def second = http('/advancedCaching/listData?category=books')
+ def secondData = second.json().data
then: "new data is generated after eviction"
firstData != secondData
-
- cleanup:
- client?.close()
}
def "eviction clears map cache via HTTP"() {
given:
- def client = createClient()
// First call to populate cache
- def first = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/mapData?key=mykey'),
- String
- )
- def firstData = new JsonSlurper().parseText(first.body()).data
+ def first = http('/advancedCaching/mapData?key=mykey')
+ def firstData = first.json().data
when: "evicting cache and fetching again"
-
client.toBlocking().exchange(HttpRequest.GET('/advancedCaching/evictMapCache'),
String)
- HttpResponse<String> second = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/mapData?key=mykey'),
- String
- )
- def secondData = new JsonSlurper().parseText(second.body()).data
+ http('/advancedCaching/evictMapCache')
+ def second = http('/advancedCaching/mapData?key=mykey')
+ def secondData = second.json().data
then: "new data is generated after eviction"
firstData != secondData
-
- cleanup:
- client?.close()
}
// ========== custom key caching integration tests ==========
def "custom key caching works via HTTP"() {
- given:
- def client = createClient()
-
when: "fetching data by custom key twice"
- HttpResponse<String> response1 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/getDataByKey?key=testkey'),
- String
- )
- HttpResponse<String> response2 = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/getDataByKey?key=testkey'),
- String
- )
+ def response1 = http('/advancedCaching/getDataByKey?key=testkey')
+ def response2 = http('/advancedCaching/getDataByKey?key=testkey')
then: "second call returns cached result"
- response1.status == HttpStatus.OK
- response2.status == HttpStatus.OK
- def json1 = new JsonSlurper().parseText(response1.body())
- def json2 = new JsonSlurper().parseText(response2.body())
- json1.data == json2.data
-
- cleanup:
- client?.close()
+ response1.expectStatus(200)
+ response2.expectStatus(200)
+ response1.json().data == response2.json().data
}
def "eviction by custom key works via HTTP"() {
given:
- def client = createClient()
// First call to populate cache
- def first = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/getDataByKey?key=mykey'),
- String
- )
- def firstData = new JsonSlurper().parseText(first.body()).data
+ def first = http('/advancedCaching/getDataByKey?key=mykey')
+ def firstData = first.json().data
when: "evicting by key and fetching again"
-
client.toBlocking().exchange(HttpRequest.GET('/advancedCaching/evictByKey?key=mykey'),
String)
- HttpResponse<String> second = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/getDataByKey?key=mykey'),
- String
- )
- def secondData = new JsonSlurper().parseText(second.body()).data
+ http('/advancedCaching/evictByKey?key=mykey')
+ def second = http('/advancedCaching/getDataByKey?key=mykey')
+ def secondData = second.json().data
then: "new data is generated after eviction"
firstData != secondData
-
- cleanup:
- client?.close()
}
def "eviction of all custom key cache works via HTTP"() {
given:
- def client = createClient()
// First call to populate cache
- def first = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/getDataByKey?key=anykey'),
- String
- )
- def firstData = new JsonSlurper().parseText(first.body()).data
+ def first = http('/advancedCaching/getDataByKey?key=anykey')
+ def firstData = first.json().data
when: "evicting all and fetching again"
-
client.toBlocking().exchange(HttpRequest.GET('/advancedCaching/evictAllKeyCache'),
String)
- HttpResponse<String> second = client.toBlocking().exchange(
- HttpRequest.GET('/advancedCaching/getDataByKey?key=anykey'),
- String
- )
- def secondData = new JsonSlurper().parseText(second.body()).data
+ http('/advancedCaching/evictAllKeyCache')
+ def second = http('/advancedCaching/getDataByKey?key=anykey',)
+ def secondData = second.json().data
Review Comment:
There is a syntax error here: the trailing comma after the string literal
makes this call invalid Groovy and will prevent the spec from compiling. Remove
the trailing comma (and any unintended extra argument) so `http(...)` is called
with the intended parameters.
##########
grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/ProductSpec.groovy:
##########
@@ -16,209 +16,161 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package functional.tests
-import com.fasterxml.jackson.databind.ObjectMapper
+import spock.lang.Specification
+
import grails.testing.mixin.integration.Integration
-import grails.testing.spock.RunOnce
-import grails.web.http.HttpHeaders
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
-import org.junit.jupiter.api.BeforeEach
-import spock.lang.Shared
-
-@Integration(applicationClass = Application)
-class ProductSpec extends HttpClientSpec {
-
- @Shared
- ObjectMapper objectMapper
-
- def setup() {
- objectMapper = new ObjectMapper()
- }
+import org.apache.grails.testing.httpclient.HttpClientSupport
- @RunOnce
- @BeforeEach
- void init() {
- super.init()
- }
+@Integration
+class ProductSpec extends Specification implements HttpClientSupport {
void testEmptyProducts() {
when:
- HttpRequest request = HttpRequest.GET('/products')
- HttpResponse<String> resp = client.toBlocking().exchange(request,
String)
- Map body = objectMapper.readValue(resp.body(), Map)
-
- then:
- resp.status == HttpStatus.OK
- resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/hal+json;charset=UTF-8'
-
- and: 'The values returned are there'
- body.count == 0
- body.max == 10
- body.offset == 0
- body.sort == null
- body.order == null
+ def response = http('/products')
+
+ then: 'The values returned are there'
+ response.expectJsonContains(200, 'Content-Type':
'application/hal+json;charset=UTF-8', [
+ count: 0,
+ max: 10,
+ offset: 0,
+ order: null,
+ sort: null
+ ])
and: 'the hal _links attribute is present'
- body._links.size() == 1
- body._links.self.href.startsWith("${baseUrl}/product")
+ def json = response.json()
+ json._links.size() == 1
+ json._links.self.href.startsWith("$httpClientRootUri/product")
and: 'there are no products yet'
- body._embedded.products.size() == 0
+ json._embedded.products.size() == 0
}
void testSingleProduct() {
- given:
- HttpRequest request = HttpRequest.POST('/products', [
+ when:
+ def createResponse = httpPost('/products', [
name: 'Product 1',
description: 'product 1 description',
price: 123.45
])
- when:
- HttpResponse<String> createResp =
client.toBlocking().exchange(request, String)
- Map createBody = objectMapper.readValue(createResp.body(), Map)
-
then:
- createResp.status == HttpStatus.CREATED
+ createResponse.expectStatus(201)
+ def createBody = createResponse.json()
when: 'We get the products'
- request = HttpRequest.GET('/products')
- HttpResponse<String> resp = client.toBlocking().exchange(request,
String)
- Map body = objectMapper.readValue(resp.body(), Map)
-
- then:
- resp.status == HttpStatus.OK
- resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/hal+json;charset=UTF-8'
-
- and: 'The values returned are there'
- body.count == 1
- body.max == 10
- body.offset == 0
- body.sort == null
- body.order == null
+ def response = http('/products')
+
+ then: 'The values returned are there'
+ response.expectJsonContains(200, 'Content-Type':
'application/hal+json;charset=UTF-8', [
+ count: 1,
+ max: 10,
+ offset: 0,
+ sort: null,
+ order: null
+ ])
and: 'the hal _links attribute is present'
- body._links.size() == 1
- body._links.self.href.startsWith("${baseUrl}/product")
+ def json = response.json()
+ json._links.size() == 1
+ json._links.self.href.startsWith("$httpClientRootUri/product")
and: 'the product is present'
- body._embedded.products.size() == 1
- body._embedded.products.first().name == 'Product 1'
+ json._embedded.products.size() == 1
+ json._embedded.products.first().name == 'Product 1'
cleanup:
- resp =
client.toBlocking().exchange(HttpRequest.DELETE("/products/${createBody.id}"))
- assert resp.status() == HttpStatus.OK
+ httpDelete("/products/${createBody.id}")
}
void 'test a page worth of products'() {
given:
def productsIds = []
15.times { productNumber ->
- ProductVM product = new ProductVM(
- name: "Product $productNumber",
- description: "product ${productNumber} description",
- price: productNumber + (productNumber / 100)
- )
- HttpResponse<String> createResp = client.toBlocking()
- .exchange(HttpRequest.POST('/products', product), String)
- Map createBody = objectMapper.readValue(createResp.body(), Map)
- assert createResp.status == HttpStatus.CREATED
- productsIds << createBody.id
+ def product = [
+ name: "Product $productNumber",
+ description: "product ${productNumber} description",
+ price: productNumber + (productNumber / 100)
+ ]
+ def createResponse = httpPost('/products', product)
+ assert createResponse.statusCode() == 201
+ productsIds << createResponse.json().id
}
when: 'We get the products'
- HttpRequest request = HttpRequest.GET('/products')
- HttpResponse<String> resp = client.toBlocking().exchange(request,
String)
- Map body = objectMapper.readValue(resp.body(), Map)
+ def response = http('/products')
then:
- resp.status == HttpStatus.OK
- resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/hal+json;charset=UTF-8'
+ response.expectHeaders(200, 'Content-Type':
'application/hal+json;charset=UTF-8')
Review Comment:
`expectHeaders(...)` in `HttpResponseExtensions` is defined as
`expectHeaders(Map expected)` and `expectHeaders(Map expected, int status)`
(status is the last parameter). Calling it with `(status, map)` won’t resolve
and will fail compilation unless you add a status-first overload. Either switch
to `response.expectHeaders(['Content-Type': '...'], 200)` /
`response.expectHeaders('Content-Type': '...', 200)` or add an overload that
accepts `(int status, Map expected)` to support the usage pattern across these
refactored specs.
##########
grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/api/NamespacedBookSpec.groovy:
##########
@@ -16,142 +16,103 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package functional.tests.api
-import com.fasterxml.jackson.databind.ObjectMapper
-import functional.tests.Application
-import functional.tests.HttpClientSpec
-import grails.testing.mixin.integration.Integration
-import grails.testing.spock.RunOnce
-import grails.web.http.HttpHeaders
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
-import io.micronaut.http.MediaType
-import org.junit.jupiter.api.BeforeEach
import spock.lang.Issue
-import spock.lang.Shared
-
-@Integration(applicationClass = Application)
-class NamespacedBookSpec extends HttpClientSpec {
-
- @Shared
- ObjectMapper objectMapper
+import spock.lang.Specification
- def setup() {
- objectMapper = new ObjectMapper()
- }
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.httpclient.HttpClientSupport
- @RunOnce
- @BeforeEach
- void init() {
- super.init()
- }
+@Integration
+class NamespacedBookSpec extends Specification implements HttpClientSupport {
void 'test view rendering with a namespace'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse is correct'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ def response = http('/api/book')
+
+ then: 'The response is correct'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced)',
+ title: 'API - The Shining'
+ ])
}
void 'test nested template rendering with a namespace'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book/nested')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse contains the child template'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().foo == 'bar'
+ def response = http('/api/book/nested')
+
+ then: 'The response contains the child template'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ foo: 'bar'
+ ])
}
void 'test the correct content type is chosen (json)'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
+ def response = http('/api/book')
then: 'The response contains the child template'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- !rsp.body()['_links']
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced)',
+ title: 'API - The Shining'
+ ])
}
void 'test the correct content type is chosen (hal)'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request =
HttpRequest.GET('/api/book').accept(MediaType.APPLICATION_HAL_JSON_TYPE)
- HttpResponse<String> rsp = client.toBlocking().exchange(request,
String)
- Map body = objectMapper.readValue(rsp.body(), Map)
+ def response = http('/api/book', 'Accept': 'application/hal+json')
then: 'The response contains the child template'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/hal+json;charset=UTF-8'
- body['_links']
- body.api == 'version 1.0 (Namespaced HAL)'
- body.title == 'API - The Shining'
+ response.expectJsonContains(200, 'Content-Type':
'application/hal+json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced HAL)',
+ title: 'API - The Shining',
+ ])
+ response.json()._links
}
void 'test render(view: "..", model: ..) in controllers with namespaces
works'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book/testRender')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse is correct'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ def response = http('/api/book/testRender')
+
+ then: 'The responseonse is correct'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced)',
+ title: 'API - The Shining'
+ ])
}
- void 'test rspond(foo, view: ..) in controllers with namespaces works'() {
+ void 'test responseond(foo, view: ..) in controllers with namespaces
works'() {
when: 'A request is sent to a controller with a namespace'
Review Comment:
Typo in test name: 'responseond' should be 'respond'.
##########
grails-doc/src/en/guide/testing/integrationTesting.adoc:
##########
@@ -286,6 +286,124 @@ Example usage:
----
+[[httpClientTestingSupport]]
+==== HTTP Client Testing Support
+
+For integration and functional tests that call live HTTP endpoints, Grails
provides the
+`grails-testing-support-httpclient` module.
+
+Add the dependency to your integration test configuration:
+
+[source,groovy]
+----
+dependencies {
+ integrationTestImplementation
'org.apache.grails:grails-testing-support-httpclient'
+}
+----
+
+You can then implement `HttpClientSupport` in your Spock specification and use
helper methods for
+request building and response assertions:
+
+[source,groovy]
+----
+import java.net.http.HttpTimeoutException
+
+import spock.lang.Specification
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.httpclient.HttpClientSupport
+
+@Integration
+class DemoSpec extends Specification implements HttpClientSupport {
+
+ void 'simple GET with status and body assertions'() {
+ when: 'invoking a GET request to the /health endpoint'
+ def response = http('/health')
+
+ then: 'verifies status, headers and body contents via fluent API'
+ response.expectStatus(200)
+ .expectHeaders('Content-Type': 'application/json')
+ .expectContains('UP')
+
+
+ and: 'can also verify all these under the hood in one go'
+ response.expectContains(200, 'Content-Type': 'application/json', 'UP')
+ }
+
+ void 'GET with headers and header assertions'() {
+ when:
+ def response = http('/api/info', 'Accept': 'application/json')
+
+ then:
+ response.expectHeadersIgnoreCase(200, 'content-type':
'application/json;charset=UTF-8')
+ }
+
+ void 'Posting and verifying JSON'() {
+ when: 'you post a map it is turned into a JSON payload'
+ def response = httpPost('/api/books', [title: 'Grails in Action',
pages: 500])
+
+ then: 'you can also verify response bodies with maps'
+ response.expectJson(201, [id: 1, title: 'Grails in Action', pages:
500])
+
+ and: 'you can also verify against a JSON string'
+ response.expectJson('''
+ {
+ "id": 1,
+ "title": "Grails in Action",
+ "pages": 500
+ }
+ ''')
+
+ and: 'the canonicalized JSON tree structure is compared, not strings'
+ response.expectJson('{ "pages": 500, "id": 1, "title": "Grails in
Action" }')
+
+ and: 'if you want to compare strings, use
expectContains/expectNotContains'
+ response.expectNotContains('{ "id": 1, "title": "Grails in Action",
"pages": 500 }')
+
+ and: 'you can also use expectJsonContains to verify sub trees in the
response json'
+ response.expectJsonContains([title: 'Grails in Action'])
+ }
+
+ void 'custom request and custom client configuration'() {
+ when: 'you need a custom configured client'
+ def noRedirectClient = httpClientWith {
+ followRedirects(HttpClient.Redirect.NEVER)
+ }
+
+ and: 'or a custom configured request'
+ def request = httpRequestWith('/api/slow') {
+ timeout(Duration.ofSeconds(1))
+ }
+
+ when:
+ http(request, noRedirectClient)
+
+ then:
+ thrown(HttpTimeoutException)
+ }
+
+ void 'multipart upload'() {
+ given:
+ def multipart = MultipartBody.builder()
+ .addPart('description', 'test file')
+ .addPart('file', 'hello.txt', 'hello world'.bytes,
'text/plain')
+ .build()
+
+ when:
+ def response = httpPost('/api/upload', multipart)
+
+ then:
+ response.expectStatus(200)
+ }
+}
+----
+
+`HttpClientSupport` uses the JDK `HttpClient` and applies default request and
connect timeouts of 60 seconds and
+a redirect policy of always follow redirects. You can use helpers
`httpRequestWith(Closure configurer)` and
+`httpClientWith(Closure configurer)` to customize your own client and requests
starting from the defaults.
+
+Additional request helper methods available include `httpPut()`,
`httpPatch()`, `httpDelete()`, `httpOptions()`, and many assertion methods on
responses.
+
Review Comment:
The example code in this new section doesn’t match the actual
`HttpResponseExtensions`/`MultipartBody` APIs as implemented. For example,
`expectContains(200, 'Content-Type': ..., 'UP')` and
`expectHeadersIgnoreCase(200, ...)` won’t resolve with the current method
signatures (status is not the first parameter), and the
`MultipartBody.addPart('file', 'hello.txt', 'hello world'.bytes, 'text/plain')`
argument order doesn’t match any `addPart` overload. Please update the
documentation examples to use the correct parameter order (or add matching
overloads in the implementation) so the docs compile and reflect the real API.
##########
grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/api/NamespacedBookSpec.groovy:
##########
@@ -16,142 +16,103 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package functional.tests.api
-import com.fasterxml.jackson.databind.ObjectMapper
-import functional.tests.Application
-import functional.tests.HttpClientSpec
-import grails.testing.mixin.integration.Integration
-import grails.testing.spock.RunOnce
-import grails.web.http.HttpHeaders
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
-import io.micronaut.http.MediaType
-import org.junit.jupiter.api.BeforeEach
import spock.lang.Issue
-import spock.lang.Shared
-
-@Integration(applicationClass = Application)
-class NamespacedBookSpec extends HttpClientSpec {
-
- @Shared
- ObjectMapper objectMapper
+import spock.lang.Specification
- def setup() {
- objectMapper = new ObjectMapper()
- }
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.httpclient.HttpClientSupport
- @RunOnce
- @BeforeEach
- void init() {
- super.init()
- }
+@Integration
+class NamespacedBookSpec extends Specification implements HttpClientSupport {
void 'test view rendering with a namespace'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse is correct'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ def response = http('/api/book')
+
+ then: 'The response is correct'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced)',
+ title: 'API - The Shining'
+ ])
}
void 'test nested template rendering with a namespace'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book/nested')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse contains the child template'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().foo == 'bar'
+ def response = http('/api/book/nested')
+
+ then: 'The response contains the child template'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ foo: 'bar'
+ ])
}
void 'test the correct content type is chosen (json)'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
+ def response = http('/api/book')
then: 'The response contains the child template'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- !rsp.body()['_links']
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced)',
+ title: 'API - The Shining'
+ ])
}
void 'test the correct content type is chosen (hal)'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request =
HttpRequest.GET('/api/book').accept(MediaType.APPLICATION_HAL_JSON_TYPE)
- HttpResponse<String> rsp = client.toBlocking().exchange(request,
String)
- Map body = objectMapper.readValue(rsp.body(), Map)
+ def response = http('/api/book', 'Accept': 'application/hal+json')
then: 'The response contains the child template'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/hal+json;charset=UTF-8'
- body['_links']
- body.api == 'version 1.0 (Namespaced HAL)'
- body.title == 'API - The Shining'
+ response.expectJsonContains(200, 'Content-Type':
'application/hal+json;charset=UTF-8', [
+ api: 'version 1.0 (Namespaced HAL)',
+ title: 'API - The Shining',
+ ])
+ response.json()._links
}
void 'test render(view: "..", model: ..) in controllers with namespaces
works'() {
when: 'A request is sent to a controller with a namespace'
- HttpRequest request = HttpRequest.GET('/api/book/testRender')
- HttpResponse<Map> rsp = client.toBlocking().exchange(request, Map)
-
- then: 'The rsponse is correct'
- rsp.status() == HttpStatus.OK
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).isPresent()
- rsp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() ==
'application/json;charset=UTF-8'
- rsp.body().api == 'version 1.0 (Namespaced)'
- rsp.body().title == 'API - The Shining'
+ def response = http('/api/book/testRender')
+
+ then: 'The responseonse is correct'
+ response.expectJson(200, 'Content-Type':
'application/json;charset=UTF-8', [
Review Comment:
Typo in assertion description: 'responseonse' should be 'response'.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]