You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/10/13 21:35:20 UTC
(grails-core) 01/01: Add unit tests for AntPathMatcher and ByteArrayResource.
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch test/bootstrap-antpath-7.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 23d3d4be7a19797f1b6afb51b18be2fe1dd10c32
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Tue Oct 13 17:34:09 2026 -0400
Add unit tests for AntPathMatcher and ByteArrayResource.
Exercise pattern matching, template extraction, and byte-array resource metadata.
Assisted-by: sisyphus-grok:grok-4.7
---
.../grails/io/support/AntPathMatcherSpec.groovy | 327 +++++++++++++++++++++
.../grails/io/support/ByteArrayResourceSpec.groovy | 97 ++++++
2 files changed, 424 insertions(+)
diff --git a/grails-bootstrap/src/test/groovy/org/grails/io/support/AntPathMatcherSpec.groovy b/grails-bootstrap/src/test/groovy/org/grails/io/support/AntPathMatcherSpec.groovy
new file mode 100644
index 0000000000..244557b8cb
--- /dev/null
+++ b/grails-bootstrap/src/test/groovy/org/grails/io/support/AntPathMatcherSpec.groovy
@@ -0,0 +1,327 @@
+/*
+ * 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.grails.io.support
+
+import java.util.regex.PatternSyntaxException
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class AntPathMatcherSpec extends Specification {
+
+ private final AntPathMatcher matcher = new AntPathMatcher()
+
+ @Unroll
+ void 'match #pattern against #path returns #expected'() {
+ expect:
+ matcher.match(pattern, path) == expected
+
+ where:
+ pattern | path | expected
+ '/docs/file.txt' | '/docs/file.txt' | true
+ '/docs/file.txt' | '/docs/other.txt' | false
+ '/docs/file.txt' | '/docs/file.txt/more' | false
+ '' | '' | true
+ '' | '/' | false
+ '/' | '/' | true
+ '/' | '' | false
+ '/docs' | 'docs' | false
+ 'docs' | '/docs' | false
+ '/docs/' | '/docs' | false
+ '/docs' | '/docs/' | false
+ '/docs/' | '/docs/' | true
+ '/docs//file.txt' | '/docs/file.txt' | true
+ '/docs/file.txt' | '/docs//file.txt' | true
+ 'docs/file.txt' | 'docs/file.txt' | true
+ '/docs/t?st.txt' | '/docs/test.txt' | true
+ '/docs/t?st.txt' | '/docs/tst.txt' | false
+ '/docs/t?st.txt' | '/docs/teest.txt' | false
+ '/docs/?.txt' | '/docs/a.txt' | true
+ '/docs/?.txt' | '/docs/a/b.txt' | false
+ '/docs/*.txt' | '/docs/file.txt' | true
+ '/docs/*.txt' | '/docs/.txt' | true
+ '/docs/*.txt' | '/docs/file.csv' | false
+ '/docs/*.txt' | '/docs/sub/file.txt' | false
+ '/docs/*' | '/docs/' | true
+ '/docs/*' | '/docs' | false
+ '/docs/*/' | '/docs/file' | false
+ '/docs/*/' | '/docs/file/' | true
+ '/docs/**' | '/docs' | true
+ '/docs/**' | '/docs/' | true
+ '/docs/**' | '/docs/a/b/c.txt' | true
+ '/docs/**/file.txt' | '/docs/file.txt' | true
+ '/docs/**/file.txt' | '/docs/a/b/file.txt' | true
+ '/docs/**/file.txt' | '/docs/a/b/other.txt' | false
+ '/docs/**/a/file.txt' | '/docs/file.txt' | false
+ '/docs/**/a/**/file.txt' | '/docs/x/a/y/file.txt' | true
+ '/docs/**/a/b/**/file.txt' | '/docs/x/a/no/a/b/y/file.txt' | true
+ '/docs/**/a/b/**/file.txt' | '/docs/x/a/no/b/y/file.txt' | false
+ '/docs/**/a/b/**/file.txt' | '/docs/x/file.txt' | false
+ '/docs/**/**/file.txt' | '/docs/a/b/file.txt' | true
+ '/docs/**/**' | '/docs' | true
+ '/docs/**/a/*' | '/docs/a' | false
+ '**' | '' | true
+ '*' | '' | false
+ '/**' | '/' | true
+ '/?' | '/' | false
+ '/docs/a.b+(c)[d]$' | '/docs/a.b+(c)[d]$' | true
+ '/docs/a.b' | '/docs/axb' | false
+ '/Docs/*.TXT' | '/docs/file.txt' | false
+ '/docs/ file ' | '/docs/file' | false
+ '/docs/ file ' | '/docs/ file ' | true
+ '/docs/{name}' | '/docs/file' | true
+ '/docs/{id:[0-9]{2}}' | '/docs/42' | true
+ '/docs/{id:[0-9]{2}}' | '/docs/4' | false
+ '/docs/{id:[0-9]+}' | '/docs/name' | false
+ '/docs/{unclosed' | '/docs/{unclosed' | true
+ }
+
+ @Unroll
+ void 'matchStart #pattern against #path differs from full matching when appropriate'() {
+ expect:
+ matcher.matchStart(pattern, path) == startMatch
+ matcher.match(pattern, path) == fullMatch
+
+ where:
+ pattern | path | startMatch | fullMatch
+ '/docs/file.txt' | '/docs' | true | false
+ '/docs/*/file.txt' | '/docs/a' | true | false
+ '/docs/**/file.txt' | '/docs/a/other.txt' | true | false
+ '/docs/**/file.txt' | '/other/a/file.txt' | false | false
+ '/docs/file.txt' | '/docs/other.txt' | false | false
+ '/docs' | '/docs/a' | false | false
+ '/docs' | '/docs/' | false | false
+ '/docs/file.txt' | '/docs/file.txt' | true | true
+ 'docs/file.txt' | '/docs' | false | false
+ '**/file.txt' | '' | true | false
+ '' | '' | true | true
+ }
+
+ @Unroll
+ void 'extract wildcard path from #pattern and #path'() {
+ expect:
+ matcher.extractPathWithinPattern(pattern, path) == expected
+
+ where:
+ pattern | path | expected
+ '/docs/cvs/commit.html' | '/docs/cvs/commit.html' | ''
+ '/docs/*' | '/docs/cvs/commit' | 'cvs/commit'
+ '/docs/cvs/*.html' | '/docs/cvs/commit.html' | 'commit.html'
+ '/docs/**' | '/docs/cvs/commit' | 'cvs/commit'
+ '/docs/**/*.html' | '/docs/cvs/commit.html' | 'cvs/commit.html'
+ '/*.html' | '/docs/cvs/commit.html' | 'docs/cvs/commit.html'
+ '*.html' | '/docs/cvs/commit.html' | '/docs/cvs/commit.html'
+ '*' | '/docs/cvs/commit.html' | '/docs/cvs/commit.html'
+ '/docs/t?st/*' | '/docs/test/file' | 'test/file'
+ '/docs/{name}' | '/docs/file' | ''
+ '/docs/*/fixed/*' | '/docs/a/fixed/b' | 'a/b'
+ '/docs/*' | '/docs' | ''
+ '/docs' | '/docs/a/b' | '/a/b'
+ '' | 'a/b' | 'a/b'
+ '' | '' | ''
+ }
+
+ @Unroll
+ void 'extract URI variables from #pattern and #path'() {
+ expect:
+ matcher.extractUriTemplateVariables(pattern, path) == expected
+
+ where:
+ pattern | path | expected
+ '/users/{user}' | '/users/alice' | [user: 'alice']
+ '/{first}/{second}' | '/one/two' | [first: 'one', second: 'two']
+ '/files/{name}.{extension}' | '/files/report.pdf' | [name: 'report', extension: 'pdf']
+ '/files/{name}.txt' | '/files/.txt' | [name: '']
+ '/users/{id:[0-9]{2}}' | '/users/42' | [id: '42']
+ '/users/{id:(?:ab|cd)}' | '/users/ab' | [id: 'ab']
+ '/users/{id:[0-9]+}' | '/users/alice' | [:]
+ '/{name}/**/{file}.txt' | '/docs/a/b/readme.txt' | [name: 'docs', file: 'readme']
+ '/{name}/{name}' | '/first/last' | [name: 'last']
+ '/docs/file.txt' | '/docs/file.txt' | [:]
+ '/users/{user}' | '/other/alice' | [:]
+ '/{user}/required' | '/alice/other' | [user: 'alice']
+ '' | '' | [:]
+ }
+
+ void 'URI variables preserve insertion order and extraction results are independent'() {
+ when:
+ Map<String, String> first = matcher.extractUriTemplateVariables('/{one}/{two}', '/a/b')
+ Map<String, String> second = matcher.extractUriTemplateVariables('/{one}/{two}', '/c/d')
+
+ then:
+ first.keySet().toList() == ['one', 'two']
+ first == [one: 'a', two: 'b']
+ second == [one: 'c', two: 'd']
+ !first.is(second)
+ }
+
+ @Unroll
+ void 'combine #first and #second using legacy pattern rules'() {
+ expect:
+ matcher.combine(first, second) == expected
+
+ where:
+ first | second | expected
+ null | null | ''
+ '' | '' | ''
+ null | '/hotels' | '/hotels'
+ '/hotels' | null | '/hotels'
+ '' | '/hotels' | '/hotels'
+ '/hotels' | '' | '/hotels'
+ '/hotels' | '/hotels' | '/hotels'
+ '/hotels/*' | '/hotels/booking' | '/hotels/booking'
+ '/hotels' | '/booking' | '/hotels/booking'
+ '/hotels' | 'booking' | '/hotels/booking'
+ '/hotels/' | 'booking' | '/hotels/booking'
+ '/hotels/' | '/booking' | '/hotels//booking'
+ '/hotels/*' | '/booking' | '/hotels/booking'
+ '/hotels/*' | 'booking' | '/hotels/booking'
+ '/hotels/**' | '/booking' | '/hotels/**/booking'
+ '/hotels/**' | 'booking' | '/hotels/**/booking'
+ '/hotels/*' | '{hotel}' | '/hotels/{hotel}'
+ '/hotels/**' | '{hotel}' | '/hotels/**/{hotel}'
+ '/{hotel}' | '/booking' | '/{hotel}/booking'
+ '/*.html' | '/hotels.html' | '/hotels.html'
+ '/*.html' | '/hotels' | '/hotels.html'
+ '/*.html' | '/*.txt' | '/*.html'
+ '/report.html' | '/other.txt' | '/report.html'
+ '/users/{id:[}' | '/users/42' | '/users/{id:[}/users/42'
+ ' ' | 'booking' | ' /booking'
+ }
+
+ @Unroll
+ void 'recognize wildcard patterns without treating URI templates as wildcards'() {
+ expect:
+ matcher.isPattern(path) == expected
+
+ where:
+ path | expected
+ '' | false
+ '/docs/file.txt' | false
+ '/docs/{name}' | false
+ '/docs/*' | true
+ '/docs/**' | true
+ '/docs/?.txt' | true
+ }
+
+ void 'custom path separator applies to matching and both extraction methods and null resets it'() {
+ given:
+ matcher.setPathSeparator('.')
+
+ expect:
+ matcher.match('org.**.File', 'org.grails.io.File')
+ !matcher.match('org.*.File', 'org.grails.io.File')
+ !matcher.match('.org.*', 'org.grails')
+ matcher.matchStart('org.grails.File', 'org.grails')
+ matcher.extractPathWithinPattern('org.*', 'org.grails.io') == 'grails.io'
+ matcher.extractUriTemplateVariables('org.{name}.File', 'org.grails.File') == [name: 'grails']
+
+ when:
+ matcher.setPathSeparator(null)
+
+ then:
+ matcher.match('/org/**/File', '/org/grails/io/File')
+ !matcher.match('org.grails.File', 'org/grails/File')
+ }
+
+ @Unroll
+ void 'null input to #operation is rejected'() {
+ when:
+ matcher."$operation"(*arguments)
+
+ then:
+ thrown(NullPointerException)
+
+ where:
+ operation | arguments
+ 'match' | [null, '/docs']
+ 'match' | ['/docs', null]
+ 'matchStart' | [null, '/docs']
+ 'matchStart' | ['/docs', null]
+ 'isPattern' | [null]
+ 'extractPathWithinPattern' | [null, '/docs']
+ 'extractPathWithinPattern' | ['/docs', null]
+ 'extractUriTemplateVariables' | [null, '/docs']
+ 'extractUriTemplateVariables' | ['/docs', null]
+ }
+
+ @Unroll
+ void 'malformed template regex is rejected by #operation'() {
+ when:
+ matcher."$operation"('/users/{id:[}', '/users/42')
+
+ then:
+ thrown(PatternSyntaxException)
+
+ where:
+ operation << ['match', 'matchStart', 'extractUriTemplateVariables']
+ }
+
+ void 'capturing groups in template regex match but cannot be extracted as extra variables'() {
+ expect:
+ matcher.match('/users/{id:(ab|cd)}', '/users/ab')
+
+ when:
+ matcher.extractUriTemplateVariables('/users/{id:(ab|cd)}', '/users/ab')
+
+ then:
+ thrown(IndexOutOfBoundsException)
+ }
+
+ @Unroll
+ void 'pattern comparator orders #first relative to #second by specificity'() {
+ given:
+ Comparator<String> comparator = matcher.getPatternComparator('/hotels/2')
+
+ expect:
+ Integer.signum(comparator.compare(first, second)) == expected
+ Integer.signum(comparator.compare(second, first)) == -expected
+
+ where:
+ first | second | expected
+ null | null | 0
+ null | '/hotels/*' | 1
+ '/hotels/2' | '/hotels/2' | 0
+ '/hotels/2' | '/hotels/1' | -1
+ '/hotels/new' | '/hotels/*' | -1
+ '/hotels/**' | '/hotels/*' | 1
+ '/hotels/long' | '/hotels/new' | -1
+ '/hotels/{hotel}' | '/hotels/*' | -1
+ '/a/{one}' | '/a/{two}' | 0
+ '/a/{id}/literal' | '/a/literal/*' | -1
+ '/file.*' | '/file' | -1
+ }
+
+ @Unroll
+ void 'count nonoverlapping occurrences of #substring in #text'() {
+ expect:
+ AntPathMatcher.countOccurrencesOf(text, substring) == expected
+
+ where:
+ text | substring | expected
+ null | 'a' | 0
+ 'a' | null | 0
+ '' | 'a' | 0
+ 'a' | '' | 0
+ 'abc' | 'z' | 0
+ 'aaaa' | 'aa' | 2
+ 'aaa' | 'aa' | 1
+ '*a**b*' | '*' | 4
+ }
+}
diff --git a/grails-bootstrap/src/test/groovy/org/grails/io/support/ByteArrayResourceSpec.groovy b/grails-bootstrap/src/test/groovy/org/grails/io/support/ByteArrayResourceSpec.groovy
new file mode 100644
index 0000000000..3f16bb7050
--- /dev/null
+++ b/grails-bootstrap/src/test/groovy/org/grails/io/support/ByteArrayResourceSpec.groovy
@@ -0,0 +1,97 @@
+/*
+ * 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.grails.io.support
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class ByteArrayResourceSpec extends Specification {
+
+ @Unroll
+ void 'byte array resource exposes its content and metadata for #content'() {
+ given:
+ byte[] bytes = content as byte[]
+ def resource = new ByteArrayResource(bytes)
+
+ expect:
+ resource.exists()
+ resource.isReadable()
+ resource.contentLength() == bytes.length
+ resource.lastModified() == 0
+ resource.getDescription() == 'resource loaded from byte array'
+ resource.getFilename() == resource.getDescription()
+ resource.getInputStream().withCloseable { it.readAllBytes() } == bytes
+
+ where:
+ content << [[], [0, 1, 127, -128, -1]]
+ }
+
+ void 'custom description is also used as filename'() {
+ given:
+ def resource = new ByteArrayResource([1, 2] as byte[], 'example.bin')
+
+ expect:
+ resource.getDescription() == 'example.bin'
+ resource.getFilename() == 'example.bin'
+ }
+
+ void 'each input stream has independent position and can be reopened after closing'() {
+ given:
+ def resource = new ByteArrayResource([1, 2, 3] as byte[])
+ InputStream first = resource.getInputStream()
+ InputStream second = resource.getInputStream()
+
+ expect:
+ !first.is(second)
+ first.read() == 1
+ first.read() == 2
+ second.read() == 1
+
+ when:
+ first.close()
+
+ then:
+ resource.getInputStream().withCloseable { it.readAllBytes() } == [1, 2, 3] as byte[]
+ second.read() == 2
+
+ cleanup:
+ first.close()
+ second.close()
+ }
+
+ @Unroll
+ void '#operation is unsupported for an in-memory resource'() {
+ given:
+ def resource = new ByteArrayResource(new byte[0])
+
+ when:
+ resource."$operation"(*arguments)
+
+ then:
+ UnsupportedOperationException exception = thrown()
+ exception.message == "Method $operation not supported"
+
+ where:
+ operation | arguments
+ 'getURL' | []
+ 'getURI' | []
+ 'getFile' | []
+ 'createRelative' | ['other.bin']
+ }
+}