Copilot commented on code in PR #15520:
URL: https://github.com/apache/grails-core/pull/15520#discussion_r2967609899


##########
grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/utils/XmlUtilsSpec.groovy:
##########
@@ -0,0 +1,329 @@
+/*
+ * 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 org.apache.grails.testing.http.client.utils
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+
+import groovy.xml.XmlSlurper
+
+import org.xml.sax.SAXParseException
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class XmlUtilsSpec extends Specification {
+
+    @Unroll
+    void 'toXml allows custom formatting options'() {
+        given:
+        def format = new XmlUtils.Format(
+                doubleQuotes: doubleQ,
+                expandEmptyElements: expEmptyEle,
+                omitEmptyAttributes: omitEmptyAttr,
+                omitNullAttributes: omitNullAttr,
+                spaceInEmptyElements: spaceInEmptyEle,
+                prettyPrint: false
+        )
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product(a: 'a', b: '', c: null) {
+                empty()
+            }
+        }
+
+        then:
+        xml == expectedXml
+
+        where:
+        doubleQ | expEmptyEle | omitEmptyAttr | omitNullAttr | spaceInEmptyEle 
|| expectedXml
+        true    | false       | false         | false        | true            
|| '<product a="a" b="" c=""><empty /></product>'
+        true    | false       | false         | false        | false           
|| '<product a="a" b="" c=""><empty/></product>'
+        true    | true        | false         | false        | false           
|| '<product a="a" b="" c=""><empty></empty></product>'
+        false   | false       | true          | false        | false           
|| "<product a='a' c=''><empty/></product>"
+        true    | false       | false         | true         | true            
|| '<product a="a" b=""><empty /></product>'
+        false   | true        | true          | true         | false           
|| "<product a='a'><empty></empty></product>"
+    }
+
+    void 'toXml optionally prepends doctype declaration'() {
+        given:
+        def format = new XmlUtils.Format(doctype: '<!DOCTYPE product SYSTEM 
"product.dtd">')
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product {
+                id('1')
+            }
+        }
+
+        then:
+        xml == '<!DOCTYPE product SYSTEM 
"product.dtd"><product><id>1</id></product>'
+    }
+
+    void 'toXml can include declaration before doctype'() {
+        given:
+        def format = new XmlUtils.Format(
+                omitDeclaration: false,
+                doctype: '<!DOCTYPE product SYSTEM "product.dtd">'
+        )
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product {
+                id('1')
+            }
+        }
+
+        then:
+        xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>')
+        xml.contains('<!DOCTYPE product SYSTEM "product.dtd">')
+        xml.endsWith('<product><id>1</id></product>')
+    }
+
+    void 'toXml uses declaration charset version and quote style from 
format'() {
+        given:
+        def format = new XmlUtils.Format(
+                charset: StandardCharsets.UTF_16,
+                xmlVersion: '1.1',
+                doubleQuotes: false,
+                omitDeclaration: false,
+                prettyPrint: false
+        )
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product()
+        }
+
+        then:
+        xml == "<?xml version='1.1' encoding='UTF-16'?><product />"
+    }
+
+    void 'toXml uses custom line separator in prefix'() {
+        given:
+        def format = new XmlUtils.Format(
+                omitDeclaration: false,
+                prettyPrint: true,
+                doctype: '<!DOCTYPE product SYSTEM "product.dtd">',
+                lineSeparator: '|'
+        )
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product {
+                id('1')
+            }
+        }
+
+        then:
+        xml == '<?xml version="1.0" encoding="UTF-8"?>|<!DOCTYPE product 
SYSTEM "product.dtd">|<product>| <id>1</id>|</product>'
+    }
+
+    void 'toXml uses custom indentation when pretty printing'() {
+        given:
+        def format = new XmlUtils.Format(indent: '--', lineSeparator: '|', 
prettyPrint: true)
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product {
+                id('1')
+            }
+        }
+
+        then:
+        xml == '<product>|--<id>1</id>|</product>'
+    }
+
+    @Unroll
+    void 'toXml configures attribute escaping'() {
+        given:
+        def format = new XmlUtils.Format(escapeAttributes: escapeAttr)
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            product(code: 'A&B<C')
+        }
+
+        then:
+        xml == expectedXml
+
+        where:
+        escapeAttr || expectedXml
+        true       || '<product code="A&amp;B&lt;C" />'
+        false      || '<product code="A&B<C" />'
+    }
+
+    void 'toXml honors direct delegate formatting overrides over passed format 
values'() {
+        given:
+        def format = new XmlUtils.Format(omitNullAttributes: false)
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            omitNullAttributes = true
+            product(nullAttr: null, keep: 'yes') {
+                name('Widget')
+            }
+        }
+
+        then:
+        xml == '<product keep="yes"><name>Widget</name></product>'
+    }
+
+    void 'toXml honors direct delegate escapeAttributes override over passed 
format value'() {
+        given:
+        def format = new XmlUtils.Format(escapeAttributes: true)
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            escapeAttributes = false
+            product(code: 'A&B<C')
+        }
+
+        then:
+        xml == '<product code="A&B<C" />'
+    }
+
+    void 'toXml preserves dsl xml declaration when format omits declaration'() 
{
+        given:
+        def format = new XmlUtils.Format(omitDeclaration: true, doctype: 
'<!DOCTYPE product SYSTEM "product.dtd">')
+
+        when:
+        def xml = XmlUtils.toXml(format) {
+            mkp.xmlDeclaration(version: '1.1', encoding: 'UTF-16')
+            product {
+                id('1')
+            }
+        }
+
+        then:
+        xml == '<?xml version="1.1" encoding="UTF-16"?><!DOCTYPE product 
SYSTEM "product.dtd"><product><id>1</id></product>'
+    }
+    
+    void 'toXml builds expected XML using markup DSL'() {
+        when:
+        def xml = XmlUtils.toXml {
+            product {
+                id('1')
+                name('Widget')
+            }
+        }
+
+        then:
+        xml.contains('<product>')
+        xml.contains('<id>1</id>')
+        xml.contains('<name>Widget</name>')
+    }
+
+    void 'toXml supports defaults when no format is provided'() {
+        when:
+        def xml = XmlUtils.toXml {
+            product(type: 'tool')
+        }
+
+        then:
+        xml.contains('type="tool"')
+        !xml.startsWith('<xml version=')
+    }

Review Comment:
   This assertion looks like a typo: the XML declaration begins with `<?xml`, 
not `<xml`. As written, the test will pass even if an XML declaration were 
incorrectly present. Consider changing the check to `!xml.startsWith('<?xml')` 
(or asserting `xml.startsWith('<product')`) so it actually verifies the default 
`omitDeclaration` behavior.



##########
grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy:
##########
@@ -0,0 +1,241 @@
+/*
+ * 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 org.apache.grails.testing.http.client.utils
+
+import javax.xml.XMLConstants
+import javax.xml.parsers.ParserConfigurationException
+import javax.xml.parsers.SAXParser
+
+import java.nio.charset.Charset
+import java.nio.charset.StandardCharsets
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+import groovy.transform.Immutable
+import groovy.transform.NamedDelegate
+import groovy.transform.NamedVariant
+import groovy.xml.FactorySupport
+import groovy.xml.MarkupBuilder
+import groovy.xml.XmlSlurper
+
+import org.xml.sax.SAXException
+
+/**
+ * Utility methods for handling XML.
+ *
+ * @since 7.0.10
+ */
+@CompileStatic
+class XmlUtils {
+
+    private static final String DISALLOW_DOCTYPE_DECL = 
'https://apache.org/xml/features/disallow-doctype-decl'
+    private static final String EXTERNAL_GENERAL_ENTITIES = 
'https://xml.org/sax/features/external-general-entities'
+    private static final String EXTERNAL_PARAMETER_ENTITIES = 
'https://xml.org/sax/features/external-parameter-entities'
+    private static final String FEATURE_SECURE_PROCESSING = 
XMLConstants.FEATURE_SECURE_PROCESSING
+    private static final String LOAD_DTD_GRAMMAR = 
'https://apache.org/xml/features/nonvalidating/load-dtd-grammar'
+    private static final String LOAD_EXTERNAL_DTD = 
'https://apache.org/xml/features/nonvalidating/load-external-dtd'
+
+    private static final Pattern SPACE_AND_EMPTY_ELEMENT_CLOSE = ~/ \/>/
+    private static final String EMPTY_ELEMENT_CLOSE = '/>'
+
+    private static final Pattern LINE_ENDINGS = ~/\r\n|[\r\n]/
+    private static final Pattern XML_DECLARATION = ~/^\s*(<\?xml\b.*?\?>)/
+
+    private static final Map<String, Boolean> SECURE_XML_SLURPER_FEATURES = [
+            (DISALLOW_DOCTYPE_DECL): false,
+            (EXTERNAL_GENERAL_ENTITIES): false,
+            (EXTERNAL_PARAMETER_ENTITIES): false,
+            (FEATURE_SECURE_PROCESSING): true,
+            (LOAD_DTD_GRAMMAR): false,
+            (LOAD_EXTERNAL_DTD): false
+    ].asImmutable()
+
+    /**
+     * Renders XML from the given {@link groovy.xml.MarkupBuilder} DSL closure
+     * using the optionally provided rendering options.
+     *
+     * @param format optional formatting options
+     * @param dsl the closure that produces the XML markup
+     * @return the rendered XML string
+     */
+    @NamedVariant
+    static String toXml(
+            @NamedDelegate Format format = null,
+            @DelegatesTo(strategy = Closure.DELEGATE_FIRST, value = 
MarkupBuilder) Closure<?> dsl
+    ) {
+        def w = new StringWriter()
+        def f = format ?: new Format()
+        def c = (Closure) dsl.clone()
+        c.resolveStrategy = Closure.DELEGATE_FIRST
+        c.delegate = f.newMarkupBuilder(w)
+        c.call()
+
+        def xml = w.toString()
+
+        def declarationMatcher = XML_DECLARATION.matcher(xml)
+        def dslXmlDeclaration = declarationMatcher.find() ? 
declarationMatcher.group(1) : null
+        if (dslXmlDeclaration) {
+            xml = xml.substring(declarationMatcher.end())
+        }
+
+        if (!f.expandEmptyElements && !f.spaceInEmptyElements) {
+            xml = xml.replaceAll(SPACE_AND_EMPTY_ELEMENT_CLOSE, 
EMPTY_ELEMENT_CLOSE)
+        }
+        if (f.prettyPrint && f.lineSeparator != System.lineSeparator()) {
+            xml = xml.replaceAll(LINE_ENDINGS, 
Matcher.quoteReplacement(f.lineSeparator))
+        }
+
+        def lineSeparator = f.prettyPrint ? f.lineSeparator : ''
+        def xmlPrefix = new StringBuilder('')
+        def xmlDeclaration = dslXmlDeclaration ?: (!f.omitDeclaration ? 
f.xmlDeclaration : null)
+        if (xmlDeclaration) {
+            xmlPrefix.append(xmlDeclaration).append(lineSeparator)
+        }
+        if (f.doctype) {
+            xmlPrefix.append(f.doctype).append(lineSeparator)
+        }
+
+        xmlPrefix.append(xml).toString()
+    }
+
+    /**
+     * Creates an {@link XmlSlurper} with secure defaults.
+     * <p>
+     * The default parser is namespace aware, non-validating, permits inline 
DOCTYPE declarations,
+     * and disables external entity expansion plus external DTD loading.
+     *
+     * @param slurperConfig optional XML parser configuration or custom factory
+     * @return configured {@link XmlSlurper}
+     */
+    @NamedVariant
+    static XmlSlurper newXmlSlurper(@NamedDelegate SlurperConfig slurperConfig 
= null) {
+        (slurperConfig ?: new SlurperConfig()).newSlurper()
+    }
+
+    /**
+     * Rendering options for XML generated by {@link #toXml(Format, Closure)}.
+     */
+    @CompileStatic
+    @Immutable(knownImmutableClasses = [Charset])
+    static class Format {
+
+        Charset charset = StandardCharsets.UTF_8
+
+        boolean doubleQuotes = true
+        boolean escapeAttributes = true
+        boolean expandEmptyElements = false
+        boolean omitDeclaration = true
+        boolean omitEmptyAttributes = false
+        boolean omitNullAttributes = false
+        boolean prettyPrint = false
+        boolean spaceInEmptyElements = true
+
+        String doctype
+        String indent = ' '
+        String lineSeparator = System.lineSeparator()
+        String xmlVersion = '1.0'
+
+        MarkupBuilder applyFormat(MarkupBuilder markupBuilder) {
+            markupBuilder.tap {
+                it.doubleQuotes = this.doubleQuotes
+                it.expandEmptyElements = this.expandEmptyElements
+                it.omitEmptyAttributes = this.omitEmptyAttributes
+                it.omitNullAttributes = this.omitNullAttributes
+                it.escapeAttributes = this.escapeAttributes
+            }
+        }
+
+        private IndentPrinter newIndentPrinter(Writer writer) {
+            new IndentPrinter(
+                    writer,
+                    prettyPrint ? indent : '',
+                    prettyPrint,
+                    prettyPrint
+            )
+        }
+
+        MarkupBuilder newMarkupBuilder(Writer writer) {
+            applyFormat(new MarkupBuilder(newIndentPrinter(writer)))
+        }

Review Comment:
   `IndentPrinter` is referenced but not imported/qualified (`private 
IndentPrinter newIndentPrinter(...)`). This will fail compilation unless 
`IndentPrinter` happens to be in the default imports. Add an explicit `import 
groovy.xml.IndentPrinter` (or fully-qualify it) to ensure this class compiles 
under `@CompileStatic`.



##########
grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/TestHttpResponse.groovy:
##########
@@ -0,0 +1,994 @@
+/*
+ * 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 org.apache.grails.testing.http.client
+
+import java.net.http.HttpClient
+import java.net.http.HttpHeaders
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.util.regex.Pattern
+
+import javax.net.ssl.SSLSession
+
+import groovy.transform.NamedDelegate
+import groovy.transform.NamedVariant
+import groovy.xml.slurpersupport.GPathResult
+
+import org.apache.grails.testing.http.client.utils.JsonUtils
+import org.apache.grails.testing.http.client.utils.XmlUtils
+import org.opentest4j.AssertionFailedError
+
+/**
+ * Fluent assertion wrapper around a JDK {@link HttpResponse} used by the HTTP 
client testing helpers.
+ * <p>
+ * This type keeps the underlying response accessible through the standard 
{@link HttpResponse} interface while
+ * adding convenience methods for the kinds of assertions common in 
integration and functional tests: status codes,
+ * headers, body text, structural JSON checks, XML parsing, and regex matching.
+ * <p>
+ * Assertion helpers are chainable and return {@code this} when successful, 
making it practical to express tests as:
+ * <pre>
+ * def response = http('/health')
+ * response.assertStatus(200)
+ *         .assertContains('UP')
+ * </pre>
+ * <p>
+ * JSON helpers use structural comparison instead of raw string comparison, so 
object key ordering does not matter.
+ * Text and regex helpers work on the response body as a {@link String}. 
Parsing helpers such as {@link #json},
+ * {@link #jsonList}, and {@link #xml} convert the body into richer 
representations when tests need to inspect
+ * structured content directly. XML parsing uses a secure default {@link 
groovy.xml.XmlSlurper} configuration and can
+ * be overridden per wrapper with {@link #withXmlSlurper}.
+ * <p>
+ * When assertion methods accept a {@code headers} map, only the provided 
header entries are validated.
+ * Additional headers present on the response are ignored unless explicitly 
included in the expected map.
+ * <p>
+ * Assertion failures are reported as {@link AssertionFailedError} with 
expected/actual values when possible.
+ * Methods that parse JSON may also surface parsing exceptions if the response 
body is not valid for the configured
+ * parser settings.
+ *
+ * @since 7.0.10
+ */
+class TestHttpResponse implements HttpResponse {
+
+    private static final String CONTENT_TYPE = 'Content-Type'
+    private static final Map<String, String> EMPTY = Collections.emptyMap()
+
+    private final HttpResponse<?> delegate
+    private final JsonUtils.SlurperConfig jsonSlurperConfig
+    private final XmlUtils.SlurperConfig xmlSlurperConfig
+
+    TestHttpResponse(HttpResponse<?> response) {
+        this(response, null, null)
+    }
+
+    TestHttpResponse(HttpResponse<?> response, JsonUtils.SlurperConfig 
jsonSlurperConfig, XmlUtils.SlurperConfig xmlSlurperConfig) {
+        this.delegate = response
+        this.jsonSlurperConfig = jsonSlurperConfig ?: new 
JsonUtils.SlurperConfig()
+        this.xmlSlurperConfig = xmlSlurperConfig ?: new 
XmlUtils.SlurperConfig()
+    }
+
+    /**
+     * Wraps a raw {@link HttpResponse} in {@link TestHttpResponse} unless it 
is already wrapped.
+     *
+     * @param response raw or already wrapped response
+     * @return {@code response} unchanged when already a {@link 
TestHttpResponse}; otherwise a new wrapper
+     */
+    static TestHttpResponse wrap(HttpResponse<?> response) {
+        response instanceof TestHttpResponse ?
+                (TestHttpResponse) response : new TestHttpResponse(response)
+    }
+
+    /**
+     * Returns a new response wrapper that uses the provided
+     * {@link 
org.apache.grails.testing.http.client.utils.JsonUtils.SlurperConfig}
+     * for JSON parsing and JSON assertion helpers.
+     * <p>
+     * This method does not mutate the current wrapper. Instead, it returns a 
new wrapper sharing the same underlying
+     * response while applying the supplied {@link JsonUtils.SlurperConfig} to 
methods such as {@link #json()},
+     * {@link #jsonList()}, {@link #assertJson(CharSequence)}, and {@link 
#assertJsonContains(CharSequence)}.
+     *
+     * @param jsonSlurperConfig JSON parser configuration to use for the 
returned wrapper
+     * @return new response wrapper using the supplied JSON parser settings
+     */
+    @NamedVariant
+    TestHttpResponse withJsonSlurper(@NamedDelegate JsonUtils.SlurperConfig 
jsonSlurperConfig = new JsonUtils.SlurperConfig()) {
+        new TestHttpResponse(delegate, jsonSlurperConfig, xmlSlurperConfig)
+    }
+
+    /**
+     * Returns a new response wrapper that uses the provided
+     * {@link 
org.apache.grails.testing.http.client.utils.XmlUtils.SlurperConfig}
+     * for XML parsing helpers.
+     * <p>
+     * This method does not mutate the current wrapper. Instead, it returns a 
new wrapper sharing the same underlying
+     * response while applying the supplied XML parser configuration to {@link 
#xml()}.
+     *
+     * @param xmlSlurperConfig XML parser configuration to use for the 
returned wrapper
+     * @return new response wrapper using the supplied XML parser settings
+     */
+    @NamedVariant
+    TestHttpResponse withXmlSlurper(@NamedDelegate XmlUtils.SlurperConfig 
xmlSlurperConfig = new XmlUtils.SlurperConfig()) {
+        new TestHttpResponse(delegate, jsonSlurperConfig, xmlSlurperConfig)
+    }
+
+    /**
+     * Parses the response body as a JSON object.
+     *
+     * @return parsed JSON object body as a {@link Map}
+     * @throws ClassCastException if the parsed body is not a JSON object
+     */
+    Map json() {
+        (Map) JsonUtils.parseText(delegate.body() as String, jsonSlurperConfig)
+    }
+
+    /**
+     * Parses the response body as a JSON array.
+     *
+     * @return parsed JSON array body as a {@link List}
+     * @throws ClassCastException if the parsed body is not a JSON array
+     */
+    List jsonList() {
+        (List) JsonUtils.parseText(delegate.body() as String, 
jsonSlurperConfig)
+    }
+
+    /**
+     * Parses the response body as XML.
+     *
+     * A secure {@link groovy.xml.XmlSlurper} configuration is used by 
default, disabling external entity expansion and
+     * external DTD loading while remaining namespace aware and 
non-validating. Override the parser with
+     * {@link #withXmlSlurper(XmlUtils.SlurperConfig)} when a test needs 
custom XML parsing behavior.
+     *
+     * @return XML body parsed into a {@link GPathResult} for XML assertions
+     */
+    GPathResult xml() {
+        XmlUtils.newXmlSlurper(xmlSlurperConfig).parseText(delegate.body() as 
String)
+    }
+
+    // region STATUS
+
+    /**
+     * Asserts response status and optional exact header values.
+     *
+     * @param headers expected headers to validate;
+     *        ignored when empty and treated as a subset match when provided,
+     *        meaning additional response headers are ignored
+     * @param status expected HTTP status
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertStatus(Map<String, String> headers = EMPTY, int 
status) {
+        verifyStatus(delegate, status)
+        verifyHeaders(delegate, headers)
+        this
+    }
+
+    /**
+     * Asserts that the response status is not equal to {@code status}.
+     *
+     * @param status unexpected HTTP status
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertNotStatus(int status) {
+        verifyNotStatus(delegate, status)
+        this
+    }
+
+    // endregion
+    // region HEADERS
+
+    /**
+     * Returns the first value for the given header, or {@code null} when 
missing.
+     *
+     * @param name header name to look up
+     * @return first matching value, or {@code null} when no matching header 
exists
+     */
+    String headerValue(String name) {
+        delegate.headers().firstValue(name).orElse(null)
+    }
+
+    /**
+     * Returns the first header value for the given name using 
case-insensitive header-name matching.
+     *
+     * @param name header name to look up
+     * @return first matching value, or {@code null} when no matching header 
exists
+     */
+    String headerValueIgnoreCase(String name) {
+        def values = headerValuesIgnoreCase(delegate, name)
+        if (values) {
+            return values.first()
+        }
+        null
+    }
+
+    /**
+     *  Returns the first value for the given header as a long.
+     *
+     *  @throws NoSuchElementException if no header is found
+     *  @throws NumberFormatException if a value is found, but does not parse 
as a Long
+     */
+    long headerValueAsLong(String name) {
+        delegate.headers().firstValueAsLong(name).orElseThrow()
+    }
+
+    /**
+     * Shortcut for the {@code Content-Type} header value.
+     *
+     * @return first matching value of the {@code Content-Type} header 
(ignoring case),
+     *         or {@code null} when the header does not exist
+     */
+    String getContentType() {
+        headerValueIgnoreCase(CONTENT_TYPE)
+    }
+
+    /**
+     * Indicates whether the response contains the named header.
+     *
+     * @param name header name to look up
+     * @return {@code true} if at least one matching header exists
+     */
+    boolean hasHeader(String name) {
+        delegate.headers().firstValue(name).isPresent()
+    }
+
+    /**
+     * Indicates whether the named header exists and its first value equals 
{@code expected} exactly.
+     *
+     * @param name header name to look up
+     * @param expected expected first header value
+     * @return {@code true} if the first value matches exactly
+     */
+    boolean hasHeaderValue(String name, String expected) {
+        delegate.headers().firstValue(name).map { it == expected 
}.orElse(false)
+    }
+
+    /**
+     * Indicates whether a matching header exists and any of its values equals 
{@code expected} ignoring case.
+     *
+     * @param name header name to look up case-insensitively
+     * @param expected expected header value, compared ignoring case
+     * @return {@code true} if a matching value exists
+     */
+    boolean hasHeaderValueIgnoreCase(String name, String expected) {
+        def expectedLower = expected == null ? null : 
expected.toLowerCase(Locale.ENGLISH)
+        def values = headerValuesIgnoreCase(delegate, name)
+        values.any { String actual ->
+            (actual == null && expectedLower == null) ||
+                    (actual != null && expectedLower != null && 
actual.toLowerCase(Locale.ENGLISH) == expectedLower)
+        }
+    }
+
+    /**
+     * Asserts exact values for the provided header names.
+     *
+     * @param expected expected header values keyed by header name;
+     *        only these entries are checked and extra response headers are 
ignored
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertHeaders(Map<String, String> expected) {
+        verifyHeaders(delegate, expected)
+        this
+    }
+
+    /**
+     * Asserts status and exact values for the provided header names.
+     *
+     * @param expected expected header values keyed by header name;
+     *        only these entries are checked and extra response headers are 
ignored
+     * @param status expected HTTP status
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertHeaders(Map<String, String> expected, int status) {
+        verifyStatus(delegate, status)
+        verifyHeaders(delegate, expected)
+        this
+    }
+
+    /**
+     * Asserts case-insensitive header-name/value equality for all expected 
entries.
+     *
+     * @param expected expected headers to validate;
+     *        only these entries are checked and extra response headers are 
ignored
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertHeadersIgnoreCase(Map<String, String> expected) {
+        verifyHeadersIgnoreCase(delegate, expected)
+        this
+    }
+
+    /**
+     * Asserts status and case-insensitive header-name/value equality.
+     *
+     * @param expected expected headers to validate;
+     *        only these entries are checked and extra response headers are 
ignored
+     * @param status expected HTTP status
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertHeadersIgnoreCase(Map<String, String> expected, int 
status) {
+        verifyStatus(delegate, status)
+        verifyHeadersIgnoreCase(delegate, expected)
+        this
+    }
+
+    /**
+     * Asserts that the provided header values are not present as exact 
first-value matches.
+     *
+     * @param headers header/value pairs that must not match the response
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertNotHeaders(Map<String, String> headers) {
+        verifyNotHeaders(delegate, headers)
+        this
+    }
+
+    // endregion
+    // region JSON
+    // region assertJson
+
+    /**
+     * Asserts response JSON tree equality against a JSON object.
+     *
+     * @param headers expected headers; ignored when empty
+     * @param json expected JSON object
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertJson(Map<String, String> headers = EMPTY, 
Map<String, ?> json) {
+        assertJson(headers, (Object) json)
+    }
+
+    /**
+     * Asserts response JSON tree equality against a JSON array.
+     *
+     * @param headers expected headers; ignored when empty
+     * @param json expected JSON array
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertJson(Map<String, String> headers = EMPTY, List<?> 
json) {
+        assertJson(headers, (Object) json)
+    }
+
+    /**
+     * Asserts response JSON tree equality against a JSON-compatible object.
+     *
+     * <p>Uses structural comparison (for example, object key order does not 
matter).</p>
+     *
+     * @param headers expected headers to validate;
+     *        ignored when empty and treated as a subset match when provided,
+     *        meaning additional response headers are ignored
+     * @param json expected JSON-compatible object tree
+     * @return same response for fluent chaining
+     */
+    TestHttpResponse assertJson(Map<String, String> headers = EMPTY, Object 
json) {

Review Comment:
   The assertion helper APIs here take `headers` as the first parameter and 
`status` as the second (e.g. `assertJson(Map headers=..., int status, ...)`). 
However, many updated specs in this PR call these helpers as 
`response.assertJson(200, 'Content-Type': ..., ...)` / `assertJsonContains(200, 
...)` / `assertHeaders(200, ...)`, which won't match any method signature and 
will fail at runtime/compile time. Either update the helper API to include 
overloads that accept `(int status, Map headers = EMPTY, ...)` (and `(int 
status, ...)`) or update the call sites to pass the headers map first and 
status second.



-- 
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]

Reply via email to