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/01/26 03:08:51 UTC
(grails-core) 08/12: Add HTTP error handling, request/response, CORS, and file upload tests
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 61b87c1a15c9e7e7bba44d9683612a462d4fbda1
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sun Jan 25 22:06:39 2026 -0500
Add HTTP error handling, request/response, CORS, and file upload tests
- Add ErrorHandlingSpec with 23 tests for HTTP error responses
- Tests all common HTTP status codes (400-503)
- Tests JSON error payloads and custom error headers
- Add RequestResponseSpec with 20 tests for HTTP handling
- Tests headers, cookies, sessions, request info
- Add CorsAdvancedSpec with 16 tests for CORS headers
- Tests preflight requests and cross-origin scenarios
- Add FileUploadSpec with 15 tests for multipart uploads
- Tests single/multiple files, validation, content processing
---
.../controllers/functionaltests/UrlMappings.groovy | 59 +++
.../functionaltests/cors/CorsTestController.groovy | 113 ++++++
.../ErrorHandlingTestController.groovy | 215 +++++++++++
.../fileupload/FileUploadTestController.groovy | 241 +++++++++++++
.../RequestResponseTestController.groovy | 270 ++++++++++++++
.../functionaltests/cors/CorsAdvancedSpec.groovy | 301 ++++++++++++++++
.../errorhandling/ErrorHandlingSpec.groovy | 363 +++++++++++++++++++
.../fileupload/FileUploadSpec.groovy | 395 +++++++++++++++++++++
.../requestresponse/RequestResponseSpec.groovy | 388 ++++++++++++++++++++
9 files changed, 2345 insertions(+)
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/UrlMappings.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/UrlMappings.groovy
index 72757b478b..85ca8e1e7b 100644
--- a/grails-test-examples/app1/grails-app/controllers/functionaltests/UrlMappings.groovy
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/UrlMappings.groovy
@@ -43,6 +43,65 @@ class UrlMappings {
"/forward/$param1"(controller: 'forwarding', action: 'two')
+ // === URL Mappings Test Routes ===
+
+ // Static path mapping
+ "/api/test"(controller: 'urlMappingsTest', action: 'index')
+
+ // Path variable mapping
+ "/api/items/$id"(controller: 'urlMappingsTest', action: 'show')
+
+ // Multiple path variables (date pattern)
+ "/api/archive/$year/$month/$day"(controller: 'urlMappingsTest', action: 'pathVars')
+
+ // Named URL mapping
+ name testNamed: "/api/named/$name"(controller: 'urlMappingsTest', action: 'named')
+
+ // Constrained path variable (only uppercase letters allowed)
+ "/api/codes/$code" {
+ controller = 'urlMappingsTest'
+ action = 'constrained'
+ constraints {
+ code matches: /[A-Z]+/
+ }
+ }
+
+ // Wildcard double-star captures remaining path
+ "/api/files/**"(controller: 'urlMappingsTest', action: 'wildcard') {
+ path = { request.forwardURI - '/api/files/' }
+ }
+
+ // HTTP method constraints
+ "/api/resources"(controller: 'urlMappingsTest') {
+ action = [GET: 'list', POST: 'save']
+ }
+ "/api/resources/$id"(controller: 'urlMappingsTest') {
+ action = [GET: 'show', PUT: 'update', DELETE: 'delete']
+ }
+
+ // Optional path variable
+ "/api/optional/$required/$optional?"(controller: 'urlMappingsTest', action: 'optional')
+
+ // HTTP method only mapping
+ "/api/method-test"(controller: 'urlMappingsTest', action: 'httpMethod')
+
+ // Redirect mapping with permanent flag
+ "/api/old-endpoint"(redirect: '/api/test', permanent: true)
+
+ // === CORS Test Routes (under /api/** which has CORS enabled) ===
+ "/api/cors"(controller: 'corsTest', action: 'index')
+ "/api/cors/data"(controller: 'corsTest', action: 'getData')
+ "/api/cors/items/$id"(controller: 'corsTest') {
+ action = [GET: 'getItem', PUT: 'update', DELETE: 'delete']
+ }
+ "/api/cors/items"(controller: 'corsTest') {
+ action = [GET: 'getData', POST: 'create']
+ }
+ "/api/cors/custom-headers"(controller: 'corsTest', action: 'withCustomHeaders')
+ "/api/cors/echo-origin"(controller: 'corsTest', action: 'echoOrigin')
+ "/api/cors/authenticated"(controller: 'corsTest', action: 'authenticated')
+ "/api/cors/slow"(controller: 'corsTest', action: 'slowRequest')
+
"/"(view:"/index")
"500"(view:'/error')
"404"(controller:"errors", action:"notFound")
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/cors/CorsTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/cors/CorsTestController.groovy
new file mode 100644
index 0000000000..d0c4055fc8
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/cors/CorsTestController.groovy
@@ -0,0 +1,113 @@
+/*
+ * 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.cors
+
+import grails.converters.JSON
+
+/**
+ * Controller for testing CORS (Cross-Origin Resource Sharing) functionality.
+ * This controller provides endpoints under /api/* which have CORS enabled
+ * via application.yml configuration.
+ */
+class CorsTestController {
+
+ static responseFormats = ['json']
+
+ // ========== Basic CORS Endpoints ==========
+
+ def index() {
+ render([message: 'CORS test endpoint', path: '/api/corsTest'] as JSON)
+ }
+
+ def getData() {
+ render([
+ data: [
+ [id: 1, name: 'Item 1', description: 'First item'],
+ [id: 2, name: 'Item 2', description: 'Second item'],
+ [id: 3, name: 'Item 3', description: 'Third item']
+ ],
+ total: 3
+ ] as JSON)
+ }
+
+ def getItem() {
+ def id = params.id
+ render([id: id, name: "Item ${id}", retrieved: true] as JSON)
+ }
+
+ // ========== POST/PUT/DELETE endpoints for CORS testing ==========
+
+ def create() {
+ def data = request.JSON ?: [:]
+ render([created: true, data: data, method: 'POST'] as JSON)
+ }
+
+ def update() {
+ def id = params.id
+ def data = request.JSON ?: [:]
+ render([updated: true, id: id, data: data, method: 'PUT'] as JSON)
+ }
+
+ def delete() {
+ def id = params.id
+ render([deleted: true, id: id, method: 'DELETE'] as JSON)
+ }
+
+ // ========== Custom Header Endpoints ==========
+
+ def withCustomHeaders() {
+ response.setHeader('X-Custom-Response', 'custom-value')
+ response.setHeader('X-Request-Timestamp', String.valueOf(System.currentTimeMillis()))
+ render([
+ message: 'Response with custom headers',
+ customHeadersSet: true
+ ] as JSON)
+ }
+
+ def echoOrigin() {
+ def origin = request.getHeader('Origin')
+ render([
+ receivedOrigin: origin,
+ message: 'Origin header received'
+ ] as JSON)
+ }
+
+ // ========== Authenticated/Credentials Endpoint ==========
+
+ def authenticated() {
+ def authHeader = request.getHeader('Authorization')
+ def hasCredentials = authHeader != null
+ render([
+ authenticated: hasCredentials,
+ authType: hasCredentials ? authHeader.split(' ')[0] : null,
+ message: hasCredentials ? 'Credentials received' : 'No credentials'
+ ] as JSON)
+ }
+
+ // ========== Long-running request for timing ==========
+
+ def slowRequest() {
+ Thread.sleep(100) // Simulate processing
+ render([
+ completed: true,
+ processingTime: 100,
+ message: 'Slow request completed'
+ ] as JSON)
+ }
+}
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/errorhandling/ErrorHandlingTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/errorhandling/ErrorHandlingTestController.groovy
new file mode 100644
index 0000000000..0b7ae3e3fc
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/errorhandling/ErrorHandlingTestController.groovy
@@ -0,0 +1,215 @@
+/*
+ * 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.errorhandling
+
+import grails.converters.JSON
+import grails.web.http.HttpHeaders
+
+/**
+ * Controller demonstrating various error handling patterns in Grails.
+ */
+class ErrorHandlingTestController {
+
+ static responseFormats = ['json', 'html']
+
+ // ========== HTTP Status Code Tests ==========
+
+ def renderNotFound() {
+ render status: 404, text: 'Resource not found'
+ }
+
+ def renderBadRequest() {
+ render status: 400, text: 'Bad request'
+ }
+
+ def renderUnauthorized() {
+ render status: 401, text: 'Unauthorized'
+ }
+
+ def renderForbidden() {
+ render status: 403, text: 'Forbidden'
+ }
+
+ def renderMethodNotAllowed() {
+ render status: 405, text: 'Method not allowed'
+ }
+
+ def renderConflict() {
+ render status: 409, text: 'Conflict'
+ }
+
+ def renderGone() {
+ render status: 410, text: 'Gone'
+ }
+
+ def renderUnprocessableEntity() {
+ render status: 422, text: 'Unprocessable entity'
+ }
+
+ def renderTooManyRequests() {
+ render status: 429, text: 'Too many requests'
+ }
+
+ def renderInternalServerError() {
+ render status: 500, text: 'Internal server error'
+ }
+
+ def renderServiceUnavailable() {
+ render status: 503, text: 'Service unavailable'
+ }
+
+ // ========== JSON Error Response Tests ==========
+
+ def jsonNotFound() {
+ response.status = 404
+ render([error: 'not_found', message: 'The requested resource was not found'] as JSON)
+ }
+
+ def jsonBadRequest() {
+ response.status = 400
+ render([error: 'bad_request', message: 'Invalid request parameters', details: [field: 'name', issue: 'required']] as JSON)
+ }
+
+ def jsonValidationError() {
+ response.status = 422
+ render([
+ error: 'validation_error',
+ message: 'Validation failed',
+ errors: [
+ [field: 'email', message: 'Invalid email format'],
+ [field: 'age', message: 'Must be at least 18']
+ ]
+ ] as JSON)
+ }
+
+ def jsonServerError() {
+ response.status = 500
+ render([error: 'internal_error', message: 'An unexpected error occurred', requestId: UUID.randomUUID().toString()] as JSON)
+ }
+
+ // ========== Exception Throwing Tests ==========
+
+ def throwRuntimeException() {
+ throw new RuntimeException('A runtime error occurred')
+ }
+
+ def throwIllegalArgumentException() {
+ throw new IllegalArgumentException('Invalid argument provided')
+ }
+
+ def throwIllegalStateException() {
+ throw new IllegalStateException('Invalid state')
+ }
+
+ def throwNullPointerException() {
+ String s = null
+ s.length() // This will throw NPE
+ }
+
+ def throwIndexOutOfBounds() {
+ def list = []
+ list[10] // This will throw IndexOutOfBoundsException
+ }
+
+ def throwArithmeticException() {
+ def result = 1 / 0 // This will throw ArithmeticException
+ render([result: result] as JSON)
+ }
+
+ def throwNumberFormatException() {
+ Integer.parseInt('not-a-number')
+ }
+
+ def throwCustomBusinessException() {
+ throw new BusinessException('INVALID_ORDER', 'Order cannot be processed')
+ }
+
+ def throwNestedExceptions() {
+ try {
+ throw new IllegalArgumentException('Root cause')
+ } catch (Exception e) {
+ throw new RuntimeException('Wrapper exception', e)
+ }
+ }
+
+ // ========== Conditional Error Handling ==========
+
+ def conditionalError() {
+ def condition = params.condition
+ switch (condition) {
+ case 'notfound':
+ response.status = 404
+ render([error: 'not_found'] as JSON)
+ break
+ case 'badrequest':
+ response.status = 400
+ render([error: 'bad_request'] as JSON)
+ break
+ case 'forbidden':
+ response.status = 403
+ render([error: 'forbidden'] as JSON)
+ break
+ case 'error':
+ throw new RuntimeException('Conditional error triggered')
+ default:
+ render([status: 'ok', condition: condition] as JSON)
+ }
+ }
+
+ // ========== Response with Headers ==========
+
+ def errorWithHeaders() {
+ response.status = 429
+ response.setHeader('Retry-After', '60')
+ response.setHeader('X-RateLimit-Limit', '100')
+ response.setHeader('X-RateLimit-Remaining', '0')
+ response.setHeader('X-RateLimit-Reset', String.valueOf(System.currentTimeMillis() + 60000))
+ render([error: 'rate_limited', retryAfter: 60] as JSON)
+ }
+
+ def notFoundWithHints() {
+ response.status = 404
+ response.setHeader('X-Suggested-Resource', '/api/items')
+ render([error: 'not_found', suggestions: ['/api/items', '/api/products']] as JSON)
+ }
+
+ // ========== Successful Operations for Comparison ==========
+
+ def success() {
+ render([status: 'ok', message: 'Operation successful'] as JSON)
+ }
+
+ def successWithData() {
+ render([status: 'ok', data: [id: 1, name: 'Test Item', createdAt: new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'")]] as JSON)
+ }
+}
+
+/**
+ * Custom business exception for testing exception handling.
+ */
+class BusinessException extends RuntimeException {
+ String code
+ String description
+
+ BusinessException(String code, String description) {
+ super("$code: $description")
+ this.code = code
+ this.description = description
+ }
+}
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/fileupload/FileUploadTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/fileupload/FileUploadTestController.groovy
new file mode 100644
index 0000000000..c67d7318f3
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/fileupload/FileUploadTestController.groovy
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package functionaltests.fileupload
+
+import grails.converters.JSON
+import org.springframework.web.multipart.MultipartFile
+
+/**
+ * Controller for testing file upload functionality in Grails.
+ * Tests various file upload patterns including single file, multiple files,
+ * file validation, and metadata extraction.
+ */
+class FileUploadTestController {
+
+ static responseFormats = ['json', 'html']
+
+ // ========== Single File Upload ==========
+
+ def uploadSingle() {
+ def file = request.getFile('file')
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ render([
+ success: true,
+ filename: file.originalFilename,
+ size: file.size,
+ contentType: file.contentType
+ ] as JSON)
+ }
+
+ def uploadWithMetadata() {
+ def file = request.getFile('file')
+ def description = params.description
+ def category = params.category
+
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ render([
+ success: true,
+ filename: file.originalFilename,
+ size: file.size,
+ contentType: file.contentType,
+ description: description,
+ category: category
+ ] as JSON)
+ }
+
+ // ========== Multiple File Upload ==========
+
+ def uploadMultiple() {
+ def files = request.getFiles('files')
+ if (!files || files.every { it.empty }) {
+ response.status = 400
+ render([error: 'no_files', message: 'No files uploaded'] as JSON)
+ return
+ }
+
+ def uploadedFiles = files.findAll { !it.empty }.collect { file ->
+ [
+ filename: file.originalFilename,
+ size: file.size,
+ contentType: file.contentType
+ ]
+ }
+
+ render([
+ success: true,
+ count: uploadedFiles.size(),
+ files: uploadedFiles
+ ] as JSON)
+ }
+
+ // ========== File Content Processing ==========
+
+ def uploadTextFile() {
+ def file = request.getFile('file')
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ def content = new String(file.bytes, 'UTF-8')
+ def lineCount = content.count('\n') + 1
+ def wordCount = content.split(/\s+/).length
+
+ render([
+ success: true,
+ filename: file.originalFilename,
+ size: file.size,
+ lineCount: lineCount,
+ wordCount: wordCount,
+ preview: content.take(100)
+ ] as JSON)
+ }
+
+ def uploadAndEcho() {
+ def file = request.getFile('file')
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ def content = new String(file.bytes, 'UTF-8')
+ render([
+ success: true,
+ filename: file.originalFilename,
+ content: content
+ ] as JSON)
+ }
+
+ // ========== File Validation ==========
+
+ def uploadWithValidation() {
+ def file = request.getFile('file')
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ // Size validation (max 10KB for this test)
+ def maxSize = 10 * 1024
+ if (file.size > maxSize) {
+ response.status = 400
+ render([error: 'file_too_large', message: "File exceeds max size of ${maxSize} bytes", actualSize: file.size] as JSON)
+ return
+ }
+
+ // Type validation - normalize content type (remove charset if present)
+ def contentType = file.contentType?.split(';')?.first()?.trim()
+ def allowedTypes = ['text/plain', 'application/json', 'text/csv']
+ if (!allowedTypes.contains(contentType)) {
+ response.status = 400
+ render([error: 'invalid_type', message: "File type ${file.contentType} not allowed", allowedTypes: allowedTypes] as JSON)
+ return
+ }
+
+ render([
+ success: true,
+ validated: true,
+ filename: file.originalFilename,
+ size: file.size,
+ contentType: file.contentType
+ ] as JSON)
+ }
+
+ def uploadWithExtensionValidation() {
+ def file = request.getFile('file')
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ def allowedExtensions = ['txt', 'csv', 'json', 'xml']
+ def filename = file.originalFilename
+ def extension = filename.contains('.') ? filename.substring(filename.lastIndexOf('.') + 1).toLowerCase() : ''
+
+ if (!allowedExtensions.contains(extension)) {
+ response.status = 400
+ render([error: 'invalid_extension', message: "Extension '${extension}' not allowed", allowedExtensions: allowedExtensions] as JSON)
+ return
+ }
+
+ render([
+ success: true,
+ filename: filename,
+ extension: extension,
+ validated: true
+ ] as JSON)
+ }
+
+ // ========== File Info Extraction ==========
+
+ def getFileInfo() {
+ def file = request.getFile('file')
+ if (!file || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file uploaded'] as JSON)
+ return
+ }
+
+ def filename = file.originalFilename
+ def extension = filename.contains('.') ? filename.substring(filename.lastIndexOf('.') + 1).toLowerCase() : ''
+ def basename = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename
+
+ render([
+ originalFilename: filename,
+ basename: basename,
+ extension: extension,
+ size: file.size,
+ sizeKB: (file.size / 1024).round(2),
+ contentType: file.contentType,
+ isEmpty: file.empty
+ ] as JSON)
+ }
+
+ // ========== Params-based Access ==========
+
+ def uploadViaParams() {
+ def file = params.file
+ if (!file || !(file instanceof MultipartFile) || file.empty) {
+ response.status = 400
+ render([error: 'no_file', message: 'No file in params'] as JSON)
+ return
+ }
+
+ render([
+ success: true,
+ accessedViaParams: true,
+ filename: file.originalFilename,
+ size: file.size
+ ] as JSON)
+ }
+}
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/requestresponse/RequestResponseTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/requestresponse/RequestResponseTestController.groovy
new file mode 100644
index 0000000000..2ccbd247e4
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/requestresponse/RequestResponseTestController.groovy
@@ -0,0 +1,270 @@
+/*
+ * 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.requestresponse
+
+import grails.converters.JSON
+
+/**
+ * Controller demonstrating request/response handling patterns in Grails,
+ * including headers, cookies, session, and request attributes.
+ */
+class RequestResponseTestController {
+
+ static responseFormats = ['json', 'html']
+
+ // ========== Request Header Tests ==========
+
+ def echoHeaders() {
+ def headers = [:]
+ request.headerNames.each { name ->
+ headers[name] = request.getHeader(name)
+ }
+ render([headers: headers] as JSON)
+ }
+
+ def getSpecificHeader() {
+ def headerName = params.headerName ?: 'X-Custom-Header'
+ def headerValue = request.getHeader(headerName)
+ render([headerName: headerName, headerValue: headerValue] as JSON)
+ }
+
+ def checkUserAgent() {
+ def userAgent = request.getHeader('User-Agent')
+ def isBrowser = userAgent?.contains('Mozilla') || userAgent?.contains('Chrome')
+ render([userAgent: userAgent, isBrowser: isBrowser] as JSON)
+ }
+
+ def checkAcceptHeader() {
+ def accept = request.getHeader('Accept')
+ def acceptsJson = accept?.contains('application/json')
+ def acceptsHtml = accept?.contains('text/html')
+ def acceptsAll = accept?.contains('*/*')
+ render([accept: accept, acceptsJson: acceptsJson, acceptsHtml: acceptsHtml, acceptsAll: acceptsAll] as JSON)
+ }
+
+ def checkContentType() {
+ def contentType = request.contentType
+ render([contentType: contentType] as JSON)
+ }
+
+ // ========== Response Header Tests ==========
+
+ def setCustomHeaders() {
+ response.setHeader('X-Custom-Header', 'CustomValue')
+ response.setHeader('X-Request-Id', UUID.randomUUID().toString())
+ response.setHeader('X-Timestamp', String.valueOf(System.currentTimeMillis()))
+ render([status: 'ok', message: 'Headers set'] as JSON)
+ }
+
+ def setCacheHeaders() {
+ response.setHeader('Cache-Control', 'max-age=3600, public')
+ response.setHeader('ETag', '"abc123"')
+ response.setHeader('Last-Modified', 'Wed, 21 Oct 2025 07:28:00 GMT')
+ render([status: 'ok', cached: true] as JSON)
+ }
+
+ def setNoCacheHeaders() {
+ response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
+ response.setHeader('Pragma', 'no-cache')
+ response.setHeader('Expires', '0')
+ render([status: 'ok', cached: false] as JSON)
+ }
+
+ def setContentDisposition() {
+ response.setHeader('Content-Disposition', 'attachment; filename="report.pdf"')
+ response.contentType = 'application/pdf'
+ render([status: 'ok', downloadable: true] as JSON)
+ }
+
+ def setMultipleCustomHeaders() {
+ 5.times { i ->
+ response.setHeader("X-Custom-${i}", "Value-${i}")
+ }
+ render([status: 'ok', headersSet: 5] as JSON)
+ }
+
+ // ========== Cookie Tests ==========
+
+ def setCookie() {
+ def cookieName = params.name ?: 'testCookie'
+ def cookieValue = params.value ?: 'testValue'
+ def maxAge = params.int('maxAge') ?: 3600
+
+ def cookie = new jakarta.servlet.http.Cookie(cookieName, cookieValue)
+ cookie.maxAge = maxAge
+ cookie.path = '/'
+ response.addCookie(cookie)
+
+ render([status: 'ok', cookieSet: true, name: cookieName, value: cookieValue, maxAge: maxAge] as JSON)
+ }
+
+ def setSecureCookie() {
+ def cookie = new jakarta.servlet.http.Cookie('secureCookie', 'secureValue')
+ cookie.maxAge = 3600
+ cookie.path = '/'
+ cookie.secure = true
+ cookie.httpOnly = true
+ response.addCookie(cookie)
+
+ render([status: 'ok', secure: true, httpOnly: true] as JSON)
+ }
+
+ def setMultipleCookies() {
+ 3.times { i ->
+ def cookie = new jakarta.servlet.http.Cookie("cookie${i}", "value${i}")
+ cookie.maxAge = 3600
+ cookie.path = '/'
+ response.addCookie(cookie)
+ }
+ render([status: 'ok', cookiesSet: 3] as JSON)
+ }
+
+ def getCookies() {
+ def cookies = request.cookies?.collectEntries { cookie ->
+ [cookie.name, cookie.value]
+ } ?: [:]
+ render([cookies: cookies] as JSON)
+ }
+
+ def getSpecificCookie() {
+ def cookieName = params.name ?: 'testCookie'
+ def cookie = request.cookies?.find { it.name == cookieName }
+ render([
+ found: cookie != null,
+ name: cookie?.name,
+ value: cookie?.value
+ ] as JSON)
+ }
+
+ def deleteCookie() {
+ def cookieName = params.name ?: 'testCookie'
+ def cookie = new jakarta.servlet.http.Cookie(cookieName, '')
+ cookie.maxAge = 0
+ cookie.path = '/'
+ response.addCookie(cookie)
+ render([status: 'ok', deleted: cookieName] as JSON)
+ }
+
+ // ========== Session Tests ==========
+
+ def setSessionAttribute() {
+ def key = params.key ?: 'testKey'
+ def value = params.value ?: 'testValue'
+ session[key] = value
+ render([status: 'ok', sessionId: session.id, key: key, value: value] as JSON)
+ }
+
+ def getSessionAttribute() {
+ def key = params.key ?: 'testKey'
+ def value = session[key]
+ render([sessionId: session.id, key: key, value: value, found: value != null] as JSON)
+ }
+
+ def getAllSessionAttributes() {
+ def attributes = [:]
+ session.attributeNames.each { name ->
+ // Skip internal attributes
+ if (!name.startsWith('org.') && !name.startsWith('SPRING_')) {
+ attributes[name] = session.getAttribute(name)?.toString()
+ }
+ }
+ render([sessionId: session.id, attributes: attributes] as JSON)
+ }
+
+ def removeSessionAttribute() {
+ def key = params.key ?: 'testKey'
+ def previousValue = session[key]
+ session.removeAttribute(key)
+ render([status: 'ok', key: key, previousValue: previousValue, removed: true] as JSON)
+ }
+
+ def invalidateSession() {
+ def oldSessionId = session.id
+ session.invalidate()
+ render([status: 'ok', invalidated: true, oldSessionId: oldSessionId] as JSON)
+ }
+
+ def sessionCounter() {
+ def count = session.counter ?: 0
+ count++
+ session.counter = count
+ render([sessionId: session.id, count: count] as JSON)
+ }
+
+ // ========== Request Attribute Tests ==========
+
+ def setRequestAttribute() {
+ def key = params.key ?: 'requestAttr'
+ def value = params.value ?: 'requestValue'
+ request.setAttribute(key, value)
+ // Read it back to verify
+ def retrieved = request.getAttribute(key)
+ render([key: key, setValue: value, retrievedValue: retrieved] as JSON)
+ }
+
+ def getRequestInfo() {
+ render([
+ method: request.method,
+ uri: request.requestURI,
+ url: request.requestURL.toString(),
+ queryString: request.queryString,
+ contextPath: request.contextPath,
+ servletPath: request.servletPath,
+ scheme: request.scheme,
+ serverName: request.serverName,
+ serverPort: request.serverPort,
+ remoteAddr: request.remoteAddr,
+ localAddr: request.localAddr,
+ protocol: request.protocol
+ ] as JSON)
+ }
+
+ def getRequestParameters() {
+ def params = request.parameterMap.collectEntries { k, v ->
+ [k, v.length == 1 ? v[0] : v.toList()]
+ }
+ render([parameters: params] as JSON)
+ }
+
+ // ========== Content Type and Encoding Tests ==========
+
+ def setContentType() {
+ def contentType = params.contentType ?: 'application/json'
+ response.contentType = contentType
+ render([contentType: contentType] as JSON)
+ }
+
+ def setCharacterEncoding() {
+ def encoding = params.encoding ?: 'UTF-8'
+ response.characterEncoding = encoding
+ render([encoding: encoding, message: 'Encoding set to ' + encoding] as JSON)
+ }
+
+ def unicodeResponse() {
+ response.characterEncoding = 'UTF-8'
+ render([
+ english: 'Hello World',
+ chinese: '你好世界',
+ japanese: 'こんにちは世界',
+ korean: '안녕하세요 세계',
+ arabic: 'مرحبا بالعالم',
+ emoji: '👋🌍🎉'
+ ] as JSON)
+ }
+}
diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/cors/CorsAdvancedSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/cors/CorsAdvancedSpec.groovy
new file mode 100644
index 0000000000..bf86e37560
--- /dev/null
+++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/cors/CorsAdvancedSpec.groovy
@@ -0,0 +1,301 @@
+/*
+ * 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.cors
+
+import functionaltests.Application
+import grails.testing.mixin.integration.Integration
+import grails.gorm.transactions.Rollback
+import groovy.json.JsonSlurper
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.MediaType
+import io.micronaut.http.client.HttpClient
+import spock.lang.Specification
+import spock.lang.Shared
+
+/**
+ * Integration tests for CORS (Cross-Origin Resource Sharing) functionality.
+ * Tests preflight requests, CORS headers, and cross-origin scenarios.
+ *
+ * Note: CORS is enabled for /api/** in application.yml
+ */
+@Integration(applicationClass = Application)
+@Rollback
+class CorsAdvancedSpec extends Specification {
+
+ @Shared
+ HttpClient client
+
+ def setup() {
+ client = HttpClient.create(new URL("http://localhost:${serverPort}"))
+ }
+
+ def cleanup() {
+ client?.close()
+ }
+
+ // ========== Basic CORS Header Tests ==========
+
+ def "GET request to CORS-enabled endpoint includes CORS headers"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors')
+ .header('Origin', 'http://example.com'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ // CORS headers should be present
+ response.header('Access-Control-Allow-Origin') != null
+ }
+
+ def "OPTIONS preflight request returns appropriate headers"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.OPTIONS('/api/cors/data')
+ .header('Origin', 'http://example.com')
+ .header('Access-Control-Request-Method', 'GET'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('Access-Control-Allow-Origin') != null
+ response.header('Access-Control-Allow-Methods') != null
+ }
+
+ def "preflight request for POST method succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.OPTIONS('/api/cors/items')
+ .header('Origin', 'http://example.com')
+ .header('Access-Control-Request-Method', 'POST')
+ .header('Access-Control-Request-Headers', 'Content-Type'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('Access-Control-Allow-Methods')?.contains('POST') ||
+ response.header('Access-Control-Allow-Origin') != null
+ }
+
+ def "preflight request for PUT method succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.OPTIONS('/api/cors/items/1')
+ .header('Origin', 'http://example.com')
+ .header('Access-Control-Request-Method', 'PUT')
+ .header('Access-Control-Request-Headers', 'Content-Type'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ }
+
+ def "preflight request for DELETE method succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.OPTIONS('/api/cors/items/1')
+ .header('Origin', 'http://example.com')
+ .header('Access-Control-Request-Method', 'DELETE'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ }
+
+ // ========== Actual Request Tests ==========
+
+ def "GET request to CORS endpoint returns data"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors/data')
+ .header('Origin', 'http://example.com'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.data.size() == 3
+ json.total == 3
+ }
+
+ def "POST request with CORS headers succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/api/cors/items', '{"name":"New Item"}')
+ .header('Origin', 'http://example.com')
+ .contentType(MediaType.APPLICATION_JSON),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.created == true
+ json.method == 'POST'
+ }
+
+ def "PUT request with CORS headers succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.PUT('/api/cors/items/42', '{"name":"Updated Item"}')
+ .header('Origin', 'http://example.com')
+ .contentType(MediaType.APPLICATION_JSON),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.updated == true
+ json.id == '42'
+ json.method == 'PUT'
+ }
+
+ def "DELETE request with CORS headers succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.DELETE('/api/cors/items/99')
+ .header('Origin', 'http://example.com'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.deleted == true
+ json.id == '99'
+ json.method == 'DELETE'
+ }
+
+ // ========== Custom Headers Tests ==========
+
+ def "response with custom headers includes CORS headers"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors/custom-headers')
+ .header('Origin', 'http://example.com'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.customHeadersSet == true
+ response.header('X-Custom-Response') == 'custom-value'
+ }
+
+ def "echo origin endpoint returns received origin"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors/echo-origin')
+ .header('Origin', 'http://my-app.example.com'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.receivedOrigin == 'http://my-app.example.com'
+ }
+
+ // ========== Credentials Tests ==========
+
+ def "authenticated endpoint receives authorization header"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors/authenticated')
+ .header('Origin', 'http://example.com')
+ .header('Authorization', 'Bearer test-token-123'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.authenticated == true
+ json.authType == 'Bearer'
+ }
+
+ def "authenticated endpoint without credentials returns unauthenticated"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors/authenticated')
+ .header('Origin', 'http://example.com'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.authenticated == false
+ json.message == 'No credentials'
+ }
+
+ // ========== Various Origin Tests ==========
+
+ def "request from localhost origin succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors')
+ .header('Origin', 'http://localhost:3000'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.message == 'CORS test endpoint'
+ }
+
+ def "request from HTTPS origin succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors')
+ .header('Origin', 'https://secure.example.com'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.message == 'CORS test endpoint'
+ }
+
+ def "request with port in origin succeeds"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/cors')
+ .header('Origin', 'http://example.com:8080'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.message == 'CORS test endpoint'
+ }
+}
diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/errorhandling/ErrorHandlingSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/errorhandling/ErrorHandlingSpec.groovy
new file mode 100644
index 0000000000..9c28026cbc
--- /dev/null
+++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/errorhandling/ErrorHandlingSpec.groovy
@@ -0,0 +1,363 @@
+/*
+ * 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.errorhandling
+
+import functionaltests.Application
+import grails.testing.mixin.integration.Integration
+import grails.gorm.transactions.Rollback
+import groovy.json.JsonSlurper
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import spock.lang.Specification
+import spock.lang.Shared
+import spock.lang.Unroll
+
+/**
+ * Integration tests for error handling patterns in Grails controllers.
+ * Tests various HTTP status codes, JSON error responses, exception handling,
+ * and error response headers.
+ */
+@Integration(applicationClass = Application)
+@Rollback
+class ErrorHandlingSpec extends Specification {
+
+ @Shared
+ HttpClient client
+
+ def setup() {
+ client = HttpClient.create(new URL("http://localhost:${serverPort}"))
+ }
+
+ def cleanup() {
+ client?.close()
+ }
+
+ // ========== HTTP Status Code Tests ==========
+
+ def "render 404 Not Found status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderNotFound'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+ }
+
+ def "render 400 Bad Request status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderBadRequest'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.BAD_REQUEST
+ }
+
+ def "render 401 Unauthorized status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderUnauthorized'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.UNAUTHORIZED
+ }
+
+ def "render 403 Forbidden status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderForbidden'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.FORBIDDEN
+ }
+
+ def "render 405 Method Not Allowed status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderMethodNotAllowed'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.METHOD_NOT_ALLOWED
+ }
+
+ def "render 409 Conflict status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderConflict'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.CONFLICT
+ }
+
+ def "render 410 Gone status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderGone'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.GONE
+ }
+
+ def "render 422 Unprocessable Entity status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderUnprocessableEntity'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.UNPROCESSABLE_ENTITY
+ }
+
+ def "render 429 Too Many Requests status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderTooManyRequests'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.TOO_MANY_REQUESTS
+ }
+
+ def "render 500 Internal Server Error status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderInternalServerError'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ }
+
+ def "render 503 Service Unavailable status"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/renderServiceUnavailable'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.SERVICE_UNAVAILABLE
+ }
+
+ // ========== JSON Error Response Tests ==========
+
+ def "JSON 404 error response contains proper structure"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/jsonNotFound').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+ }
+
+ def "JSON 400 error response with validation details"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/jsonBadRequest').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.BAD_REQUEST
+ }
+
+ def "JSON 422 validation error with multiple field errors"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/jsonValidationError').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.UNPROCESSABLE_ENTITY
+ }
+
+ def "JSON 500 error response includes request ID"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/jsonServerError').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ }
+
+ // ========== Conditional Error Handling Tests ==========
+
+ def "conditional error returns 404 when condition is notfound"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/conditionalError?condition=notfound').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+ }
+
+ def "conditional error returns 400 when condition is badrequest"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/conditionalError?condition=badrequest').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.BAD_REQUEST
+ }
+
+ def "conditional error returns 403 when condition is forbidden"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/conditionalError?condition=forbidden').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.FORBIDDEN
+ }
+
+ def "conditional error returns success for unknown condition"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/conditionalError?condition=normal').accept('application/json'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+
+ and:
+ def json = new JsonSlurper().parseText(response.body())
+ json.status == 'ok'
+ json.condition == 'normal'
+ }
+
+ // ========== Error Response Headers Tests ==========
+
+ def "rate limit error includes appropriate headers"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/errorWithHeaders').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.TOO_MANY_REQUESTS
+
+ and:
+ def response = e.response
+ response.header('Retry-After') == '60'
+ response.header('X-RateLimit-Limit') == '100'
+ response.header('X-RateLimit-Remaining') == '0'
+ response.header('X-RateLimit-Reset') != null
+ }
+
+ def "not found error includes suggestion header"() {
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/notFoundWithHints').accept('application/json'),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+
+ and: "suggestion header is present"
+ e.response.header('X-Suggested-Resource') == '/api/items'
+ }
+
+ // ========== Success Comparison Tests ==========
+
+ def "success endpoint returns 200 OK"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/success').accept('application/json'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+
+ and:
+ def json = new JsonSlurper().parseText(response.body())
+ json.status == 'ok'
+ json.message == 'Operation successful'
+ }
+
+ def "success with data returns structured response"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/errorHandlingTest/successWithData').accept('application/json'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+
+ and:
+ def json = new JsonSlurper().parseText(response.body())
+ json.status == 'ok'
+ json.data.id == 1
+ json.data.name == 'Test Item'
+ json.data.createdAt != null
+ }
+}
diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/fileupload/FileUploadSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/fileupload/FileUploadSpec.groovy
new file mode 100644
index 0000000000..a81b33bbd5
--- /dev/null
+++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/fileupload/FileUploadSpec.groovy
@@ -0,0 +1,395 @@
+/*
+ * 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.fileupload
+
+import functionaltests.Application
+import grails.testing.mixin.integration.Integration
+import grails.gorm.transactions.Rollback
+import groovy.json.JsonSlurper
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.MediaType
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import io.micronaut.http.client.multipart.MultipartBody
+import spock.lang.Specification
+import spock.lang.Shared
+
+/**
+ * Integration tests for file upload functionality in Grails.
+ * Tests various file upload patterns including single file, multiple files,
+ * file validation, and metadata extraction.
+ */
+@Integration(applicationClass = Application)
+@Rollback
+class FileUploadSpec extends Specification {
+
+ @Shared
+ HttpClient client
+
+ def setup() {
+ client = HttpClient.create(new URL("http://localhost:${serverPort}"))
+ }
+
+ def cleanup() {
+ client?.close()
+ }
+
+ // ========== Single File Upload Tests ==========
+
+ def "upload single text file returns file info"() {
+ given:
+ def content = 'Hello, this is a test file content!'
+ def body = MultipartBody.builder()
+ .addPart('file', 'test.txt', MediaType.TEXT_PLAIN_TYPE, content.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadSingle', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.filename == 'test.txt'
+ json.size == content.bytes.length
+ }
+
+ def "upload single file without file returns error"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('other', 'value')
+ .build()
+
+ when:
+ client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadSingle', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.BAD_REQUEST
+ }
+
+ def "upload file with metadata includes description and category"() {
+ given:
+ def content = 'File with metadata'
+ def body = MultipartBody.builder()
+ .addPart('file', 'data.txt', MediaType.TEXT_PLAIN_TYPE, content.bytes)
+ .addPart('description', 'My test file')
+ .addPart('category', 'documents')
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadWithMetadata', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.description == 'My test file'
+ json.category == 'documents'
+ }
+
+ // ========== Multiple File Upload Tests ==========
+
+ def "upload multiple files returns all file info"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('files', 'file1.txt', MediaType.TEXT_PLAIN_TYPE, 'Content 1'.bytes)
+ .addPart('files', 'file2.txt', MediaType.TEXT_PLAIN_TYPE, 'Content 2'.bytes)
+ .addPart('files', 'file3.txt', MediaType.TEXT_PLAIN_TYPE, 'Content 3'.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadMultiple', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.count == 3
+ json.files.size() == 3
+ json.files*.filename.containsAll(['file1.txt', 'file2.txt', 'file3.txt'])
+ }
+
+ // ========== File Content Processing Tests ==========
+
+ def "upload text file returns line and word count"() {
+ given:
+ def content = '''Line 1
+Line 2
+Line 3
+This is a longer line with more words'''
+ def body = MultipartBody.builder()
+ .addPart('file', 'multiline.txt', MediaType.TEXT_PLAIN_TYPE, content.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadTextFile', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.lineCount == 4
+ json.wordCount > 0
+ json.preview.startsWith('Line 1')
+ }
+
+ def "upload and echo returns original content"() {
+ given:
+ def content = 'Echo this content back to me!'
+ def body = MultipartBody.builder()
+ .addPart('file', 'echo.txt', MediaType.TEXT_PLAIN_TYPE, content.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadAndEcho', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.content == content
+ }
+
+ // ========== File Validation Tests ==========
+
+ def "upload file with allowed type passes validation"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('file', 'valid.txt', MediaType.TEXT_PLAIN_TYPE, 'Valid content'.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadWithValidation', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.validated == true
+ }
+
+ def "upload file with valid extension passes validation"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('file', 'data.json', MediaType.APPLICATION_JSON_TYPE, '{"key":"value"}'.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadWithExtensionValidation', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.validated == true
+ json.extension == 'json'
+ }
+
+ def "upload file with csv extension passes validation"() {
+ given:
+ def csvContent = 'name,age,city\nJohn,30,NYC\nJane,25,LA'
+ def body = MultipartBody.builder()
+ .addPart('file', 'data.csv', MediaType.of('text/csv'), csvContent.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadWithExtensionValidation', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.extension == 'csv'
+ }
+
+ // ========== File Info Extraction Tests ==========
+
+ def "get file info extracts all metadata"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('file', 'document.txt', MediaType.TEXT_PLAIN_TYPE, 'Some content here'.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/getFileInfo', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.originalFilename == 'document.txt'
+ json.basename == 'document'
+ json.extension == 'txt'
+ json.size == 'Some content here'.bytes.length
+ json.isEmpty == false
+ }
+
+ def "get file info handles filename without extension"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('file', 'README', MediaType.TEXT_PLAIN_TYPE, 'Readme content'.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/getFileInfo', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.originalFilename == 'README'
+ json.basename == 'README'
+ json.extension == ''
+ }
+
+ // ========== Params Access Tests ==========
+
+ def "upload via params accesses file correctly"() {
+ given:
+ def body = MultipartBody.builder()
+ .addPart('file', 'params-test.txt', MediaType.TEXT_PLAIN_TYPE, 'Accessed via params'.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadViaParams', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.accessedViaParams == true
+ json.filename == 'params-test.txt'
+ }
+
+ // ========== Large File Tests ==========
+
+ def "upload larger text file succeeds"() {
+ given:
+ def content = ('X' * 1000) // 1KB of X characters
+ def body = MultipartBody.builder()
+ .addPart('file', 'large.txt', MediaType.TEXT_PLAIN_TYPE, content.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadSingle', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.size == 1000
+ }
+
+ def "upload json file with content"() {
+ given:
+ def jsonContent = '{"users":[{"name":"Alice","age":30},{"name":"Bob","age":25}]}'
+ def body = MultipartBody.builder()
+ .addPart('file', 'users.json', MediaType.APPLICATION_JSON_TYPE, jsonContent.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadAndEcho', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.filename == 'users.json'
+ json.content == jsonContent
+ }
+
+ def "upload xml file with content"() {
+ given:
+ def xmlContent = '<?xml version="1.0"?><root><item id="1">Test</item></root>'
+ def body = MultipartBody.builder()
+ .addPart('file', 'data.xml', MediaType.APPLICATION_XML_TYPE, xmlContent.bytes)
+ .build()
+
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/fileUploadTest/uploadAndEcho', body)
+ .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.success == true
+ json.filename == 'data.xml'
+ json.content == xmlContent
+ }
+}
diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/requestresponse/RequestResponseSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/requestresponse/RequestResponseSpec.groovy
new file mode 100644
index 0000000000..40f907bbad
--- /dev/null
+++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/requestresponse/RequestResponseSpec.groovy
@@ -0,0 +1,388 @@
+/*
+ * 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.requestresponse
+
+import functionaltests.Application
+import grails.testing.mixin.integration.Integration
+import grails.gorm.transactions.Rollback
+import groovy.json.JsonSlurper
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.MutableHttpRequest
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.cookie.Cookie
+import spock.lang.Specification
+import spock.lang.Shared
+
+/**
+ * Integration tests for request/response handling patterns including
+ * headers, cookies, session management, and request attributes.
+ */
+@Integration(applicationClass = Application)
+@Rollback
+class RequestResponseSpec extends Specification {
+
+ @Shared
+ HttpClient client
+
+ def setup() {
+ client = HttpClient.create(new URL("http://localhost:${serverPort}"))
+ }
+
+ def cleanup() {
+ client?.close()
+ }
+
+ /**
+ * Helper method to find header value case-insensitively.
+ * HTTP headers are case-insensitive per spec.
+ */
+ private String findHeader(Map headers, String name) {
+ def entry = headers.find { k, v -> k.equalsIgnoreCase(name) }
+ return entry?.value
+ }
+
+ // ========== Request Header Tests ==========
+
+ def "echo request headers returns all headers"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/echoHeaders')
+ .header('X-Custom-Header', 'TestValue')
+ .header('X-Another-Header', 'AnotherValue'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ findHeader(json.headers, 'X-Custom-Header') == 'TestValue'
+ findHeader(json.headers, 'X-Another-Header') == 'AnotherValue'
+ }
+
+ def "get specific header returns correct value"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/getSpecificHeader?headerName=X-Test-Header')
+ .header('X-Test-Header', 'MyTestValue'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ // Header name might be normalized/lowercased
+ json.headerValue == 'MyTestValue'
+ }
+
+ def "check accept header detects JSON accept type"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/checkAcceptHeader')
+ .accept('application/json'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.acceptsJson == true
+ }
+
+ // ========== Response Header Tests ==========
+
+ def "set custom headers returns headers in response"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setCustomHeaders'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('X-Custom-Header') == 'CustomValue'
+ response.header('X-Request-Id') != null
+ response.header('X-Timestamp') != null
+ }
+
+ def "set cache headers configures caching properly"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setCacheHeaders'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('Cache-Control') == 'max-age=3600, public'
+ response.header('ETag') == '"abc123"'
+ response.header('Last-Modified') != null
+ }
+
+ def "set no-cache headers prevents caching"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setNoCacheHeaders'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('Cache-Control') == 'no-cache, no-store, must-revalidate'
+ response.header('Pragma') == 'no-cache'
+ response.header('Expires') == '0'
+ }
+
+ def "set content disposition for file download"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setContentDisposition'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('Content-Disposition') == 'attachment; filename="report.pdf"'
+ }
+
+ def "set multiple custom headers returns all headers"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setMultipleCustomHeaders'),
+ String
+ )
+
+ then:
+ response.status == HttpStatus.OK
+ response.header('X-Custom-0') == 'Value-0'
+ response.header('X-Custom-1') == 'Value-1'
+ response.header('X-Custom-4') == 'Value-4'
+ }
+
+ // ========== Cookie Tests ==========
+
+ def "set cookie returns Set-Cookie header"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setCookie?name=myCookie&value=myValue'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.cookieSet == true
+ json.name == 'myCookie'
+ json.value == 'myValue'
+ response.header('Set-Cookie')?.contains('myCookie=myValue')
+ }
+
+ def "set multiple cookies returns multiple Set-Cookie headers"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setMultipleCookies'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.cookiesSet == 3
+ response.headers.getAll('Set-Cookie').size() >= 3
+ }
+
+ def "get cookies reads cookies from request"() {
+ when:
+ MutableHttpRequest<Object> request = HttpRequest.GET('/requestResponseTest/getCookies')
+ .cookie(Cookie.of('testCookie1', 'value1'))
+ .cookie(Cookie.of('testCookie2', 'value2'))
+ HttpResponse<String> response = client.toBlocking().exchange(request, String)
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.cookies['testCookie1'] == 'value1'
+ json.cookies['testCookie2'] == 'value2'
+ }
+
+ def "get specific cookie returns correct cookie value"() {
+ when:
+ MutableHttpRequest<Object> request = HttpRequest.GET('/requestResponseTest/getSpecificCookie?name=myCookie')
+ .cookie(Cookie.of('myCookie', 'cookieValue'))
+ HttpResponse<String> response = client.toBlocking().exchange(request, String)
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.found == true
+ json.name == 'myCookie'
+ json.value == 'cookieValue'
+ }
+
+ def "delete cookie sets max-age to 0"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/deleteCookie?name=deletedCookie'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.deleted == 'deletedCookie'
+ response.header('Set-Cookie')?.contains('Max-Age=0') || response.header('Set-Cookie')?.contains('Expires=')
+ }
+
+ // ========== Session Tests ==========
+
+ def "set session attribute stores value in session"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setSessionAttribute?key=testKey&value=testValue'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.key == 'testKey'
+ json.value == 'testValue'
+ json.sessionId != null
+ }
+
+ def "get session attribute retrieves stored value"() {
+ given:
+ // First set a session attribute
+ HttpResponse<String> setResponse = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setSessionAttribute?key=retrieveKey&value=retrieveValue'),
+ String
+ )
+ def sessionCookie = setResponse.header('Set-Cookie')
+
+ when:
+ // Then retrieve it with the same session
+ MutableHttpRequest<Object> getRequest = HttpRequest.GET('/requestResponseTest/getSessionAttribute?key=retrieveKey')
+ if (sessionCookie) {
+ def cookieValue = sessionCookie.split(';')[0]
+ getRequest = getRequest.header('Cookie', cookieValue)
+ }
+ HttpResponse<String> response = client.toBlocking().exchange(getRequest, String)
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.key == 'retrieveKey'
+ json.value == 'retrieveValue'
+ json.found == true
+ }
+
+ def "session counter increments on each request"() {
+ given:
+ // First request
+ HttpResponse<String> response1 = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/sessionCounter'),
+ String
+ )
+ def json1 = new JsonSlurper().parseText(response1.body())
+ def sessionCookie = response1.header('Set-Cookie')
+
+ when:
+ // Second request with same session
+ MutableHttpRequest<Object> request2 = HttpRequest.GET('/requestResponseTest/sessionCounter')
+ if (sessionCookie) {
+ def cookieValue = sessionCookie.split(';')[0]
+ request2 = request2.header('Cookie', cookieValue)
+ }
+ HttpResponse<String> response2 = client.toBlocking().exchange(request2, String)
+ def json2 = new JsonSlurper().parseText(response2.body())
+
+ then:
+ response1.status == HttpStatus.OK
+ response2.status == HttpStatus.OK
+ json1.count == 1
+ json2.count == 2
+ }
+
+ // ========== Request Info Tests ==========
+
+ def "get request info returns server details"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/getRequestInfo'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.method == 'GET'
+ json.uri == '/requestResponseTest/getRequestInfo'
+ json.scheme == 'http'
+ json.serverPort == serverPort
+ }
+
+ def "get request parameters returns query parameters"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/getRequestParameters?param1=value1¶m2=value2'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.parameters['param1'] == 'value1'
+ json.parameters['param2'] == 'value2'
+ }
+
+ def "set request attribute stores and retrieves value"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/setRequestAttribute?key=myAttr&value=myVal'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.key == 'myAttr'
+ json.setValue == 'myVal'
+ json.retrievedValue == 'myVal'
+ }
+
+ // ========== Content Type and Encoding Tests ==========
+
+ def "unicode response returns characters in multiple languages"() {
+ when:
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/requestResponseTest/unicodeResponse'),
+ String
+ )
+ def json = new JsonSlurper().parseText(response.body())
+
+ then:
+ response.status == HttpStatus.OK
+ json.english == 'Hello World'
+ json.chinese == '你好世界'
+ json.japanese == 'こんにちは世界'
+ json.korean == '안녕하세요 세계'
+ json.emoji == '👋🌍🎉'
+ }
+}