This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch test/expand-integration-test-coverage in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit d665e49f199113ae30fa12a4828fc0acb73a5f36 Author: James Fredley <[email protected]> AuthorDate: Sun Jan 25 22:04:33 2026 -0500 Add content negotiation and URL mappings tests - Add ContentNegotiationSpec with 21 tests for format handling - Tests JSON, XML, HTML responses based on Accept headers - Tests format parameter, extension-based negotiation - Add UrlMappingsSpec with 20 tests for routing - Tests path variables, constraints, HTTP methods, redirects - Includes optional parameters and format extension support --- .../contentneg/ContentNegotiationController.groovy | 168 +++++++ .../urlmappings/UrlMappingsTestController.groovy | 167 +++++++ .../grails-app/views/contentNegotiation/error.gsp | 11 + .../grails-app/views/contentNegotiation/index.gsp | 16 + .../contentneg/ContentNegotiationSpec.groovy | 530 +++++++++++++++++++++ .../urlmappings/UrlMappingsSpec.groovy | 466 ++++++++++++++++++ 6 files changed, 1358 insertions(+) diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/contentneg/ContentNegotiationController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/contentneg/ContentNegotiationController.groovy new file mode 100644 index 0000000000..349a2ef33e --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/contentneg/ContentNegotiationController.groovy @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests.contentneg + +import grails.converters.JSON +import grails.converters.XML + +/** + * Controller demonstrating content negotiation features. + */ +class ContentNegotiationController { + + static responseFormats = ['json', 'xml', 'html'] + + /** + * Returns data in format based on Accept header or extension. + */ + def index() { + def data = [ + message: 'Hello World', + timestamp: new Date().format('yyyy-MM-dd'), + items: ['one', 'two', 'three'] + ] + + withFormat { + json { render data as JSON } + xml { render data as XML } + html { render view: 'index', model: [data: data] } + } + } + + /** + * Demonstrates respond method for automatic content negotiation. + */ + def respond() { + def data = [ + status: 'success', + code: 200, + data: [ + id: 1, + name: 'Test Item', + active: true + ] + ] + respond data + } + + /** + * Returns different status codes based on format. + */ + def statusByFormat() { + withFormat { + json { render([status: 'ok'] as JSON) } + xml { render([status: 'ok'] as XML) } + html { + response.status = 200 + render '<html><body><p>OK</p></body></html>' + } + } + } + + /** + * Demonstrates format parameter override. + */ + def formatParam() { + def data = [format: params.format ?: 'unknown', value: 42] + + withFormat { + json { render data as JSON } + xml { render data as XML } + '*' { render data as JSON } // Default fallback + } + } + + /** + * Returns a list for collection content negotiation. + */ + def list() { + def items = [ + [id: 1, name: 'Item 1'], + [id: 2, name: 'Item 2'], + [id: 3, name: 'Item 3'] + ] + respond items + } + + /** + * Demonstrates explicit content type setting. + */ + def explicitContentType() { + response.contentType = 'application/json' + render '{"explicit": true}' + } + + /** + * Returns data with custom JSON rendering. + */ + def customJson() { + def data = [ + nested: [ + deep: [ + value: 'found' + ] + ] + ] + render(contentType: 'application/json') { + result(data.nested.deep.value) + } + } + + /** + * Demonstrates error response formatting. + */ + def error() { + def error = [ + error: true, + message: 'Something went wrong', + code: 'ERR_001' + ] + + withFormat { + json { + response.status = 400 + render error as JSON + } + xml { + response.status = 400 + render error as XML + } + html { + response.status = 400 + render view: 'error', model: [error: error] + } + } + } + + /** + * Demonstrates multiple accept types in request. + */ + def multiAccept() { + // Request can have multiple Accept types with quality values + def acceptHeader = request.getHeader('Accept') ?: 'none' + def data = [acceptHeader: acceptHeader, negotiated: response.format ?: 'unknown'] + + withFormat { + json { render data as JSON } + xml { render data as XML } + '*' { render data as JSON } + } + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/urlmappings/UrlMappingsTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/urlmappings/UrlMappingsTestController.groovy new file mode 100644 index 0000000000..9ff7aa4f39 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/urlmappings/UrlMappingsTestController.groovy @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests.urlmappings + +import grails.converters.JSON + +/** + * Controller for testing URL mapping features. + */ +class UrlMappingsTestController { + + static responseFormats = ['json'] + + /** + * Returns information about the requested parameters. + */ + def index() { + render([ + controller: 'urlMappingsTest', + action: 'index', + params: params.findAll { k, v -> !['controller', 'action'].contains(k) } + ] as JSON) + } + + /** + * Show action with ID parameter. + */ + def show() { + render([ + controller: 'urlMappingsTest', + action: 'show', + id: params.id + ] as JSON) + } + + /** + * Action with multiple path variables. + */ + def pathVars() { + render([ + controller: 'urlMappingsTest', + action: 'pathVars', + year: params.year, + month: params.month, + day: params.day + ] as JSON) + } + + /** + * Action for testing named URL mappings. + */ + def named() { + render([ + controller: 'urlMappingsTest', + action: 'named', + name: params.name + ] as JSON) + } + + /** + * Action for testing constrained parameters. + */ + def constrained() { + render([ + controller: 'urlMappingsTest', + action: 'constrained', + code: params.code + ] as JSON) + } + + /** + * Action that demonstrates wildcard capture. + */ + def wildcard() { + render([ + controller: 'urlMappingsTest', + action: 'wildcard', + path: params.path + ] as JSON) + } + + /** + * Action for REST-style resource. + */ + def list() { + render([ + controller: 'urlMappingsTest', + action: 'list', + format: params.format ?: 'json' + ] as JSON) + } + + /** + * Create action for REST resource. + */ + def save() { + render([ + controller: 'urlMappingsTest', + action: 'save', + method: 'POST' + ] as JSON) + } + + /** + * Update action for REST resource. + */ + def update() { + render([ + controller: 'urlMappingsTest', + action: 'update', + id: params.id, + method: 'PUT' + ] as JSON) + } + + /** + * Delete action for REST resource. + */ + def delete() { + render([ + controller: 'urlMappingsTest', + action: 'delete', + id: params.id, + method: 'DELETE' + ] as JSON) + } + + /** + * Action for optional parameters. + */ + def optional() { + render([ + controller: 'urlMappingsTest', + action: 'optional', + required: params.required, + optional: params.optional ?: 'default' + ] as JSON) + } + + /** + * Action that returns HTTP method info. + */ + def httpMethod() { + render([ + controller: 'urlMappingsTest', + action: 'httpMethod', + method: request.method + ] as JSON) + } +} diff --git a/grails-test-examples/app1/grails-app/views/contentNegotiation/error.gsp b/grails-test-examples/app1/grails-app/views/contentNegotiation/error.gsp new file mode 100644 index 0000000000..fda47db970 --- /dev/null +++ b/grails-test-examples/app1/grails-app/views/contentNegotiation/error.gsp @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <title>Error</title> +</head> +<body> + <h1>Error</h1> + <p>Message: ${error.message}</p> + <p>Code: ${error.code}</p> +</body> +</html> diff --git a/grails-test-examples/app1/grails-app/views/contentNegotiation/index.gsp b/grails-test-examples/app1/grails-app/views/contentNegotiation/index.gsp new file mode 100644 index 0000000000..72b762563c --- /dev/null +++ b/grails-test-examples/app1/grails-app/views/contentNegotiation/index.gsp @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> +<head> + <title>Content Negotiation Demo</title> +</head> +<body> + <h1>Content Negotiation</h1> + <p>Message: ${data.message}</p> + <p>Timestamp: ${data.timestamp}</p> + <ul> + <g:each in="${data.items}" var="item"> + <li>${item}</li> + </g:each> + </ul> +</body> +</html> diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/contentneg/ContentNegotiationSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/contentneg/ContentNegotiationSpec.groovy new file mode 100644 index 0000000000..018eab6bdb --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/contentneg/ContentNegotiationSpec.groovy @@ -0,0 +1,530 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests.contentneg + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +import groovy.json.JsonSlurper +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import spock.lang.Narrative +import spock.lang.Specification + +/** + * Integration tests for Grails content negotiation features. + * + * Tests Accept header-based content negotiation, URL extension-based + * format selection, respond method, withFormat, and various + * content type handling scenarios. + */ +@Integration(applicationClass = Application) +@Narrative(''' +Grails provides content negotiation that allows the same controller action +to return different response formats (JSON, XML, HTML) based on the client's +Accept header or URL extension. +''') +class ContentNegotiationSpec extends Specification { + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + // ========== Accept Header-Based Negotiation ========== + + def "JSON response via Accept header application/json"() { + given: + def client = createClient() + + when: "requesting with Accept: application/json" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/index') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('application/json') + + and: "content is valid JSON" + def json = new JsonSlurper().parseText(response.body()) + json.message == 'Hello World' + json.items.size() == 3 + + cleanup: + client.close() + } + + def "XML response via Accept header application/xml"() { + given: + def client = createClient() + + when: "requesting with Accept: application/xml" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/index') + .accept(MediaType.APPLICATION_XML), + String + ) + + then: "response is XML" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('xml') + + and: "content contains expected XML elements (Grails XML converter uses entry key format for maps)" + response.body().contains('<entry key="message">Hello World</entry>') + + cleanup: + client.close() + } + + def "HTML response via Accept header text/html"() { + given: + def client = createClient() + + when: "requesting with Accept: text/html" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/index') + .accept(MediaType.TEXT_HTML), + String + ) + + then: "response is HTML" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('text/html') + + and: "content is HTML page" + response.body().contains('<h1>Content Negotiation</h1>') + response.body().contains('Hello World') + + cleanup: + client.close() + } + + // ========== URL Extension-Based Negotiation ========== + + def "JSON response via .json extension"() { + given: + def client = createClient() + + when: "requesting URL with .json extension" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/index.json'), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + + and: "content is valid JSON with expected data" + def json = new JsonSlurper().parseText(response.body()) + json.message == 'Hello World' + + cleanup: + client.close() + } + + def "XML response via .xml extension"() { + given: + def client = createClient() + + when: "requesting URL with .xml extension" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/index.xml'), + String + ) + + then: "response is XML" + response.status == HttpStatus.OK + + and: "content is XML (Grails converter uses map/entry format)" + response.body().contains('<entry key="message">') + + cleanup: + client.close() + } + + // ========== Respond Method Tests ========== + + def "respond method returns JSON for Accept application/json"() { + given: + def client = createClient() + + when: "calling respond action with Accept: application/json" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/respond') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + + and: "content is valid JSON" + def json = new JsonSlurper().parseText(response.body()) + json.status == 'success' + json.data.id == 1 + json.data.name == 'Test Item' + + cleanup: + client.close() + } + + def "respond method returns XML for Accept application/xml"() { + given: + def client = createClient() + + when: "calling respond action with Accept: application/xml" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/respond') + .accept(MediaType.APPLICATION_XML), + String + ) + + then: "response is XML" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('xml') + + cleanup: + client.close() + } + + // ========== List/Collection Content Negotiation ========== + + def "list action returns JSON array"() { + given: + def client = createClient() + + when: "requesting list with Accept: application/json" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/list') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response is JSON array" + response.status == HttpStatus.OK + + and: "content is array with 3 items" + def json = new JsonSlurper().parseText(response.body()) + json instanceof List + json.size() == 3 + json[0].id == 1 + json[0].name == 'Item 1' + + cleanup: + client.close() + } + + def "list action returns XML for XML accept"() { + given: + def client = createClient() + + when: "requesting list with Accept: application/xml" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/list') + .accept(MediaType.APPLICATION_XML), + String + ) + + then: "response is XML" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('xml') + + cleanup: + client.close() + } + + // ========== Explicit Content Type ========== + + def "explicit content type overrides negotiation"() { + given: + def client = createClient() + + when: "calling action with explicit content type" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/explicitContentType'), + String + ) + + then: "response has explicit content type" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('application/json') + + and: "content is as specified" + def json = new JsonSlurper().parseText(response.body()) + json.explicit == true + + cleanup: + client.close() + } + + // ========== Error Response Formatting ========== + + def "error response in JSON format"() { + given: + def client = createClient() + + when: "requesting error action with Accept: application/json" + client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/error') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "error response is returned" + HttpClientResponseException e = thrown() + e.status == HttpStatus.BAD_REQUEST + + and: "error body is JSON" + def json = new JsonSlurper().parseText(e.response.body().toString()) + json.error == true + json.message == 'Something went wrong' + json.code == 'ERR_001' + + cleanup: + client.close() + } + + def "error response in XML format"() { + given: + def client = createClient() + + when: "requesting error action with Accept: application/xml" + client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/error') + .accept(MediaType.APPLICATION_XML), + String + ) + + then: "error response is returned" + HttpClientResponseException e = thrown() + e.status == HttpStatus.BAD_REQUEST + + and: "error body contains XML elements (Grails converter uses entry key format)" + e.response.body().toString().contains('<entry key="error">true</entry>') + + cleanup: + client.close() + } + + // ========== Format Parameter Override ========== + + def "format parameter can specify JSON"() { + given: + def client = createClient() + + when: "requesting with format=json parameter" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/formatParam?format=json'), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + + and: "format is recorded in response" + def json = new JsonSlurper().parseText(response.body()) + json.format == 'json' + json.value == 42 + + cleanup: + client.close() + } + + def "format parameter can specify XML"() { + given: + def client = createClient() + + when: "requesting with format=xml parameter" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/formatParam?format=xml'), + String + ) + + then: "response is XML" + response.status == HttpStatus.OK + response.body().contains('<entry key="format">xml</entry>') + + cleanup: + client.close() + } + + // ========== Status Code Tests ========== + + def "status by format returns correct status for JSON"() { + given: + def client = createClient() + + when: "requesting statusByFormat with Accept: application/json" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/statusByFormat') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response status is OK" + response.status == HttpStatus.OK + + and: "body is JSON" + def json = new JsonSlurper().parseText(response.body()) + json.status == 'ok' + + cleanup: + client.close() + } + + // ========== Accept Header Quality Values ========== + + def "multiAccept action handles Accept header"() { + given: + def client = createClient() + + when: "requesting with complex Accept header" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/multiAccept') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response is successful" + response.status == HttpStatus.OK + + and: "response contains accept header info" + def json = new JsonSlurper().parseText(response.body()) + json.acceptHeader != null + + cleanup: + client.close() + } + + // ========== Wildcard/Default Format ========== + + def "formatParam falls back to default for unknown format"() { + given: + def client = createClient() + + when: "requesting with unknown format" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/formatParam?format=unknown'), + String + ) + + then: "response uses default format (JSON)" + response.status == HttpStatus.OK + + and: "response is valid JSON" + def json = new JsonSlurper().parseText(response.body()) + json.format == 'unknown' + json.value == 42 + + cleanup: + client.close() + } + + // ========== Content-Type Header Variations ========== + + def "JSON with charset in Accept header"() { + given: + def client = createClient() + + when: "requesting with Accept including charset" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/respond') + .header('Accept', 'application/json; charset=utf-8'), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('json') + + cleanup: + client.close() + } + + // ========== Multiple Format Extensions ========== + + def "respond action with .json extension"() { + given: + def client = createClient() + + when: "requesting respond with .json extension" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/respond.json'), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + + and: "content is valid" + def json = new JsonSlurper().parseText(response.body()) + json.status == 'success' + + cleanup: + client.close() + } + + def "list action with .json extension"() { + given: + def client = createClient() + + when: "requesting list with .json extension" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/list.json'), + String + ) + + then: "response is JSON array" + response.status == HttpStatus.OK + + and: "content is array" + def json = new JsonSlurper().parseText(response.body()) + json instanceof List + json.size() == 3 + + cleanup: + client.close() + } + + // ========== Custom JSON Rendering ========== + + def "custom JSON rendering produces valid output"() { + given: + def client = createClient() + + when: "requesting custom JSON action" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/contentNegotiation/customJson'), + String + ) + + then: "response is JSON" + response.status == HttpStatus.OK + response.contentType.get().toString().contains('application/json') + + cleanup: + client.close() + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/urlmappings/UrlMappingsSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/urlmappings/UrlMappingsSpec.groovy new file mode 100644 index 0000000000..894214b069 --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/urlmappings/UrlMappingsSpec.groovy @@ -0,0 +1,466 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests.urlmappings + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +import groovy.json.JsonSlurper +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import spock.lang.Narrative +import spock.lang.Specification + +/** + * Integration tests for Grails URL mappings features. + * + * Tests static paths, path variables, constraints, HTTP method mappings, + * redirects, and various URL mapping patterns. + */ +@Integration(applicationClass = Application) +@Narrative(''' +Grails URL mappings provide flexible routing of HTTP requests to controller actions. +This includes path variables, constraints, HTTP method-based routing, and redirects. +''') +class UrlMappingsSpec extends Specification { + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + // ========== Static Path Mappings ========== + + def "static path mapping routes to correct action"() { + given: + def client = createClient() + + when: "accessing static path" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/test'), + String + ) + + then: "routes to index action" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.controller == 'urlMappingsTest' + json.action == 'index' + + cleanup: + client.close() + } + + // ========== Path Variable Mappings ========== + + def "single path variable is captured"() { + given: + def client = createClient() + + when: "accessing path with variable" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/items/123'), + String + ) + + then: "variable is captured" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'show' + json.id == '123' + + cleanup: + client.close() + } + + def "path variable accepts alphanumeric values"() { + given: + def client = createClient() + + when: "accessing path with alphanumeric id" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/items/abc-123'), + String + ) + + then: "variable is captured correctly" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.id == 'abc-123' + + cleanup: + client.close() + } + + def "multiple path variables are captured"() { + given: + def client = createClient() + + when: "accessing path with multiple variables" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/archive/2024/03/15'), + String + ) + + then: "all variables are captured" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'pathVars' + json.year == '2024' + json.month == '03' + json.day == '15' + + cleanup: + client.close() + } + + // ========== Named URL Mappings ========== + + def "named mapping routes correctly"() { + given: + def client = createClient() + + when: "accessing named mapping" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/named/test-name'), + String + ) + + then: "routes to correct action with variable" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'named' + json.name == 'test-name' + + cleanup: + client.close() + } + + // ========== Constrained Path Variables ========== + + def "constrained path accepts valid values"() { + given: + def client = createClient() + + when: "accessing with valid constrained value" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/codes/ABC'), + String + ) + + then: "request succeeds" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'constrained' + json.code == 'ABC' + + cleanup: + client.close() + } + + def "constrained path rejects invalid values"() { + given: + def client = createClient() + + when: "accessing with invalid constrained value (lowercase)" + client.toBlocking().exchange( + HttpRequest.GET('/api/codes/abc'), + String + ) + + then: "request is rejected with 404" + HttpClientResponseException e = thrown() + e.status == HttpStatus.NOT_FOUND + + cleanup: + client.close() + } + + def "constrained path rejects numeric values"() { + given: + def client = createClient() + + when: "accessing with numeric value" + client.toBlocking().exchange( + HttpRequest.GET('/api/codes/123'), + String + ) + + then: "request is rejected with 404" + HttpClientResponseException e = thrown() + e.status == HttpStatus.NOT_FOUND + + cleanup: + client.close() + } + + // ========== HTTP Method Mappings ========== + + def "GET request routes to list action"() { + given: + def client = createClient() + + when: "making GET request to resources" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/resources'), + String + ) + + then: "routes to list action" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'list' + + cleanup: + client.close() + } + + def "POST request routes to save action"() { + given: + def client = createClient() + + when: "making POST request to resources" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.POST('/api/resources', '{}'), + String + ) + + then: "routes to save action" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'save' + json.method == 'POST' + + cleanup: + client.close() + } + + def "PUT request routes to update action"() { + given: + def client = createClient() + + when: "making PUT request to resources with id" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.PUT('/api/resources/42', '{}'), + String + ) + + then: "routes to update action" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'update' + json.id == '42' + json.method == 'PUT' + + cleanup: + client.close() + } + + def "DELETE request routes to delete action"() { + given: + def client = createClient() + + when: "making DELETE request to resources with id" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.DELETE('/api/resources/42'), + String + ) + + then: "routes to delete action" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'delete' + json.id == '42' + json.method == 'DELETE' + + cleanup: + client.close() + } + + // ========== Optional Path Variables ========== + + def "optional path variable with value"() { + given: + def client = createClient() + + when: "accessing with optional variable provided" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/optional/required-value/optional-value'), + String + ) + + then: "both values captured" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.required == 'required-value' + json.optional == 'optional-value' + + cleanup: + client.close() + } + + def "optional path variable without value uses default"() { + given: + def client = createClient() + + when: "accessing without optional variable" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/optional/required-value'), + String + ) + + then: "required captured, optional uses default" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.required == 'required-value' + json.optional == 'default' + + cleanup: + client.close() + } + + // ========== Redirect Mappings ========== + + def "redirect mapping performs redirect"() { + given: + def client = HttpClient.create(new URL("http://localhost:$serverPort")) + + when: "accessing redirect mapping" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/old-endpoint').header('Accept', '*/*'), + String + ) + + then: "redirect is performed and final response received" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'index' + + cleanup: + client.close() + } + + // ========== Default Controller/Action Mapping ========== + + def "default mapping with controller and action"() { + given: + def client = createClient() + + when: "using default mapping pattern" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/urlMappingsTest/show/99'), + String + ) + + then: "routes correctly" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.controller == 'urlMappingsTest' + json.action == 'show' + json.id == '99' + + cleanup: + client.close() + } + + def "default mapping with format extension"() { + given: + def client = createClient() + + when: "using default mapping with format" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/urlMappingsTest/list.json'), + String + ) + + then: "routes correctly with format" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.action == 'list' + + cleanup: + client.close() + } + + // ========== Query Parameter Handling ========== + + def "query parameters are accessible in action"() { + given: + def client = createClient() + + when: "accessing with query parameters" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/test?param1=value1¶m2=value2'), + String + ) + + then: "query params are accessible" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.params.param1 == 'value1' + json.params.param2 == 'value2' + + cleanup: + client.close() + } + + // ========== HTTP Method Detection ========== + + def "request method is correctly detected"() { + given: + def client = createClient() + + when: "making request" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/api/method-test'), + String + ) + + then: "method is correctly detected" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.method == 'GET' + + cleanup: + client.close() + } + + // ========== 404 Not Found ========== + + def "non-existent path returns 404"() { + given: + def client = createClient() + + when: "accessing non-existent path" + client.toBlocking().exchange( + HttpRequest.GET('/api/does-not-exist'), + String + ) + + then: "returns 404" + HttpClientResponseException e = thrown() + e.status == HttpStatus.NOT_FOUND + + cleanup: + client.close() + } +}
