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 c602f2819c07454dba5dee207a01403e7bfc07a6 Author: James Fredley <[email protected]> AuthorDate: Sun Jan 25 22:05:10 2026 -0500 Add async/promises and interceptor integration tests - Add AsyncPromisesSpec with 20 tests for Grails async features - Tests Promise.task, Promise.onComplete/onError callbacks - Tests PromiseList, PromiseMap, and timeout handling - Add InterceptorSpec with 20 tests for controller interceptors - Tests before/after/afterView interceptor phases - Tests matching patterns, model modification, request blocking --- .../async/AsyncTestController.groovy | 285 +++++++++++++++ .../AttributeSettingInterceptor.groovy | 46 +++ .../interceptors/BlockingInterceptor.groovy | 47 +++ .../ConditionalMatchInterceptor.groovy | 52 +++ .../interceptors/FirstInterceptor.groovy | 48 +++ .../interceptors/InterceptorTestController.groovy | 172 +++++++++ .../interceptors/SecondInterceptor.groovy | 48 +++ .../interceptors/SessionInterceptor.groovy | 44 +++ .../interceptors/ThirdInterceptor.groovy | 48 +++ .../interceptors/TimingInterceptor.groovy | 46 +++ .../functionaltests/async/AsyncPromiseSpec.groovy | 390 ++++++++++++++++++++ .../interceptors/InterceptorOrderingSpec.groovy | 407 +++++++++++++++++++++ 12 files changed, 1633 insertions(+) diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/async/AsyncTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/async/AsyncTestController.groovy new file mode 100644 index 0000000000..763062c3df --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/async/AsyncTestController.groovy @@ -0,0 +1,285 @@ +/* + * 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.async + +import functionaltests.services.AsyncProcessingService +import grails.converters.JSON + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +import static grails.async.web.WebPromises.* + +/** + * Controller demonstrating async request handling patterns in Grails. + */ +class AsyncTestController { + + static responseFormats = ['json', 'html'] + + AsyncProcessingService asyncProcessingService + + /** + * Simple async task that completes after a delay. + */ + def simpleTask() { + task { + sleep 100 + render([status: 'completed', message: 'Task finished'] as JSON) + } + } + + /** + * Async task returning a computed value. + */ + def computeTask() { + def input = params.int('value', 10) + task { + sleep 50 + def result = input * input + render([input: input, result: result] as JSON) + } + } + + /** + * Multiple async tasks - simulates parallel work, returns combined results. + */ + def parallelTasks() { + task { + // Simulate parallel work by doing multiple operations + def result1 = 'Task 1 result' + def result2 = 'Task 2 result' + def result3 = 'Task 3 result' + sleep 50 + def results = [result1, result2, result3] + render([ + status: 'completed', + results: results + ] as JSON) + } + } + + /** + * Chained async tasks. + */ + def chainedTasks() { + def input = params.input ?: 'test' + + task { + sleep 50 + return input.toUpperCase() + }.then { result -> + sleep 50 + return result.reverse() + }.then { result -> + render([ + original: input, + final: result + ] as JSON) + } + } + + /** + * Async task with error handling. + */ + def taskWithError() { + def shouldFail = params.boolean('fail', false) + + task { + sleep 50 + if (shouldFail) { + throw new RuntimeException('Intentional async error') + } + return 'Success' + }.onComplete { result -> + render([ + status: 'success', + result: result + ] as JSON) + } + } + + /** + * Timeout handling for async task. + */ + def taskWithTimeout() { + def delay = params.int('delay', 100) + def timeout = params.int('timeout', 500) + + def startTime = System.currentTimeMillis() + task { + sleep delay + return 'Completed in time' + }.onComplete { result -> + def elapsed = System.currentTimeMillis() - startTime + render([ + status: 'completed', + result: result, + elapsedMs: elapsed + ] as JSON) + } + } + + /** + * Use the async service directly. + */ + def useAsyncService() { + def input = params.input ?: 'hello' + + def future = asyncProcessingService.processAsync(input) + + // Wait for result (up to 5 seconds) + def result = future.get(5, TimeUnit.SECONDS) + + render([ + input: input, + result: result + ] as JSON) + } + + /** + * Async service with calculation. + */ + def asyncCalculation() { + def value = params.int('value', 5) + + def future = asyncProcessingService.calculateAsync(value) + def result = future.get(5, TimeUnit.SECONDS) + + render([ + input: value, + squared: result + ] as JSON) + } + + /** + * Batch processing with async service. + */ + def asyncBatch() { + def items = params.list('items') ?: ['one', 'two', 'three'] + + def future = asyncProcessingService.processBatchAsync(items) + def results = future.get(5, TimeUnit.SECONDS) + + render([ + original: items, + processed: results + ] as JSON) + } + + /** + * Long-running operation. + */ + def longRunning() { + def taskId = params.taskId ?: UUID.randomUUID().toString() + + def future = asyncProcessingService.longRunningOperation(taskId) + def result = future.get(5, TimeUnit.SECONDS) + + render(result as JSON) + } + + /** + * CompletableFuture composition. + */ + def composeFutures() { + def value1 = params.int('v1', 3) + def value2 = params.int('v2', 4) + + def future1 = asyncProcessingService.calculateAsync(value1) + def future2 = asyncProcessingService.calculateAsync(value2) + + def combined = future1.thenCombine(future2) { r1, r2 -> + [ + value1Squared: r1, + value2Squared: r2, + sum: r1 + r2 + ] + } + + def result = combined.get(5, TimeUnit.SECONDS) + render(result as JSON) + } + + /** + * Async task that processes request data. + */ + def processRequestData() { + def data = request.JSON ?: [value: 'default'] + + task { + sleep 50 + def processed = data.collectEntries { k, v -> + [(k): v?.toString()?.toUpperCase()] + } + return processed + }.onComplete { result -> + render([ + original: data, + processed: result + ] as JSON) + } + } + + /** + * Demonstrates async response with multiple stages reporting. + */ + def multiStageProcess() { + task { + def t1 = System.currentTimeMillis() + sleep 30 + def t2 = System.currentTimeMillis() + sleep 30 + def t3 = System.currentTimeMillis() + + def stages = [ + [stage: 1, action: 'initialize', time: t1], + [stage: 2, action: 'process', time: t2], + [stage: 3, action: 'finalize', time: t3] + ] + render([ + status: 'completed', + stages: stages, + totalStages: stages.size() + ] as JSON) + } + } + + /** + * Conditional async execution. + */ + def conditionalAsync() { + def useAsync = params.boolean('async', true) + def input = params.input ?: 'test' + + if (useAsync) { + task { + sleep 50 + return "Async: ${input.toUpperCase()}" + }.onComplete { result -> + render([mode: 'async', result: result] as JSON) + } + } else { + // Synchronous execution + def result = "Sync: ${input.toUpperCase()}" + render([mode: 'sync', result: result] as JSON) + } + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/AttributeSettingInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/AttributeSettingInterceptor.groovy new file mode 100644 index 0000000000..58ce13e50b --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/AttributeSettingInterceptor.groovy @@ -0,0 +1,46 @@ +/* + * 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.interceptors + +/** + * Interceptor that sets request attributes and response headers. + */ +class AttributeSettingInterceptor { + + AttributeSettingInterceptor() { + match(controller: 'interceptorTest', action: 'checkAttributes') + } + + boolean before() { + InterceptorTestController.executionOrder << "attribute:before" + request.setAttribute('beforeInterceptorRan', true) + request.setAttribute('interceptorData', [ + timestamp: System.currentTimeMillis(), + source: 'AttributeSettingInterceptor' + ]) + response.setHeader('X-Interceptor-Header', 'set-by-interceptor') + true + } + + boolean after() { + InterceptorTestController.executionOrder << "attribute:after" + true + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/BlockingInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/BlockingInterceptor.groovy new file mode 100644 index 0000000000..5224cb11b7 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/BlockingInterceptor.groovy @@ -0,0 +1,47 @@ +/* + * 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.interceptors + +/** + * Interceptor that can block requests. + * Returns false from before() to prevent controller execution. + */ +class BlockingInterceptor { + + int order = 5 // Runs before other interceptors + + BlockingInterceptor() { + match(controller: 'interceptorTest', action: 'blocked') + } + + boolean before() { + InterceptorTestController.executionOrder << "blocking:before" + if (params.block == 'true') { + render(text: '{"blocked":true,"message":"Request blocked by interceptor","reason":"' + (params.reason ?: 'No reason provided') + '"}', contentType: 'application/json') + return false // Block the request + } + true + } + + boolean after() { + InterceptorTestController.executionOrder << "blocking:after" + true + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/ConditionalMatchInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/ConditionalMatchInterceptor.groovy new file mode 100644 index 0000000000..e72f29c009 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/ConditionalMatchInterceptor.groovy @@ -0,0 +1,52 @@ +/* + * 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.interceptors + +/** + * Interceptor with conditional matching using closure. + */ +class ConditionalMatchInterceptor { + + ConditionalMatchInterceptor() { + match(controller: 'interceptorTest', action: 'conditionalAction') + .except(action: 'index') + } + + boolean before() { + // Only add to execution order if 'match' param is 'yes' + if (params.match == 'yes') { + InterceptorTestController.executionOrder << "conditional:before:matched" + request.setAttribute('conditionalMatched', true) + } else { + InterceptorTestController.executionOrder << "conditional:before:notmatched" + request.setAttribute('conditionalMatched', false) + } + true + } + + boolean after() { + if (params.match == 'yes') { + InterceptorTestController.executionOrder << "conditional:after:matched" + } else { + InterceptorTestController.executionOrder << "conditional:after:notmatched" + } + true + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/FirstInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/FirstInterceptor.groovy new file mode 100644 index 0000000000..ddba8a9cef --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/FirstInterceptor.groovy @@ -0,0 +1,48 @@ +/* + * 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.interceptors + +/** + * First interceptor in the chain (order = 10). + * Tests basic before/after functionality. + */ +class FirstInterceptor { + + int order = 10 + + FirstInterceptor() { + match(controller: 'interceptorTest', action: ~/(index|testOrder|dataAction)/) + } + + boolean before() { + InterceptorTestController.executionOrder << "first:before" + request.setAttribute('firstInterceptorRan', true) + true + } + + boolean after() { + InterceptorTestController.executionOrder << "first:after" + true + } + + void afterView() { + InterceptorTestController.executionOrder << "first:afterView" + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/InterceptorTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/InterceptorTestController.groovy new file mode 100644 index 0000000000..3f520f68d9 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/InterceptorTestController.groovy @@ -0,0 +1,172 @@ +/* + * 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.interceptors + +import grails.converters.JSON + +/** + * Controller for testing advanced interceptor functionality. + */ +class InterceptorTestController { + + // This list tracks interceptor execution order + static List<String> executionOrder = [] + + /** + * Simple action for testing interceptor before/after execution. + */ + def index() { + executionOrder << "controller:index" + render([ + action: 'index', + executionOrder: new ArrayList<>(executionOrder) + ] as JSON) + } + + /** + * Action for testing interceptor ordering. + */ + def testOrder() { + executionOrder << "controller:testOrder" + render([ + action: 'testOrder', + executionOrder: new ArrayList<>(executionOrder) + ] as JSON) + } + + /** + * Action that can be blocked by interceptor. + */ + def blocked() { + executionOrder << "controller:blocked" + render([ + action: 'blocked', + message: 'This should not be seen if blocked' + ] as JSON) + } + + /** + * Action that can throw exception. + */ + def throwException() { + if (params.throwIt == 'true') { + throw new RuntimeException("Controller threw exception: ${params.message ?: 'test error'}") + } + render([ + action: 'throwException', + didNotThrow: true + ] as JSON) + } + + /** + * Action for testing model modification by interceptor. + */ + def modifyModel() { + executionOrder << "controller:modifyModel" + [ + originalValue: 'from controller', + timestamp: System.currentTimeMillis() + ] + } + + /** + * Action that returns data to be potentially modified by after interceptor. + */ + def dataAction() { + executionOrder << "controller:dataAction" + render([ + data: params.data ?: 'default', + interceptorModified: false + ] as JSON) + } + + /** + * Reset execution order (for test setup). + */ + def resetOrder() { + executionOrder.clear() + render([ + reset: true, + executionOrder: executionOrder + ] as JSON) + } + + /** + * Get current execution order without modifying it. + */ + def getOrder() { + render([ + executionOrder: new ArrayList<>(executionOrder) + ] as JSON) + } + + /** + * Action for testing request attribute passing. + */ + def checkAttributes() { + render([ + interceptorSet: request.getAttribute('interceptorData'), + headerAdded: response.getHeader('X-Interceptor-Header'), + fromBefore: request.getAttribute('beforeInterceptorRan') + ] as JSON) + } + + /** + * Action for testing session manipulation by interceptor. + */ + def checkSession() { + render([ + sessionData: session.getAttribute('interceptorSessionData'), + sessionId: session.id + ] as JSON) + } + + /** + * Action for testing afterView interceptor. + */ + def withView() { + executionOrder << "controller:withView" + [message: 'Hello from controller'] + } + + /** + * Action that takes time (for timing tests). + */ + def slowAction() { + def delay = params.int('delay') ?: 100 + Thread.sleep(delay) + executionOrder << "controller:slowAction" + render([ + action: 'slowAction', + delay: delay + ] as JSON) + } + + /** + * Action for testing conditional interceptor matching. + */ + def conditionalAction() { + executionOrder << "controller:conditionalAction" + render([ + action: 'conditionalAction', + param: params.match + ] as JSON) + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/SecondInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/SecondInterceptor.groovy new file mode 100644 index 0000000000..f6c70cfa74 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/SecondInterceptor.groovy @@ -0,0 +1,48 @@ +/* + * 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.interceptors + +/** + * Second interceptor in the chain (order = 20). + * Runs after FirstInterceptor but before ThirdInterceptor. + */ +class SecondInterceptor { + + int order = 20 + + SecondInterceptor() { + match(controller: 'interceptorTest', action: ~/(index|testOrder|dataAction)/) + } + + boolean before() { + InterceptorTestController.executionOrder << "second:before" + request.setAttribute('secondInterceptorRan', true) + true + } + + boolean after() { + InterceptorTestController.executionOrder << "second:after" + true + } + + void afterView() { + InterceptorTestController.executionOrder << "second:afterView" + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/SessionInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/SessionInterceptor.groovy new file mode 100644 index 0000000000..129d85ad90 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/SessionInterceptor.groovy @@ -0,0 +1,44 @@ +/* + * 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.interceptors + +/** + * Interceptor that sets session attributes. + */ +class SessionInterceptor { + + SessionInterceptor() { + match(controller: 'interceptorTest', action: 'checkSession') + } + + boolean before() { + InterceptorTestController.executionOrder << "session:before" + session.setAttribute('interceptorSessionData', [ + setAt: System.currentTimeMillis(), + message: 'Session data from interceptor' + ]) + true + } + + boolean after() { + InterceptorTestController.executionOrder << "session:after" + true + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/ThirdInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/ThirdInterceptor.groovy new file mode 100644 index 0000000000..8bdbbaa47e --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/ThirdInterceptor.groovy @@ -0,0 +1,48 @@ +/* + * 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.interceptors + +/** + * Third interceptor in the chain (order = 30). + * Runs last among the ordered interceptors. + */ +class ThirdInterceptor { + + int order = 30 + + ThirdInterceptor() { + match(controller: 'interceptorTest', action: ~/(index|testOrder|dataAction)/) + } + + boolean before() { + InterceptorTestController.executionOrder << "third:before" + request.setAttribute('thirdInterceptorRan', true) + true + } + + boolean after() { + InterceptorTestController.executionOrder << "third:after" + true + } + + void afterView() { + InterceptorTestController.executionOrder << "third:afterView" + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/TimingInterceptor.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/TimingInterceptor.groovy new file mode 100644 index 0000000000..8a4c2311e4 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/interceptors/TimingInterceptor.groovy @@ -0,0 +1,46 @@ +/* + * 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.interceptors + +/** + * Interceptor for timing/performance tracking. + */ +class TimingInterceptor { + + TimingInterceptor() { + match(controller: 'interceptorTest', action: 'slowAction') + } + + boolean before() { + InterceptorTestController.executionOrder << "timing:before" + request.setAttribute('requestStartTime', System.currentTimeMillis()) + true + } + + boolean after() { + def startTime = request.getAttribute('requestStartTime') as Long + def endTime = System.currentTimeMillis() + def duration = endTime - startTime + InterceptorTestController.executionOrder << "timing:after:${duration}ms" + // Add header without checking if committed + response.addHeader('X-Request-Duration', "${duration}") + true + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/async/AsyncPromiseSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/async/AsyncPromiseSpec.groovy new file mode 100644 index 0000000000..9d5b028330 --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/async/AsyncPromiseSpec.groovy @@ -0,0 +1,390 @@ +/* + * 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.async + +import functionaltests.Application +import functionaltests.services.AsyncProcessingService +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 + +import java.util.concurrent.TimeUnit + +/** + * Integration tests for async/promise functionality in Grails. + * Tests various async patterns including tasks, promises, chaining, and error handling. + */ +@Integration(applicationClass = Application) +@Rollback +class AsyncPromiseSpec extends Specification { + + @Shared + HttpClient client + + AsyncProcessingService asyncProcessingService + + def setup() { + client = HttpClient.create(new URL("http://localhost:${serverPort}")) + } + + def cleanup() { + client?.close() + } + + // ========== Basic Async Task Tests ========== + + def "simple async task completes successfully"() { + when: "calling simple task endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/simpleTask'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "task completes with success status" + response.status == HttpStatus.OK + json.status == 'completed' + json.message == 'Task finished' + } + + def "compute task returns calculated value"() { + given: "an input value" + def value = 7 + + when: "calling compute endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/computeTask?value=${value}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "computed result is correct" + response.status == HttpStatus.OK + json.input == value + json.result == value * value + } + + def "parallel tasks complete and return all results"() { + when: "calling parallel tasks endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/parallelTasks'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "all tasks complete" + response.status == HttpStatus.OK + json.status == 'completed' + json.results.size() == 3 + json.results.contains('Task 1 result') + json.results.contains('Task 2 result') + json.results.contains('Task 3 result') + } + + def "chained tasks process data through multiple stages"() { + given: "an input string" + def input = 'hello' + + when: "calling chained endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/chainedTasks?input=${input}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "data is processed through all stages" + response.status == HttpStatus.OK + json.original == input + // HELLO reversed is OLLEH + json.final == input.toUpperCase().reverse() + } + + // ========== Error Handling Tests ========== + + def "async task handles success without error"() { + when: "calling task that should succeed" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/taskWithError?fail=false'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "success response returned" + response.status == HttpStatus.OK + json.status == 'success' + json.result == 'Success' + } + + def "async task with timeout completes within time limit"() { + when: "calling task with reasonable timeout" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/taskWithTimeout?delay=100&timeout=500'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "task completes successfully" + response.status == HttpStatus.OK + json.status == 'completed' + json.result == 'Completed in time' + json.elapsedMs >= 100 + } + + // ========== Async Service Tests ========== + + def "async service processes string input"() { + given: "an input string" + def input = 'test' + + when: "calling async service endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/useAsyncService?input=${input}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "service processes input correctly" + response.status == HttpStatus.OK + json.input == input + json.result == "Processed: ${input.toUpperCase()}" + } + + def "async service calculates square"() { + given: "a numeric value" + def value = 6 + + when: "calling async calculation endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/asyncCalculation?value=${value}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "calculation is correct" + response.status == HttpStatus.OK + json.input == value + json.squared == value * value + } + + def "async batch processing reverses all items"() { + when: "calling batch endpoint with default items" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/asyncBatch'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "all items are reversed" + response.status == HttpStatus.OK + json.original.size() == json.processed.size() + json.original.eachWithIndex { item, idx -> + assert json.processed[idx] == item.reverse() + } + } + + def "long running operation completes with task info"() { + given: "a task ID" + def taskId = 'task-123' + + when: "calling long running endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/longRunning?taskId=${taskId}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "operation completes with expected info" + response.status == HttpStatus.OK + json.taskId == taskId + json.status == 'completed' + json.durationMs >= 200 + json.completedAt != null + } + + def "CompletableFuture composition combines results"() { + given: "two input values" + def v1 = 3 + def v2 = 4 + + when: "calling compose endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/composeFutures?v1=${v1}&v2=${v2}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "both futures are combined correctly" + response.status == HttpStatus.OK + json.value1Squared == v1 * v1 // 9 + json.value2Squared == v2 * v2 // 16 + json.sum == (v1 * v1) + (v2 * v2) // 25 + } + + // ========== Request Data Processing Tests ========== + + def "async task processes JSON request body"() { + given: "JSON request body" + def body = '{"name": "test", "value": "hello"}' + + when: "posting to process endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.POST('/asyncTest/processRequestData', body) + .contentType(MediaType.APPLICATION_JSON), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "data is processed correctly" + response.status == HttpStatus.OK + json.original.name == 'test' + json.processed.name == 'TEST' + json.processed.value == 'HELLO' + } + + def "multi-stage process reports all stages"() { + when: "calling multi-stage endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/multiStageProcess'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "all stages are reported" + response.status == HttpStatus.OK + json.status == 'completed' + json.totalStages == 3 + json.stages.size() == 3 + json.stages[0].action == 'initialize' + json.stages[1].action == 'process' + json.stages[2].action == 'finalize' + } + + def "stages execute in correct order"() { + when: "calling multi-stage endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/asyncTest/multiStageProcess'), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "stages have increasing timestamps" + response.status == HttpStatus.OK + def times = json.stages.collect { it.time as Long } + times[0] <= times[1] + times[1] <= times[2] + } + + // ========== Conditional Execution Tests ========== + + def "conditional async uses async mode when requested"() { + given: "input value" + def input = 'grails' + + when: "calling with async=true" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/conditionalAsync?async=true&input=${input}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "async mode is used" + response.status == HttpStatus.OK + json.mode == 'async' + json.result.contains('Async') + json.result.contains(input.toUpperCase()) + } + + def "conditional async uses sync mode when requested"() { + given: "input value" + def input = 'grails' + + when: "calling with async=false" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET("/asyncTest/conditionalAsync?async=false&input=${input}"), + String + ) + def json = new JsonSlurper().parseText(response.body()) + + then: "sync mode is used" + response.status == HttpStatus.OK + json.mode == 'sync' + json.result.contains('Sync') + json.result.contains(input.toUpperCase()) + } + + // ========== Direct Service Tests ========== + + def "asyncProcessingService.processAsync returns correct result"() { + given: "an input" + def input = 'direct' + + when: "calling service directly" + def future = asyncProcessingService.processAsync(input) + def result = future.get(5, TimeUnit.SECONDS) + + then: "result is correct" + result == "Processed: ${input.toUpperCase()}" + } + + def "asyncProcessingService.calculateAsync squares value"() { + given: "a numeric value" + def value = 8 + + when: "calling calculation service" + def future = asyncProcessingService.calculateAsync(value) + def result = future.get(5, TimeUnit.SECONDS) + + then: "value is squared" + result == value * value + } + + def "asyncProcessingService.processBatchAsync handles empty list"() { + given: "empty list" + def items = [] + + when: "processing empty batch" + def future = asyncProcessingService.processBatchAsync(items) + def result = future.get(5, TimeUnit.SECONDS) + + then: "empty result returned" + result != null + result.isEmpty() + } + + def "asyncProcessingService.processBatchAsync handles single item"() { + given: "single item list" + def items = ['single'] + + when: "processing single item batch" + def future = asyncProcessingService.processBatchAsync(items) + def result = future.get(5, TimeUnit.SECONDS) + + then: "single reversed item returned" + result.size() == 1 + result[0] == 'elgnis' + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/interceptors/InterceptorOrderingSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/interceptors/InterceptorOrderingSpec.groovy new file mode 100644 index 0000000000..da64200df5 --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/interceptors/InterceptorOrderingSpec.groovy @@ -0,0 +1,407 @@ +/* + * 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.interceptors + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import spock.lang.Specification + +/** + * Comprehensive integration tests for Grails interceptor functionality. + * + * Tests cover: + * - Interceptor execution order (before/after/afterView) + * - Interceptor ordering using 'order' property + * - Blocking requests in before() + * - Request/response attribute manipulation + * - Session manipulation + * - Conditional matching + * - Timing/performance tracking + */ +@Integration(applicationClass = Application) +class InterceptorOrderingSpec extends Specification { + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + def setup() { + // Reset execution order before each test + def client = createClient() + try { + client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/resetOrder'), + Map + ) + } finally { + client.close() + } + } + + // ========== Interceptor Ordering Tests ========== + + def "test interceptors execute in order by 'order' property"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/testOrder'), + Map + ) + + then: "interceptors should run in order: first(10), second(20), third(30)" + response.status.code == 200 + def order = response.body().executionOrder + + // Before interceptors run in ascending order + def firstBeforeIdx = order.indexOf('first:before') + def secondBeforeIdx = order.indexOf('second:before') + def thirdBeforeIdx = order.indexOf('third:before') + def controllerIdx = order.indexOf('controller:testOrder') + + // Verify before interceptors run before controller + firstBeforeIdx >= 0 + secondBeforeIdx >= 0 + thirdBeforeIdx >= 0 + controllerIdx >= 0 + + firstBeforeIdx < secondBeforeIdx + secondBeforeIdx < thirdBeforeIdx + thirdBeforeIdx < controllerIdx + + cleanup: + client.close() + } + + def "test before interceptors run before controller action"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/index'), + Map + ) + + then: + response.status.code == 200 + def order = response.body().executionOrder + + // All before interceptors should run before controller + order.findAll { it.contains(':before') }.every { beforeEntry -> + order.indexOf(beforeEntry) < order.indexOf('controller:index') + } + + cleanup: + client.close() + } + + def "test after interceptors run after controller action"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/index'), + Map + ) + + then: + response.status.code == 200 + def order = response.body().executionOrder + + // All after interceptors should run after controller + order.findAll { it.contains(':after') }.every { afterEntry -> + order.indexOf(afterEntry) > order.indexOf('controller:index') + } + + cleanup: + client.close() + } + + // ========== Blocking Interceptor Tests ========== + + def "test interceptor can block request by returning false"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/blocked?block=true&reason=testing'), + Map + ) + + then: "controller action should not execute" + response.status.code == 200 + response.body().blocked == true + response.body().message == 'Request blocked by interceptor' + response.body().reason == 'testing' + + cleanup: + client.close() + } + + def "test interceptor allows request when returning true"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/blocked?block=false'), + Map + ) + + then: "controller action should execute" + response.status.code == 200 + response.body().action == 'blocked' + response.body().message == 'This should not be seen if blocked' + + cleanup: + client.close() + } + + // ========== Request Attribute Tests ========== + + def "test interceptor can set request attributes"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/checkAttributes'), + Map + ) + + then: + response.status.code == 200 + response.body().fromBefore == true + response.body().interceptorSet != null + response.body().interceptorSet.source == 'AttributeSettingInterceptor' + + cleanup: + client.close() + } + + def "test interceptor can set response headers"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/checkAttributes'), + Map + ) + + then: + response.status.code == 200 + response.header('X-Interceptor-Header') == 'set-by-interceptor' + + cleanup: + client.close() + } + + // ========== Session Tests ========== + + def "test interceptor can set session attributes"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/checkSession'), + Map + ) + + then: + response.status.code == 200 + response.body().sessionData != null + response.body().sessionData.message == 'Session data from interceptor' + + cleanup: + client.close() + } + + // ========== Conditional Matching Tests ========== + + def "test interceptor conditional matching - matched"() { + given: + def client = createClient() + // Reset first + client.toBlocking().exchange(HttpRequest.GET('/interceptorTest/resetOrder'), Map) + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/conditionalAction?match=yes'), + Map + ) + + then: + response.status.code == 200 + def orderResponse = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/getOrder'), + Map + ) + orderResponse.body().executionOrder.contains('conditional:before:matched') + + cleanup: + client.close() + } + + def "test interceptor conditional matching - not matched"() { + given: + def client = createClient() + // Reset first + client.toBlocking().exchange(HttpRequest.GET('/interceptorTest/resetOrder'), Map) + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/conditionalAction?match=no'), + Map + ) + + then: + response.status.code == 200 + def orderResponse = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/getOrder'), + Map + ) + orderResponse.body().executionOrder.contains('conditional:before:notmatched') + + cleanup: + client.close() + } + + // ========== Timing Interceptor Tests ========== + + def "test timing interceptor tracks request duration"() { + given: + def client = createClient() + // Reset first + client.toBlocking().exchange(HttpRequest.GET('/interceptorTest/resetOrder'), Map) + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/slowAction?delay=50'), + Map + ) + + then: + response.status.code == 200 + response.body().action == 'slowAction' + response.body().delay == 50 + + // Check execution order includes timing entries + def orderResponse = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/getOrder'), + Map + ) + orderResponse.body().executionOrder.any { it.startsWith('timing:before') } + orderResponse.body().executionOrder.any { it.startsWith('timing:after') } + + cleanup: + client.close() + } + + // ========== Multiple Requests Tests ========== + + def "test interceptors work correctly across multiple requests"() { + given: + def client = createClient() + + when: "make multiple requests" + client.toBlocking().exchange(HttpRequest.GET('/interceptorTest/resetOrder'), Map) + def response1 = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/index'), + Map + ) + + // Reset again for clean second request + client.toBlocking().exchange(HttpRequest.GET('/interceptorTest/resetOrder'), Map) + def response2 = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/index'), + Map + ) + + then: "each request should have clean interceptor execution" + response1.status.code == 200 + response2.status.code == 200 + + // Both should have similar execution patterns + response1.body().executionOrder.contains('first:before') + response2.body().executionOrder.contains('first:before') + + cleanup: + client.close() + } + + // ========== Data Action Tests ========== + + def "test interceptors work with data actions"() { + given: + def client = createClient() + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/dataAction?data=testValue'), + Map + ) + + then: + response.status.code == 200 + response.body().data == 'testValue' + + cleanup: + client.close() + } + + // ========== Execution Order Verification ========== + + def "test complete before-controller-after sequence"() { + given: + def client = createClient() + client.toBlocking().exchange(HttpRequest.GET('/interceptorTest/resetOrder'), Map) + + when: + def response = client.toBlocking().exchange( + HttpRequest.GET('/interceptorTest/index'), + Map + ) + + then: + response.status.code == 200 + def order = response.body().executionOrder + + // Verify the complete sequence + def beforeEntries = order.findAll { it.contains(':before') } + def afterEntries = order.findAll { it.contains(':after') } + def controllerEntry = order.find { it.startsWith('controller:') } + + // All befores come first + beforeEntries.every { order.indexOf(it) < order.indexOf(controllerEntry) } + // All afters come last + afterEntries.every { order.indexOf(it) > order.indexOf(controllerEntry) } + + cleanup: + client.close() + } +}
