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 43672ec977a5cf7c12e6b543f5d682782ea97b4f Author: James Fredley <[email protected]> AuthorDate: Sun Jan 25 22:05:52 2026 -0500 Add data binding, codecs, and i18n integration tests - Add DataBindingSpec with 20 tests for request parameter binding - Tests type coercion, nested objects, collections, dates - Add CodecsSpec with 15 tests for encoding/decoding - Tests HTML, URL, Base64, JavaScript, MD5, SHA codecs - Add I18nSpec with 17 tests for internationalization - Tests message resolution, locale switching, pluralization - Includes message bundles for English, French, German --- .../binding/AdvancedDataBindingController.groovy | 297 ++++++++++ .../codecs/CodecTestController.groovy | 281 ++++++++++ .../functionaltests/i18n/I18nTestController.groovy | 227 ++++++++ .../domain/functionaltests/binding/Address.groovy | 39 ++ .../functionaltests/binding/Contributor.groovy | 33 ++ .../domain/functionaltests/binding/Employee.groovy | 59 ++ .../domain/functionaltests/binding/Project.groovy | 36 ++ .../domain/functionaltests/binding/Team.groovy | 34 ++ .../functionaltests/binding/TeamMember.groovy | 36 ++ .../app1/grails-app/i18n/messages.properties | 9 + .../app1/grails-app/i18n/messages_de.properties | 9 + .../app1/grails-app/i18n/messages_fr.properties | 9 + .../binding/AdvancedDataBindingSpec.groovy | 595 ++++++++++++++++++++ .../codecs/SecurityCodecsSpec.groovy | 560 +++++++++++++++++++ .../i18n/InternationalizationSpec.groovy | 606 +++++++++++++++++++++ 15 files changed, 2830 insertions(+) diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/binding/AdvancedDataBindingController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/binding/AdvancedDataBindingController.groovy new file mode 100644 index 0000000000..e333e50c30 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/binding/AdvancedDataBindingController.groovy @@ -0,0 +1,297 @@ +/* + * 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.binding + +import grails.converters.JSON +import grails.databinding.SimpleMapDataBindingSource +import grails.validation.Validateable +import grails.web.RequestParameter + +/** + * Controller for testing advanced data binding features. + */ +class AdvancedDataBindingController { + + def grailsWebDataBinder + + /** + * Test basic map-based binding with nested objects. + */ + def bindEmployee() { + def employee = new Employee(params) + render([ + firstName: employee.firstName, + lastName: employee.lastName, + email: employee.email, + salary: employee.salary, + homeAddress: employee.homeAddress ? [ + street: employee.homeAddress.street, + city: employee.homeAddress.city, + state: employee.homeAddress.state + ] : null + ] as JSON) + } + + /** + * Test @BindUsing annotation - email should be lowercased. + */ + def bindWithBindUsing() { + def employee = new Employee(params) + render([ + email: employee.email, + originalEmail: params.email + ] as JSON) + } + + /** + * Test @BindingFormat annotation for dates. + */ + def bindWithDateFormat() { + def employee = new Employee(params) + render([ + hireDate: employee.hireDate?.format('yyyy-MM-dd'), + birthDate: employee.birthDate?.format('yyyy-MM-dd'), + hireDateInput: params.hireDate, + birthDateInput: params.birthDate + ] as JSON) + } + + /** + * Test collection binding to List. + */ + def bindTeamWithMembers() { + def team = new Team(params) + render([ + name: team.name, + members: team.members?.findAll { it != null }?.collect { [name: it.name, role: it.role] } ?: [] + ] as JSON) + } + + /** + * Test Map-based collection binding. + */ + def bindProjectWithContributors() { + def project = new Project(params) + def contributorsMap = [:] + project.contributors?.each { key, contributor -> + contributorsMap[key] = [name: contributor.name, expertise: contributor.expertise] + } + render([ + name: project.name, + contributors: contributorsMap + ] as JSON) + } + + /** + * Test binding with @RequestParameter annotation. + */ + def bindWithRequestParameter( + @RequestParameter('firstName') String givenName, + @RequestParameter('lastName') String familyName, + Integer age + ) { + render([ + givenName: givenName, + familyName: familyName, + age: age + ] as JSON) + } + + /** + * Test bindData method with include/exclude. + */ + def bindWithIncludeExclude() { + def employee = new Employee() + // Only bind firstName and lastName, exclude email + bindData(employee, params, [include: ['firstName', 'lastName']]) + render([ + firstName: employee.firstName, + lastName: employee.lastName, + email: employee.email, // Should be null + salary: employee.salary // Should be null + ] as JSON) + } + + /** + * Test selective property binding using subscript operator. + */ + def bindSelectiveProperties() { + def employee = new Employee() + employee.properties['firstName', 'lastName'] = params + render([ + firstName: employee.firstName, + lastName: employee.lastName, + email: employee.email, // Should be null + salary: employee.salary // Should be null + ] as JSON) + } + + /** + * Test direct data binder usage in service-like scenario. + */ + def bindUsingDirectBinder() { + def employee = new Employee() + grailsWebDataBinder.bind(employee, params as SimpleMapDataBindingSource) + render([ + firstName: employee.firstName, + lastName: employee.lastName, + email: employee.email + ] as JSON) + } + + /** + * Test type conversion errors. + */ + def bindWithTypeConversion(Integer salary, String firstName) { + def hasErrors = hasErrors() + render([ + salary: salary, + firstName: firstName, + hasErrors: hasErrors, + errorCount: errors?.errorCount ?: 0 + ] as JSON) + } + + /** + * Test command object binding. + */ + def bindCommandObject(EmployeeCommand cmd) { + render([ + firstName: cmd.firstName, + lastName: cmd.lastName, + email: cmd.email, + valid: cmd.validate(), + errors: cmd.errors?.allErrors?.collect { it.field } ?: [] + ] as JSON) + } + + /** + * Test nested command object binding. + */ + def bindNestedCommandObject(ContactCommand cmd) { + render([ + name: cmd.name, + address: cmd.address ? [ + street: cmd.address.street, + city: cmd.address.city + ] : null, + valid: cmd.validate() + ] as JSON) + } + + /** + * Test binding JSON request body to command object. + */ + def bindJsonBody(EmployeeCommand cmd) { + render([ + firstName: cmd.firstName, + lastName: cmd.lastName, + email: cmd.email, + valid: cmd.validate() + ] as JSON) + } + + /** + * Test multiple command objects. + */ + def bindMultipleCommandObjects(EmployeeCommand employee, AddressCommand address) { + render([ + employee: [ + firstName: employee.firstName, + lastName: employee.lastName + ], + address: [ + street: address.street, + city: address.city + ] + ] as JSON) + } + + /** + * Test empty string to null conversion. + */ + def bindEmptyStrings() { + def employee = new Employee(params) + render([ + firstName: employee.firstName, + firstNameIsNull: employee.firstName == null, + lastName: employee.lastName, + lastNameIsNull: employee.lastName == null + ] as JSON) + } + + /** + * Test string trimming during binding. + */ + def bindWithTrimming() { + def employee = new Employee(params) + render([ + firstName: employee.firstName, + firstNameLength: employee.firstName?.length() ?: 0, + originalFirstName: params.firstName, + originalLength: params.firstName?.length() ?: 0 + ] as JSON) + } +} + +/** + * Command object for employee data. + */ +class EmployeeCommand implements Validateable { + String firstName + String lastName + String email + + static constraints = { + firstName blank: false + lastName blank: false + email nullable: true, email: true + } +} + +/** + * Command object for address data. + */ +class AddressCommand implements Validateable { + String street + String city + String state + String zipCode + + static constraints = { + street nullable: true + city nullable: true + state nullable: true + zipCode nullable: true + } +} + +/** + * Command object with nested address. + */ +class ContactCommand implements Validateable { + String name + AddressCommand address + + static constraints = { + name blank: false + address nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/codecs/CodecTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/codecs/CodecTestController.groovy new file mode 100644 index 0000000000..ff40193ae5 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/codecs/CodecTestController.groovy @@ -0,0 +1,281 @@ +/* + * 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.codecs + +import grails.converters.JSON +import grails.core.GrailsApplication + +/** + * Controller for testing codec functionality in an integration context. + * Tests various encoding/decoding methods available in Grails. + */ +class CodecTestController { + + GrailsApplication grailsApplication + + /** + * Test HTML encoding to prevent XSS attacks. + */ + def encodeHtml() { + def input = params.input ?: '<script>alert("XSS")</script>' + def encoded = input.encodeAsHTML() + render([ + input: input, + encoded: encoded, + decodedBack: encoded.decodeHTML() + ] as JSON) + } + + /** + * Test URL encoding for safe URL parameters. + */ + def encodeUrl() { + def input = params.input ?: 'hello world&foo=bar' + def encoded = input.encodeAsURL() + render([ + input: input, + encoded: encoded, + decodedBack: encoded.decodeURL() + ] as JSON) + } + + /** + * Test Base64 encoding/decoding. + */ + def encodeBase64() { + def input = params.input ?: 'Hello, World!' + def encoded = input.encodeAsBase64() + def decoded = encoded.decodeBase64() + render([ + input: input, + encoded: encoded, + decodedBack: new String(decoded) + ] as JSON) + } + + /** + * Test Base64 encoding with binary data. + */ + def encodeBase64Binary() { + byte[] data = [0x48, 0x65, 0x6C, 0x6C, 0x6F] as byte[] // "Hello" + def encoded = data.encodeAsBase64() + def decoded = encoded.decodeBase64() + render([ + originalBytes: data.collect { it }, + encoded: encoded, + decodedBytes: decoded.collect { it } + ] as JSON) + } + + /** + * Test MD5 hashing. + */ + def encodeMd5() { + def input = params.input ?: 'password123' + def hash = input.encodeAsMD5() + render([ + input: input, + md5Hash: hash, + hashLength: hash.length() + ] as JSON) + } + + /** + * Test MD5 bytes hashing. + */ + def encodeMd5Bytes() { + def input = params.input ?: 'password123' + def hashBytes = input.encodeAsMD5Bytes() + render([ + input: input, + md5Bytes: hashBytes.collect { it & 0xFF }, // Convert to unsigned + bytesLength: hashBytes.length + ] as JSON) + } + + /** + * Test SHA1 hashing. + */ + def encodeSha1() { + def input = params.input ?: 'password123' + def hash = input.encodeAsSHA1() + render([ + input: input, + sha1Hash: hash, + hashLength: hash.length() + ] as JSON) + } + + /** + * Test SHA1 bytes hashing. + */ + def encodeSha1Bytes() { + def input = params.input ?: 'password123' + def hashBytes = input.encodeAsSHA1Bytes() + render([ + input: input, + sha1Bytes: hashBytes.collect { it & 0xFF }, + bytesLength: hashBytes.length + ] as JSON) + } + + /** + * Test SHA256 hashing. + */ + def encodeSha256() { + def input = params.input ?: 'password123' + def hash = input.encodeAsSHA256() + render([ + input: input, + sha256Hash: hash, + hashLength: hash.length() + ] as JSON) + } + + /** + * Test SHA256 bytes hashing. + */ + def encodeSha256Bytes() { + def input = params.input ?: 'password123' + def hashBytes = input.encodeAsSHA256Bytes() + render([ + input: input, + sha256Bytes: hashBytes.collect { it & 0xFF }, + bytesLength: hashBytes.length + ] as JSON) + } + + /** + * Test Hex encoding/decoding. + */ + def encodeHex() { + def input = params.input ?: 'Hello' + def encoded = input.bytes.encodeAsHex() + def decoded = encoded.decodeHex() + render([ + input: input, + hexEncoded: encoded, + decodedBack: new String(decoded) + ] as JSON) + } + + /** + * Test JavaScript encoding for safe inclusion in JS strings. + */ + def encodeJavaScript() { + def input = params.input ?: "alert('hello');\nvar x = \"test\";" + def encoded = input.encodeAsJavaScript() + render([ + input: input, + encoded: encoded + ] as JSON) + } + + /** + * Test raw output (no encoding). + */ + def encodeRaw() { + def input = params.input ?: '<b>Bold</b>' + def raw = input.encodeAsRaw() + render([ + input: input, + raw: raw.toString(), + rawClass: raw.getClass().name + ] as JSON) + } + + /** + * Test multiple encodings combined (HTML then Base64). + */ + def multipleEncodings() { + def input = params.input ?: '<script>alert(1)</script>' + def htmlEncoded = input.encodeAsHTML() + def base64Encoded = htmlEncoded.encodeAsBase64() + def base64Decoded = base64Encoded.decodeBase64() + def htmlDecoded = new String(base64Decoded).decodeHTML() + render([ + input: input, + htmlEncoded: htmlEncoded, + base64Encoded: base64Encoded, + fullyDecoded: htmlDecoded + ] as JSON) + } + + /** + * Test encoding with special characters. + */ + def encodeSpecialChars() { + def input = params.input ?: '日本語 & émoji 👍 <tag>' + render([ + input: input, + htmlEncoded: input.encodeAsHTML(), + urlEncoded: input.encodeAsURL(), + base64Encoded: input.encodeAsBase64() + ] as JSON) + } + + /** + * Test encoding null values - should not throw errors. + */ + def encodeNull() { + String nullString = null + render([ + nullBase64: nullString?.encodeAsBase64(), + nullMd5: nullString?.encodeAsMD5(), + nullHtml: nullString?.encodeAsHTML() + ] as JSON) + } + + /** + * Test encoding empty strings. + */ + def encodeEmpty() { + def input = '' + render([ + input: input, + base64Encoded: input.encodeAsBase64(), + md5Hash: input.encodeAsMD5(), + sha256Hash: input.encodeAsSHA256(), + htmlEncoded: input.encodeAsHTML() + ] as JSON) + } + + /** + * Test hash consistency - same input should produce same hash. + */ + def hashConsistency() { + def input = params.input ?: 'consistent-input' + def md5_1 = input.encodeAsMD5() + def md5_2 = input.encodeAsMD5() + def sha1_1 = input.encodeAsSHA1() + def sha1_2 = input.encodeAsSHA1() + def sha256_1 = input.encodeAsSHA256() + def sha256_2 = input.encodeAsSHA256() + render([ + input: input, + md5Consistent: md5_1 == md5_2, + sha1Consistent: sha1_1 == sha1_2, + sha256Consistent: sha256_1 == sha256_2, + md5Hash: md5_1, + sha1Hash: sha1_1, + sha256Hash: sha256_1 + ] as JSON) + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/i18n/I18nTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/i18n/I18nTestController.groovy new file mode 100644 index 0000000000..25fc947264 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/i18n/I18nTestController.groovy @@ -0,0 +1,227 @@ +/* + * 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.i18n + +import grails.converters.JSON +import org.springframework.context.MessageSource +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.web.servlet.LocaleResolver +import org.springframework.web.servlet.support.RequestContextUtils + +/** + * Controller for testing internationalization (i18n) features. + * Tests locale switching, message resolution, and formatting. + */ +class I18nTestController { + + MessageSource messageSource + LocaleResolver localeResolver + + /** + * Get a simple message using the current locale. + */ + def getMessage() { + def code = params.code ?: 'app.welcome' + def locale = resolveLocale() + def message = messageSource.getMessage(code, null, locale) + render([ + code: code, + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Get a message with arguments. + */ + def getMessageWithArgs() { + def code = params.code ?: 'app.greeting' + def arg = params.arg ?: 'World' + def locale = resolveLocale() + def message = messageSource.getMessage(code, [arg] as Object[], locale) + render([ + code: code, + arg: arg, + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Get a message with choice format (pluralization). + */ + def getChoiceMessage() { + def count = params.int('count') ?: 0 + def locale = resolveLocale() + def message = messageSource.getMessage('app.itemcount', [count] as Object[], locale) + render([ + code: 'app.itemcount', + count: count, + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Get a date formatted message. + */ + def getDateMessage() { + def locale = resolveLocale() + def date = new Date() + def message = messageSource.getMessage('app.date.format', [date] as Object[], locale) + render([ + code: 'app.date.format', + date: date.format('yyyy-MM-dd'), + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Get a currency formatted message. + */ + def getCurrencyMessage() { + def amount = params.double('amount') ?: 1234.56 + def locale = resolveLocale() + def message = messageSource.getMessage('app.currency', [amount] as Object[], locale) + render([ + code: 'app.currency', + amount: amount, + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Get a percentage formatted message. + */ + def getPercentMessage() { + def value = params.double('value') ?: 0.75 + def locale = resolveLocale() + def message = messageSource.getMessage('app.percent', [value] as Object[], locale) + render([ + code: 'app.percent', + value: value, + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Test message using controller's message() method. + */ + def useControllerMessage() { + def code = params.code ?: 'app.welcome' + def locale = resolveLocale() + // Use the controller's built-in message method + def msg = message(code: code, locale: locale) + render([ + code: code, + locale: locale.toString(), + message: msg + ] as JSON) + } + + /** + * Test message with default value. + */ + def getMessageWithDefault() { + def code = params.code ?: 'non.existent.code' + def defaultMsg = params.defaultMsg ?: 'Default Message' + def locale = resolveLocale() + def message = messageSource.getMessage(code, null, defaultMsg, locale) + render([ + code: code, + defaultMsg: defaultMsg, + locale: locale.toString(), + message: message + ] as JSON) + } + + /** + * Get validation error messages. + */ + def getValidationMessages() { + def locale = resolveLocale() + def messages = [:] + messages.blank = messageSource.getMessage('default.blank.message', ['name', 'User'] as Object[], locale) + messages.nullable = messageSource.getMessage('default.null.message', ['email', 'User'] as Object[], locale) + messages.paginate_prev = messageSource.getMessage('default.paginate.prev', null, locale) + messages.paginate_next = messageSource.getMessage('default.paginate.next', null, locale) + render([ + locale: locale.toString(), + messages: messages + ] as JSON) + } + + /** + * Test multiple messages at once. + */ + def getMultipleMessages() { + def locale = resolveLocale() + def messages = [:] + messages.welcome = messageSource.getMessage('app.welcome', null, locale) + messages.greeting = messageSource.getMessage('app.greeting', ['User'] as Object[], locale) + messages.farewell = messageSource.getMessage('app.farewell', ['User'] as Object[], locale) + render([ + locale: locale.toString(), + messages: messages + ] as JSON) + } + + /** + * Get current locale information. + */ + def getCurrentLocale() { + def locale = resolveLocale() + render([ + language: locale.language, + country: locale.country, + displayName: locale.displayName, + full: locale.toString() + ] as JSON) + } + + /** + * Test locale from Accept-Language header. + */ + def getLocaleFromHeader() { + def requestLocale = request.locale + def contextLocale = LocaleContextHolder.locale + render([ + requestLocale: requestLocale?.toString(), + contextLocale: contextLocale?.toString() + ] as JSON) + } + + private Locale resolveLocale() { + def lang = params.lang + if (lang) { + // Parse locale from parameter + def parts = lang.split('_') + if (parts.length == 2) { + return new Locale(parts[0], parts[1]) + } + return new Locale(lang) + } + // Fall back to request locale or default + return request.locale ?: Locale.ENGLISH + } +} diff --git a/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Address.groovy b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Address.groovy new file mode 100644 index 0000000000..9c628c4f84 --- /dev/null +++ b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Address.groovy @@ -0,0 +1,39 @@ +/* + * 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.binding + +/** + * Address domain class for data binding tests. + */ +class Address { + String street + String city + String state + String zipCode + String country + + static constraints = { + street nullable: true + city nullable: true + state nullable: true + zipCode nullable: true + country nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Contributor.groovy b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Contributor.groovy new file mode 100644 index 0000000000..202d3d6146 --- /dev/null +++ b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Contributor.groovy @@ -0,0 +1,33 @@ +/* + * 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.binding + +/** + * Contributor domain class for testing Map-based binding. + */ +class Contributor { + String name + String expertise + + static constraints = { + name nullable: true + expertise nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Employee.groovy b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Employee.groovy new file mode 100644 index 0000000000..2fc5e27d96 --- /dev/null +++ b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Employee.groovy @@ -0,0 +1,59 @@ +/* + * 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.binding + +import grails.databinding.BindInitializer +import grails.databinding.BindUsing +import grails.databinding.BindingFormat + +/** + * Employee domain class demonstrating advanced data binding features. + */ +class Employee { + String firstName + String lastName + + @BindUsing({ obj, source -> + source['email']?.toLowerCase()?.trim() + }) + String email + + @BindingFormat('MMddyyyy') + Date hireDate + + @BindingFormat('yyyy-MM-dd') + Date birthDate + + Integer salary + + Address homeAddress + Address workAddress + + static constraints = { + firstName nullable: true + lastName nullable: true + email nullable: true + hireDate nullable: true + birthDate nullable: true + salary nullable: true + homeAddress nullable: true + workAddress nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Project.groovy b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Project.groovy new file mode 100644 index 0000000000..1f76efe95d --- /dev/null +++ b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Project.groovy @@ -0,0 +1,36 @@ +/* + * 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.binding + +/** + * Project domain class for testing Map-based binding. + */ +class Project { + String name + String description + + static hasMany = [contributors: Contributor] + Map contributors + + static constraints = { + name nullable: true + description nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Team.groovy b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Team.groovy new file mode 100644 index 0000000000..9982f39850 --- /dev/null +++ b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/Team.groovy @@ -0,0 +1,34 @@ +/* + * 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.binding + +/** + * Team domain class for testing collection binding. + */ +class Team { + String name + + static hasMany = [members: TeamMember] + List members + + static constraints = { + name nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/domain/functionaltests/binding/TeamMember.groovy b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/TeamMember.groovy new file mode 100644 index 0000000000..c5466ea816 --- /dev/null +++ b/grails-test-examples/app1/grails-app/domain/functionaltests/binding/TeamMember.groovy @@ -0,0 +1,36 @@ +/* + * 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.binding + +/** + * TeamMember domain class for testing collection binding. + */ +class TeamMember { + String name + String role + + static belongsTo = [team: Team] + + static constraints = { + name nullable: true + role nullable: true + team nullable: true + } +} diff --git a/grails-test-examples/app1/grails-app/i18n/messages.properties b/grails-test-examples/app1/grails-app/i18n/messages.properties index 09d392c881..a48bae1282 100644 --- a/grails-test-examples/app1/grails-app/i18n/messages.properties +++ b/grails-test-examples/app1/grails-app/i18n/messages.properties @@ -68,3 +68,12 @@ typeMismatch.java.lang.Long=Property {0} must be a valid number typeMismatch.java.lang.Short=Property {0} must be a valid number typeMismatch.java.math.BigDecimal=Property {0} must be a valid number typeMismatch.java.math.BigInteger=Property {0} must be a valid number + +# Custom test messages for i18n testing +app.welcome=Welcome to the Application +app.greeting=Hello, {0}! +app.farewell=Goodbye, {0}. See you soon! +app.itemcount=You have {0,choice,0#no items|1#one item|1<{0} items}. +app.date.format=Today is {0,date,long}. +app.currency=The price is {0,number,currency}. +app.percent=Completion: {0,number,percent} diff --git a/grails-test-examples/app1/grails-app/i18n/messages_de.properties b/grails-test-examples/app1/grails-app/i18n/messages_de.properties index 18cd4a68b2..d967a583d6 100644 --- a/grails-test-examples/app1/grails-app/i18n/messages_de.properties +++ b/grails-test-examples/app1/grails-app/i18n/messages_de.properties @@ -68,3 +68,12 @@ typeMismatch.java.lang.Long=Die Eigenschaft {0} muss eine gültige Zahl sein typeMismatch.java.lang.Short=Die Eigenschaft {0} muss eine gültige Zahl sein typeMismatch.java.math.BigDecimal=Die Eigenschaft {0} muss eine gültige Zahl sein typeMismatch.java.math.BigInteger=Die Eigenschaft {0} muss eine gültige Zahl sein + +# Custom test messages for i18n testing +app.welcome=Willkommen in der Anwendung +app.greeting=Hallo, {0}! +app.farewell=Auf Wiedersehen, {0}. Bis bald! +app.itemcount=Sie haben {0,choice,0#keine Artikel|1#einen Artikel|1<{0} Artikel}. +app.date.format=Heute ist der {0,date,long}. +app.currency=Der Preis ist {0,number,currency}. +app.percent=Fertigstellung: {0,number,percent} diff --git a/grails-test-examples/app1/grails-app/i18n/messages_fr.properties b/grails-test-examples/app1/grails-app/i18n/messages_fr.properties index 93d4bc05f7..ed77e62281 100644 --- a/grails-test-examples/app1/grails-app/i18n/messages_fr.properties +++ b/grails-test-examples/app1/grails-app/i18n/messages_fr.properties @@ -32,3 +32,12 @@ default.not.unique.message=La propriété [{0}] de la classe [{1}] avec la valeu default.paginate.prev=Précédent default.paginate.next=Suivant + +# Custom test messages for i18n testing +app.welcome=Bienvenue dans l'application +app.greeting=Bonjour, {0}! +app.farewell=Au revoir, {0}. À bientôt! +app.itemcount=Vous avez {0,choice,0#aucun article|1#un article|1<{0} articles}. +app.date.format=Aujourd'hui c'est le {0,date,long}. +app.currency=Le prix est de {0,number,currency}. +app.percent=Complétion: {0,number,percent} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/binding/AdvancedDataBindingSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/binding/AdvancedDataBindingSpec.groovy new file mode 100644 index 0000000000..7fb441b62c --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/binding/AdvancedDataBindingSpec.groovy @@ -0,0 +1,595 @@ +/* + * 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.binding + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import spock.lang.Specification + +/** + * Comprehensive integration tests for advanced data binding features. + * + * Tests cover: + * - Map-based binding with nested objects + * - @BindUsing annotation + * - @BindingFormat annotation for dates + * - Collection binding (List and Map) + * - @RequestParameter annotation + * - bindData with include/exclude + * - Selective property binding + * - Direct data binder usage + * - Type conversion errors + * - Command object binding + * - JSON body binding + * - Empty string to null conversion + * - String trimming + */ +@Integration(applicationClass = Application) +class AdvancedDataBindingSpec extends Specification { + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + // ========== Map-Based Binding Tests ========== + + def "test basic map-based binding"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindEmployee?firstName=John&lastName=Doe&salary=50000'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'John' + response.body().lastName == 'Doe' + response.body().salary == 50000 + + cleanup: + client.close() + } + + def "test nested object binding"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindEmployee?firstName=Jane&homeAddress.street=123+Main+St&homeAddress.city=Springfield&homeAddress.state=IL'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'Jane' + response.body().homeAddress != null + response.body().homeAddress.street == '123 Main St' + response.body().homeAddress.city == 'Springfield' + response.body().homeAddress.state == 'IL' + + cleanup: + client.close() + } + + // ========== @BindUsing Annotation Tests ========== + + def "test @BindUsing annotation lowercases and trims email"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithBindUsing?email=John.Doe%40Example.COM'), + Map + ) + + then: + response.status.code == 200 + response.body().email == '[email protected]' + response.body().originalEmail == '[email protected]' + + cleanup: + client.close() + } + + def "test @BindUsing with mixed case email"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithBindUsing?email=TEST.User%40DOMAIN.org'), + Map + ) + + then: + response.status.code == 200 + response.body().email == '[email protected]' + + cleanup: + client.close() + } + + // ========== @BindingFormat Annotation Tests ========== + + def "test @BindingFormat for date parsing - MMddyyyy format"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithDateFormat?hireDate=01152020'), + Map + ) + + then: + response.status.code == 200 + response.body().hireDate == '2020-01-15' + response.body().hireDateInput == '01152020' + + cleanup: + client.close() + } + + def "test @BindingFormat for date parsing - yyyy-MM-dd format"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithDateFormat?birthDate=1990-05-20'), + Map + ) + + then: + response.status.code == 200 + response.body().birthDate == '1990-05-20' + response.body().birthDateInput == '1990-05-20' + + cleanup: + client.close() + } + + def "test multiple date formats in same request"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithDateFormat?hireDate=03012021&birthDate=1985-12-25'), + Map + ) + + then: + response.status.code == 200 + response.body().hireDate == '2021-03-01' + response.body().birthDate == '1985-12-25' + + cleanup: + client.close() + } + + // ========== Collection Binding Tests (List) ========== + + def "test binding to List collection"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindTeamWithMembers?name=Engineering&members%5B0%5D.name=Alice&members%5B0%5D.role=Lead&members%5B1%5D.name=Bob&members%5B1%5D.role=Developer'), + Map + ) + + then: + response.status.code == 200 + response.body().name == 'Engineering' + response.body().members.size() == 2 + response.body().members[0].name == 'Alice' + response.body().members[0].role == 'Lead' + response.body().members[1].name == 'Bob' + response.body().members[1].role == 'Developer' + + cleanup: + client.close() + } + + def "test binding to List with gaps in indices"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindTeamWithMembers?name=QA&members%5B0%5D.name=Carol&members%5B2%5D.name=Dave'), + Map + ) + + then: "only non-null members are returned" + response.status.code == 200 + response.body().name == 'QA' + // Members with gaps in indices - we only get non-null entries + response.body().members.size() == 2 + response.body().members.find { it.name == 'Carol' } != null + response.body().members.find { it.name == 'Dave' } != null + + cleanup: + client.close() + } + + // ========== Map Collection Binding Tests ========== + + def "test binding to Map collection"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindProjectWithContributors?name=GrailsCore&contributors%5Blead%5D.name=John&contributors%5Blead%5D.expertise=Architecture&contributors%5Bdev%5D.name=Jane&contributors%5Bdev%5D.expertise=Testing'), + Map + ) + + then: + response.status.code == 200 + response.body().name == 'GrailsCore' + response.body().contributors.lead.name == 'John' + response.body().contributors.lead.expertise == 'Architecture' + response.body().contributors.dev.name == 'Jane' + response.body().contributors.dev.expertise == 'Testing' + + cleanup: + client.close() + } + + // ========== @RequestParameter Annotation Tests ========== + + def "test @RequestParameter maps different parameter names"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithRequestParameter?firstName=Robert&lastName=Smith&age=30'), + Map + ) + + then: + response.status.code == 200 + response.body().givenName == 'Robert' + response.body().familyName == 'Smith' + response.body().age == 30 + + cleanup: + client.close() + } + + // ========== bindData with Include/Exclude Tests ========== + + def "test bindData with include - only specified properties bound"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithIncludeExclude?firstName=Test&lastName=User&email=test%40example.com&salary=100000'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'Test' + response.body().lastName == 'User' + response.body().email == null + response.body().salary == null + + cleanup: + client.close() + } + + // ========== Selective Property Binding Tests ========== + + def "test selective property binding using subscript operator"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindSelectiveProperties?firstName=Selective&lastName=Test&email=should.not.bind%40test.com&salary=999'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'Selective' + response.body().lastName == 'Test' + response.body().email == null + response.body().salary == null + + cleanup: + client.close() + } + + // ========== Direct Data Binder Usage Tests ========== + + def "test using grailsWebDataBinder directly"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindUsingDirectBinder?firstName=Direct&lastName=Binder&email=DIRECT%40TEST.COM'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'Direct' + response.body().lastName == 'Binder' + // Email should be lowercased due to @BindUsing + response.body().email == '[email protected]' + + cleanup: + client.close() + } + + // ========== Command Object Binding Tests ========== + + def "test command object binding with validation - valid data"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindCommandObject?firstName=Valid&lastName=User&email=valid%40email.com'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'Valid' + response.body().lastName == 'User' + response.body().email == '[email protected]' + response.body().valid == true + response.body().errors.isEmpty() + + cleanup: + client.close() + } + + def "test command object binding with validation - invalid data"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindCommandObject?firstName=&lastName=&email=invalid-email'), + Map + ) + + then: + response.status.code == 200 + response.body().valid == false + response.body().errors.contains('firstName') + response.body().errors.contains('lastName') + + cleanup: + client.close() + } + + def "test nested command object binding"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindNestedCommandObject?name=Contact+Person&address.street=456+Oak+Ave&address.city=Portland'), + Map + ) + + then: + response.status.code == 200 + response.body().name == 'Contact Person' + response.body().address.street == '456 Oak Ave' + response.body().address.city == 'Portland' + + cleanup: + client.close() + } + + // ========== JSON Body Binding Tests ========== + + def "test JSON body binding to command object"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.POST('/advancedDataBinding/bindJsonBody', [ + firstName: 'JsonFirst', + lastName: 'JsonLast', + email: '[email protected]' + ]).contentType(MediaType.APPLICATION_JSON), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'JsonFirst' + response.body().lastName == 'JsonLast' + response.body().email == '[email protected]' + response.body().valid == true + + cleanup: + client.close() + } + + // ========== Multiple Command Objects Tests ========== + + def "test binding multiple command objects"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindMultipleCommandObjects?employee.firstName=Multi&employee.lastName=Test&address.street=789+Pine+Rd&address.city=Seattle'), + Map + ) + + then: + response.status.code == 200 + response.body().employee.firstName == 'Multi' + response.body().employee.lastName == 'Test' + response.body().address.street == '789 Pine Rd' + response.body().address.city == 'Seattle' + + cleanup: + client.close() + } + + // ========== Empty String Conversion Tests ========== + + def "test empty string converts to null"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindEmptyStrings?firstName=&lastName=HasValue'), + Map + ) + + then: + response.status.code == 200 + response.body().firstNameIsNull == true + response.body().lastName == 'HasValue' + response.body().lastNameIsNull == false + + cleanup: + client.close() + } + + // ========== String Trimming Tests ========== + + def "test string trimming during binding"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithTrimming?firstName=+++Trimmed+++'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'Trimmed' + response.body().firstNameLength == 7 + + cleanup: + client.close() + } + + // ========== Type Conversion Tests ========== + + def "test valid type conversion"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindWithTypeConversion?salary=75000&firstName=TypeTest'), + Map + ) + + then: + response.status.code == 200 + response.body().salary == 75000 + response.body().firstName == 'TypeTest' + + cleanup: + client.close() + } + + // ========== Edge Cases ========== + + def "test binding with special characters in values"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindEmployee?firstName=O%27Brien&lastName=M%C3%BCller'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == "O'Brien" + response.body().lastName == 'Müller' + + cleanup: + client.close() + } + + def "test binding with unicode characters"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindEmployee?firstName=%E6%97%A5%E6%9C%AC%E8%AA%9E'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == '日本語' + + cleanup: + client.close() + } + + def "test binding with null parameter values"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/advancedDataBinding/bindEmployee?firstName=TestNull'), + Map + ) + + then: + response.status.code == 200 + response.body().firstName == 'TestNull' + response.body().lastName == null + response.body().homeAddress == null + + cleanup: + client.close() + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/codecs/SecurityCodecsSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/codecs/SecurityCodecsSpec.groovy new file mode 100644 index 0000000000..80879971af --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/codecs/SecurityCodecsSpec.groovy @@ -0,0 +1,560 @@ +/* + * 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.codecs + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import spock.lang.Specification + +/** + * Comprehensive integration tests for Grails codec functionality. + * + * Tests cover: + * - HTML encoding/decoding (XSS prevention) + * - URL encoding/decoding + * - Base64 encoding/decoding + * - MD5, SHA1, SHA256 hashing + * - Hex encoding/decoding + * - JavaScript encoding + * - Raw output handling + * - Edge cases (null, empty, special characters) + * - Hash consistency verification + */ +@Integration(applicationClass = Application) +class SecurityCodecsSpec extends Specification { + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + // ========== HTML Encoding Tests (XSS Prevention) ========== + + def "test HTML encoding escapes dangerous tags"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeHtml?input=%3Cscript%3Ealert(%22XSS%22)%3C/script%3E'), + Map + ) + + then: "script tags should be HTML encoded" + response.status.code == 200 + response.body().input == '<script>alert("XSS")</script>' + response.body().encoded.contains('<script>') + response.body().encoded.contains('</script>') + !response.body().encoded.contains('<script>') + response.body().decodedBack == '<script>alert("XSS")</script>' + + cleanup: + client.close() + } + + def "test HTML encoding escapes quotes"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeHtml?input=%22quoted%22'), + Map + ) + + then: "quotes should be HTML encoded" + response.status.code == 200 + response.body().encoded.contains('"') + response.body().decodedBack == '"quoted"' + + cleanup: + client.close() + } + + def "test HTML encoding escapes ampersands"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeHtml?input=foo%26bar'), + Map + ) + + then: "ampersands should be HTML encoded" + response.status.code == 200 + response.body().input == 'foo&bar' + response.body().encoded.contains('&') + response.body().decodedBack == 'foo&bar' + + cleanup: + client.close() + } + + // ========== URL Encoding Tests ========== + + def "test URL encoding escapes spaces and special chars"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeUrl?input=hello+world%26foo%3Dbar'), + Map + ) + + then: "spaces and special chars should be URL encoded" + response.status.code == 200 + response.body().input == 'hello world&foo=bar' + response.body().encoded.contains('+') || response.body().encoded.contains('%20') + response.body().encoded.contains('%26') + response.body().encoded.contains('%3D') + response.body().decodedBack == 'hello world&foo=bar' + + cleanup: + client.close() + } + + // ========== Base64 Encoding Tests ========== + + def "test Base64 encoding and decoding text"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeBase64?input=Hello%2C+World!'), + Map + ) + + then: "text should be Base64 encoded and decodable" + response.status.code == 200 + response.body().input == 'Hello, World!' + response.body().encoded == 'SGVsbG8sIFdvcmxkIQ==' + response.body().decodedBack == 'Hello, World!' + + cleanup: + client.close() + } + + def "test Base64 encoding with binary data"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeBase64Binary'), + Map + ) + + then: "binary data should be correctly Base64 encoded" + response.status.code == 200 + response.body().originalBytes == [72, 101, 108, 108, 111] // "Hello" in ASCII + response.body().encoded == 'SGVsbG8=' + response.body().decodedBytes == [72, 101, 108, 108, 111] + + cleanup: + client.close() + } + + // ========== MD5 Hash Tests ========== + + def "test MD5 hashing produces consistent 32-char hex string"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeMd5?input=password123'), + Map + ) + + then: "MD5 hash should be 32 characters (hex)" + response.status.code == 200 + response.body().input == 'password123' + response.body().hashLength == 32 + response.body().md5Hash ==~ /^[a-f0-9]{32}$/ + + cleanup: + client.close() + } + + def "test MD5 bytes produces 16 bytes"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeMd5Bytes?input=password123'), + Map + ) + + then: "MD5 bytes should be 16 bytes" + response.status.code == 200 + response.body().bytesLength == 16 + response.body().md5Bytes.size() == 16 + + cleanup: + client.close() + } + + // ========== SHA1 Hash Tests ========== + + def "test SHA1 hashing produces consistent 40-char hex string"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSha1?input=password123'), + Map + ) + + then: "SHA1 hash should be 40 characters (hex)" + response.status.code == 200 + response.body().hashLength == 40 + response.body().sha1Hash ==~ /^[a-f0-9]{40}$/ + + cleanup: + client.close() + } + + def "test SHA1 bytes produces 20 bytes"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSha1Bytes?input=password123'), + Map + ) + + then: "SHA1 bytes should be 20 bytes" + response.status.code == 200 + response.body().bytesLength == 20 + + cleanup: + client.close() + } + + // ========== SHA256 Hash Tests ========== + + def "test SHA256 hashing produces consistent 64-char hex string"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSha256?input=password123'), + Map + ) + + then: "SHA256 hash should be 64 characters (hex)" + response.status.code == 200 + response.body().hashLength == 64 + response.body().sha256Hash ==~ /^[a-f0-9]{64}$/ + + cleanup: + client.close() + } + + def "test SHA256 bytes produces 32 bytes"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSha256Bytes?input=password123'), + Map + ) + + then: "SHA256 bytes should be 32 bytes" + response.status.code == 200 + response.body().bytesLength == 32 + + cleanup: + client.close() + } + + // ========== Hex Encoding Tests ========== + + def "test Hex encoding and decoding"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeHex?input=Hello'), + Map + ) + + then: "text should be Hex encoded and decodable" + response.status.code == 200 + response.body().input == 'Hello' + response.body().hexEncoded == '48656c6c6f' // "Hello" in hex + response.body().decodedBack == 'Hello' + + cleanup: + client.close() + } + + // ========== JavaScript Encoding Tests ========== + + def "test JavaScript encoding escapes quotes and newlines"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET("/codecTest/encodeJavaScript"), + Map + ) + + then: "JavaScript special chars should be escaped" + response.status.code == 200 + response.body().input.contains("'") + response.body().input.contains('"') + response.body().input.contains('\n') + // The encoded output should escape these characters + response.body().encoded.contains("\\'") || response.body().encoded.contains("\\u0027") + + cleanup: + client.close() + } + + // ========== Raw Output Tests ========== + + def "test Raw encoding preserves content without escaping"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeRaw'), + Map + ) + + then: "raw content should be preserved" + response.status.code == 200 + response.body().input == '<b>Bold</b>' + response.body().raw == '<b>Bold</b>' + + cleanup: + client.close() + } + + // ========== Multiple Encodings Tests ========== + + def "test chaining multiple encodings"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/multipleEncodings'), + Map + ) + + then: "multiple encodings should be reversible" + response.status.code == 200 + response.body().input == '<script>alert(1)</script>' + response.body().htmlEncoded.contains('<') + response.body().fullyDecoded == '<script>alert(1)</script>' + + cleanup: + client.close() + } + + // ========== Special Characters Tests ========== + + def "test encoding with Unicode and special characters"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSpecialChars?input=%E6%97%A5%E6%9C%AC%E8%AA%9E+%26+%C3%A9moji+%F0%9F%91%8D+%3Ctag%3E'), + Map + ) + + then: "special characters should be properly encoded" + response.status.code == 200 + response.body().input.contains('日本語') + response.body().htmlEncoded.contains('<tag>') + response.body().urlEncoded != null + response.body().base64Encoded != null + + cleanup: + client.close() + } + + // ========== Null Handling Tests ========== + + def "test encoding null values returns null safely"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeNull'), + Map + ) + + then: "null values should be handled gracefully" + response.status.code == 200 + response.body().nullBase64 == null + response.body().nullMd5 == null + response.body().nullHtml == null + + cleanup: + client.close() + } + + // ========== Empty String Tests ========== + + def "test encoding empty strings"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeEmpty'), + Map + ) + + then: "empty strings should be encoded without errors" + response.status.code == 200 + response.body().input == '' + // Empty string Base64 is empty + response.body().base64Encoded == '' + // Empty string still has a hash + response.body().md5Hash != null + response.body().md5Hash.length() == 32 + response.body().sha256Hash != null + response.body().sha256Hash.length() == 64 + + cleanup: + client.close() + } + + // ========== Hash Consistency Tests ========== + + def "test hash functions produce consistent results"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/hashConsistency?input=test-consistency'), + Map + ) + + then: "same input should always produce same hash" + response.status.code == 200 + response.body().md5Consistent == true + response.body().sha1Consistent == true + response.body().sha256Consistent == true + + cleanup: + client.close() + } + + def "test different inputs produce different hashes"() { + given: + def client = createClient() + + when: + def response1 = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/hashConsistency?input=input1'), + Map + ) + def response2 = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/hashConsistency?input=input2'), + Map + ) + + then: "different inputs should produce different hashes" + response1.status.code == 200 + response2.status.code == 200 + response1.body().md5Hash != response2.body().md5Hash + response1.body().sha1Hash != response2.body().sha1Hash + response1.body().sha256Hash != response2.body().sha256Hash + + cleanup: + client.close() + } + + // ========== Known Hash Values Tests ========== + + def "test MD5 produces known hash for 'hello'"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeMd5?input=hello'), + Map + ) + + then: "MD5 of 'hello' should match known value" + response.status.code == 200 + response.body().md5Hash == '5d41402abc4b2a76b9719d911017c592' + + cleanup: + client.close() + } + + def "test SHA1 produces known hash for 'hello'"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSha1?input=hello'), + Map + ) + + then: "SHA1 of 'hello' should match known value" + response.status.code == 200 + response.body().sha1Hash == 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' + + cleanup: + client.close() + } + + def "test SHA256 produces known hash for 'hello'"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/codecTest/encodeSha256?input=hello'), + Map + ) + + then: "SHA256 of 'hello' should match known value" + response.status.code == 200 + response.body().sha256Hash == '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + + cleanup: + client.close() + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/i18n/InternationalizationSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/i18n/InternationalizationSpec.groovy new file mode 100644 index 0000000000..0570ede1aa --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/i18n/InternationalizationSpec.groovy @@ -0,0 +1,606 @@ +/* + * 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.i18n + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import spock.lang.Specification + +/** + * Comprehensive integration tests for internationalization (i18n) features. + * + * Tests cover: + * - Locale switching (English, German, French) + * - Message resolution from property files + * - Message formatting with arguments + * - Pluralization (choice format) + * - Date/currency/percent formatting + * - Default message fallback + * - Validation error messages + * - Accept-Language header handling + */ +@Integration(applicationClass = Application) +class InternationalizationSpec extends Specification { + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + // ========== Basic Message Resolution Tests ========== + + def "test simple message in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessage?code=app.welcome&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().code == 'app.welcome' + response.body().locale == 'en' + response.body().message == 'Welcome to the Application' + + cleanup: + client.close() + } + + def "test simple message in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessage?code=app.welcome&lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().code == 'app.welcome' + response.body().locale == 'de' + response.body().message == 'Willkommen in der Anwendung' + + cleanup: + client.close() + } + + def "test simple message in French"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessage?code=app.welcome&lang=fr'), + Map + ) + + then: + response.status.code == 200 + response.body().code == 'app.welcome' + response.body().locale == 'fr' + response.body().message == "Bienvenue dans l'application" + + cleanup: + client.close() + } + + // ========== Message With Arguments Tests ========== + + def "test message with argument in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessageWithArgs?code=app.greeting&arg=John&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Hello, John!' + + cleanup: + client.close() + } + + def "test message with argument in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessageWithArgs?code=app.greeting&arg=Johann&lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Hallo, Johann!' + + cleanup: + client.close() + } + + def "test farewell message with argument"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessageWithArgs?code=app.farewell&arg=Alice&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Goodbye, Alice. See you soon!' + + cleanup: + client.close() + } + + // ========== Pluralization Tests (Choice Format) ========== + + def "test choice format - zero items in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getChoiceMessage?count=0&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().count == 0 + response.body().message == 'You have no items.' + + cleanup: + client.close() + } + + def "test choice format - one item in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getChoiceMessage?count=1&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().count == 1 + response.body().message == 'You have one item.' + + cleanup: + client.close() + } + + def "test choice format - multiple items in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getChoiceMessage?count=5&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().count == 5 + response.body().message == 'You have 5 items.' + + cleanup: + client.close() + } + + def "test choice format in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getChoiceMessage?count=0&lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Sie haben keine Artikel.' + + cleanup: + client.close() + } + + def "test choice format - one item in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getChoiceMessage?count=1&lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Sie haben einen Artikel.' + + cleanup: + client.close() + } + + // ========== Date Formatting Tests ========== + + def "test date formatting in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getDateMessage?lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().message.startsWith('Today is ') + // English format: "Today is January 25, 2026." + response.body().message.contains(',') + + cleanup: + client.close() + } + + def "test date formatting in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getDateMessage?lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().message.startsWith('Heute ist der ') + // German format: "Heute ist der 25. Januar 2026." + + cleanup: + client.close() + } + + // ========== Currency Formatting Tests ========== + + def "test currency formatting in English US"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getCurrencyMessage?amount=1234.56&lang=en_US'), + Map + ) + + then: + response.status.code == 200 + response.body().message.contains('$') || response.body().message.contains('1,234.56') + + cleanup: + client.close() + } + + def "test currency formatting in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getCurrencyMessage?amount=1234.56&lang=de_DE'), + Map + ) + + then: + response.status.code == 200 + // German format uses € and different number formatting + response.body().message != null + + cleanup: + client.close() + } + + // ========== Percentage Formatting Tests ========== + + def "test percentage formatting in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getPercentMessage?value=0.75&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().message.contains('75') + response.body().message.contains('%') + + cleanup: + client.close() + } + + // ========== Default Message Fallback Tests ========== + + def "test default message for non-existent code"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessageWithDefault?code=non.existent.key&defaultMsg=Fallback+Message&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Fallback Message' + + cleanup: + client.close() + } + + // ========== Validation Messages Tests ========== + + def "test validation messages in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getValidationMessages?lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().messages.blank.contains('cannot be blank') + response.body().messages.nullable.contains('cannot be null') + response.body().messages.paginate_prev == 'Previous' + response.body().messages.paginate_next == 'Next' + + cleanup: + client.close() + } + + def "test validation messages in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getValidationMessages?lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().messages.paginate_prev == 'Vorherige' + response.body().messages.paginate_next == 'Nächste' + + cleanup: + client.close() + } + + def "test validation messages in French"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getValidationMessages?lang=fr'), + Map + ) + + then: + response.status.code == 200 + response.body().messages.paginate_prev == 'Précédent' + response.body().messages.paginate_next == 'Suivant' + + cleanup: + client.close() + } + + // ========== Multiple Messages Tests ========== + + def "test multiple messages at once in English"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMultipleMessages?lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().messages.welcome == 'Welcome to the Application' + response.body().messages.greeting == 'Hello, User!' + response.body().messages.farewell == 'Goodbye, User. See you soon!' + + cleanup: + client.close() + } + + def "test multiple messages at once in German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMultipleMessages?lang=de'), + Map + ) + + then: + response.status.code == 200 + response.body().messages.welcome == 'Willkommen in der Anwendung' + response.body().messages.greeting == 'Hallo, User!' + response.body().messages.farewell == 'Auf Wiedersehen, User. Bis bald!' + + cleanup: + client.close() + } + + // ========== Locale Information Tests ========== + + def "test current locale information"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getCurrentLocale?lang=de_DE'), + Map + ) + + then: + response.status.code == 200 + response.body().language == 'de' + response.body().country == 'DE' + + cleanup: + client.close() + } + + // ========== Accept-Language Header Tests ========== + + def "test locale from Accept-Language header - German"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getLocaleFromHeader') + .header('Accept-Language', 'de-DE'), + Map + ) + + then: + response.status.code == 200 + // The request locale should reflect the Accept-Language header + response.body().requestLocale?.startsWith('de') || response.body().contextLocale?.startsWith('de') + + cleanup: + client.close() + } + + def "test locale from Accept-Language header - French"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getLocaleFromHeader') + .header('Accept-Language', 'fr-FR'), + Map + ) + + then: + response.status.code == 200 + response.body().requestLocale?.startsWith('fr') || response.body().contextLocale?.startsWith('fr') + + cleanup: + client.close() + } + + // ========== Controller Message Method Tests ========== + + def "test controller message method"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/useControllerMessage?code=app.welcome&lang=en'), + Map + ) + + then: + response.status.code == 200 + response.body().message == 'Welcome to the Application' + + cleanup: + client.close() + } + + // ========== Edge Cases ========== + + def "test fallback to default locale when unsupported locale requested"() { + given: + def client = createClient() + + when: "requesting a locale that doesn't have translations" + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessage?code=app.welcome&lang=xyz'), + Map + ) + + then: "should fall back to default (English) message" + response.status.code == 200 + // Will either get the message or fall back + response.body().message != null + + cleanup: + client.close() + } + + def "test message with special characters"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/i18nTest/getMessageWithArgs?code=app.greeting&arg=%C3%A9l%C3%A8ve&lang=en'), + Map + ) + + then: "special characters should be handled correctly" + response.status.code == 200 + response.body().arg == 'élève' + response.body().message == 'Hello, élève!' + + cleanup: + client.close() + } +}
