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:19 UTC
(grails-core) branch test/bootstrap-antpath-7.0.x created (now 23d3d4be7a)
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a change to branch test/bootstrap-antpath-7.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
at 23d3d4be7a Add unit tests for AntPathMatcher and ByteArrayResource.
This branch includes the following new commits:
new 23d3d4be7a Add unit tests for AntPathMatcher and ByteArrayResource.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(grails-core) 01/01: Add unit tests for AntPathMatcher and ByteArrayResource.
Posted by ja...@apache.org.
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']
+ }
+}