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&param2=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()
+    }
+}


Reply via email to