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 <[email protected]> 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 == '👋🌍🎉' + } +}
