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 a7e63a0bef37ca6a0bd6f099f9ba7be594ddb44a Author: James Fredley <[email protected]> AuthorDate: Sun Jan 25 22:05:05 2026 -0500 Add Spring events and caching integration tests - Add SpringEventsSpec with 19 tests for ApplicationEvent handling - Tests synchronous and async event publishing/listening - Tests event inheritance and multiple listeners - Add CachingSpec with 20 tests for @Cacheable, @CacheEvict - Tests cache hits/misses, eviction strategies, conditional caching - Includes cache manager integration and TTL behavior --- .../caching/CacheTestController.groovy | 131 +++++++ .../springevents/SpringEventController.groovy | 149 +++++++ .../caching/CacheTestService.groovy | 132 +++++++ .../springevents/EventListenerService.groovy | 169 ++++++++ .../springevents/EventPublisherService.groovy | 78 ++++ .../functionaltests/caching/CachingSpec.groovy | 427 +++++++++++++++++++++ .../springevents/SpringEventsSpec.groovy | 379 ++++++++++++++++++ .../springevents/CustomApplicationEvent.groovy | 39 ++ .../springevents/PriorityEvent.groovy | 37 ++ .../springevents/UserActionEvent.groovy | 39 ++ 10 files changed, 1580 insertions(+) diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/caching/CacheTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/caching/CacheTestController.groovy new file mode 100644 index 0000000000..57b94aa014 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/caching/CacheTestController.groovy @@ -0,0 +1,131 @@ +/* + * 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.caching + +import grails.converters.JSON +import org.springframework.beans.factory.annotation.Autowired + +/** + * Controller for testing caching features via HTTP. + */ +class CacheTestController { + + static responseFormats = ['json'] + + @Autowired + CacheTestService cacheTestService + + /** + * Get basic cached data. + */ + def basicData() { + def data = cacheTestService.getBasicData() + render([data: data] as JSON) + } + + /** + * Get data by ID. + */ + def dataById() { + Long id = params.long('id', 1L) + def data = cacheTestService.getDataById(id) + render([id: id, data: data] as JSON) + } + + /** + * Get complex data. + */ + def complexData() { + String category = params.category ?: 'default' + int page = params.int('page', 1) + def data = cacheTestService.getComplexData(category, page) + render([data: data] as JSON) + } + + /** + * Get conditional data. + */ + def conditionalData() { + boolean returnEmpty = params.boolean('empty', false) + def data = cacheTestService.getConditionalData(returnEmpty) + render([data: data] as JSON) + } + + /** + * Get data by key (uses custom key closure). + */ + def byKey() { + String key = params.key ?: 'default' + def data = cacheTestService.getByKey(key) + render([key: key, data: data] as JSON) + } + + /** + * Update cached value by key. + */ + def updateByKey() { + String key = params.key ?: 'default' + String value = params.value ?: 'updated' + def result = cacheTestService.updateByKey(key, value) + render([key: key, value: result] as JSON) + } + + /** + * Evict basic cache. + */ + def evictBasic() { + cacheTestService.evictBasicCache() + render([evicted: true, cache: 'basicCache'] as JSON) + } + + /** + * Evict by ID. + */ + def evictById() { + Long id = params.long('id', 1L) + cacheTestService.evictById(id) + render([evicted: true, id: id] as JSON) + } + + /** + * Evict all from param cache. + */ + def evictAllParam() { + cacheTestService.evictAllFromParamCache() + render([evicted: true, allEntries: true] as JSON) + } + + /** + * Evict by key from keyed cache. + */ + def evictByKey() { + String key = params.key ?: 'default' + cacheTestService.evictByKey(key) + render([evicted: true, key: key] as JSON) + } + + /** + * Evict all from keyed cache. + */ + def evictAllKeyed() { + cacheTestService.evictAllKeyedCache() + render([evicted: true, allEntries: true, cache: 'keyedCache'] as JSON) + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/springevents/SpringEventController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/springevents/SpringEventController.groovy new file mode 100644 index 0000000000..e4f6368aa2 --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/springevents/SpringEventController.groovy @@ -0,0 +1,149 @@ +/* + * 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.springevents + +import grails.converters.JSON +import org.springframework.context.ApplicationEventPublisher +import org.springframework.beans.factory.annotation.Autowired + +/** + * Controller for testing Spring event publishing. + */ +class SpringEventController { + + static responseFormats = ['json'] + + @Autowired + EventPublisherService eventPublisherService + + @Autowired + EventListenerService eventListenerService + + /** + * Publishes a custom event and returns event info. + */ + def publishCustom() { + String message = params.message ?: 'Default message' + eventPublisherService.publishCustomEvent(message, [controller: 'springEvent']) + + render([ + published: true, + message: message, + eventCount: eventListenerService.eventCount + ] as JSON) + } + + /** + * Publishes a user action event. + * Note: Use 'userAction' param instead of 'action' to avoid conflict with Grails params.action + */ + def publishUserAction() { + String userId = params.userId ?: 'unknown' + String userAction = params.userAction ?: 'unknown_action' + + eventPublisherService.publishUserAction(userId, userAction, [source: 'controller']) + + render([ + published: true, + userId: userId, + userAction: userAction + ] as JSON) + } + + /** + * Publishes a priority event and returns ordered results. + */ + def publishPriority() { + String data = params.data ?: 'test' + eventListenerService.clearEvents() + eventPublisherService.publishPriorityEvent(1, data) + + render([ + published: true, + orderedResults: eventListenerService.orderedResults + ] as JSON) + } + + /** + * Publishes multiple events. + */ + def publishMultiple() { + int count = params.int('count', 5) + eventListenerService.clearEvents() + eventPublisherService.publishMultipleEvents(count) + + render([ + published: true, + count: count, + receivedCount: eventListenerService.eventCount + ] as JSON) + } + + /** + * Publishes an event that should trigger conditional listener. + */ + def publishConditional() { + boolean important = params.boolean('important', false) + String prefix = important ? 'IMPORTANT' : 'NORMAL' + String message = "${prefix}: Test message" + + eventListenerService.clearEvents() + eventPublisherService.publishCustomEvent(message, [conditional: true]) + + render([ + published: true, + message: message, + events: eventListenerService.customEvents.collect { it.message }, + conditionalResults: eventListenerService.conditionalResults + ] as JSON) + } + + /** + * Returns current event statistics. + */ + def stats() { + render([ + totalEvents: eventListenerService.eventCount, + customEvents: eventListenerService.customEvents.size(), + userActionEvents: eventListenerService.userActionEvents.size() + ] as JSON) + } + + /** + * Clears all events. + */ + def clearEvents() { + eventListenerService.clearEvents() + render([cleared: true] as JSON) + } + + /** + * Publishes event in transactional context. + */ + def publishTransactional() { + String message = params.message ?: 'transactional-test' + eventPublisherService.publishEventTransactional(message) + + render([ + published: true, + message: message + ] as JSON) + } +} diff --git a/grails-test-examples/app1/grails-app/services/functionaltests/caching/CacheTestService.groovy b/grails-test-examples/app1/grails-app/services/functionaltests/caching/CacheTestService.groovy new file mode 100644 index 0000000000..841a0c65e7 --- /dev/null +++ b/grails-test-examples/app1/grails-app/services/functionaltests/caching/CacheTestService.groovy @@ -0,0 +1,132 @@ +/* + * 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.caching + +import grails.plugin.cache.Cacheable +import grails.plugin.cache.CacheEvict +import grails.plugin.cache.CachePut +import groovy.transform.CompileStatic + +/** + * Service demonstrating Grails caching features. + * + * Methods return data with timestamps/UUIDs so tests can verify + * caching behavior by comparing results. + */ +@CompileStatic +class CacheTestService { + + /** + * Basic cached method. Returns data with timestamp. + */ + @Cacheable('basicCache') + String getBasicData() { + "Basic data: ${System.currentTimeMillis()}" + } + + /** + * Cached method with parameter-based key. + * Returns data with UUID so each call (without cache) produces unique result. + */ + @Cacheable('paramCache') + String getDataById(Long id) { + "Data for ID ${id}: ${UUID.randomUUID()}" + } + + /** + * Cached method with multiple parameters forming the cache key. + */ + @Cacheable('complexCache') + Map<String, Object> getComplexData(String category, int page) { + [ + category: category, + page: page, + timestamp: System.currentTimeMillis(), + items: (1..5).collect { "Item ${it}" } + ] + } + + /** + * Cacheable method that returns different data based on input. + */ + @Cacheable('conditionalCache') + List<String> getConditionalData(boolean returnEmpty) { + if (returnEmpty) { + return [] + } + ['item1', 'item2', 'item3'] + } + + /** + * Cached method using a custom key closure for testing. + */ + @Cacheable(value = 'keyedCache', key = { key }) + String getByKey(String key) { + "Value for ${key}: ${UUID.randomUUID()}" + } + + /** + * CachePut with key closure - updates the keyed cache. + */ + @CachePut(value = 'keyedCache', key = { key }) + String updateByKey(String key, String value) { + value + } + + /** + * Evict from basic cache. + */ + @CacheEvict(value = 'basicCache', allEntries = true) + void evictBasicCache() { + // Cache is evicted + } + + /** + * Evict specific entry from param cache. + */ + @CacheEvict(value = 'paramCache') + void evictById(Long id) { + // Evicts the entry for the given ID + } + + /** + * Evict all entries from param cache. + */ + @CacheEvict(value = 'paramCache', allEntries = true) + void evictAllFromParamCache() { + // Evicts all entries + } + + /** + * Evict from keyed cache using key closure. + */ + @CacheEvict(value = 'keyedCache', key = { key }) + void evictByKey(String key) { + // Evicts the specific key + } + + /** + * Evict all from keyed cache. + */ + @CacheEvict(value = 'keyedCache', allEntries = true) + void evictAllKeyedCache() { + // Evicts all entries + } +} diff --git a/grails-test-examples/app1/grails-app/services/functionaltests/springevents/EventListenerService.groovy b/grails-test-examples/app1/grails-app/services/functionaltests/springevents/EventListenerService.groovy new file mode 100644 index 0000000000..8facbc6e6f --- /dev/null +++ b/grails-test-examples/app1/grails-app/services/functionaltests/springevents/EventListenerService.groovy @@ -0,0 +1,169 @@ +/* + * 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.springevents + +import org.springframework.context.event.EventListener +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Service + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Service that listens for Spring application events. + * Provides methods to track and verify event handling. + */ +@Service +class EventListenerService { + + // Thread-safe collections for tracking events + private final CopyOnWriteArrayList<CustomApplicationEvent> customEvents = new CopyOnWriteArrayList<>() + private final CopyOnWriteArrayList<UserActionEvent> userActionEvents = new CopyOnWriteArrayList<>() + private final CopyOnWriteArrayList<String> orderedResults = new CopyOnWriteArrayList<>() + private final AtomicInteger eventCount = new AtomicInteger(0) + private final ConcurrentLinkedQueue<String> asyncResults = new ConcurrentLinkedQueue<>() + + // Track conditional events separately + private final CopyOnWriteArrayList<String> conditionalResults = new CopyOnWriteArrayList<>() + + // Latch for async event testing + private volatile CountDownLatch asyncLatch = new CountDownLatch(0) + + /** + * Listens for CustomApplicationEvent. + */ + @EventListener + void handleCustomEvent(CustomApplicationEvent event) { + customEvents.add(event) + eventCount.incrementAndGet() + + // Manually handle conditional logic to avoid SpEL evaluation issues + if (event.message?.startsWith('IMPORTANT')) { + conditionalResults.add("CONDITIONAL:" + event.message) + } + } + + /** + * Listens for UserActionEvent. + */ + @EventListener + void handleUserActionEvent(UserActionEvent event) { + userActionEvents.add(event) + eventCount.incrementAndGet() + } + + /** + * Listens for PriorityEvent with high priority (order 1). + */ + @EventListener + @Order(1) + void handlePriorityEventFirst(PriorityEvent event) { + orderedResults.add("first-${event.data}") + } + + /** + * Listens for PriorityEvent with medium priority (order 2). + */ + @EventListener + @Order(2) + void handlePriorityEventSecond(PriorityEvent event) { + orderedResults.add("second-${event.data}") + } + + /** + * Listens for PriorityEvent with low priority (order 3). + */ + @EventListener + @Order(3) + void handlePriorityEventThird(PriorityEvent event) { + orderedResults.add("third-${event.data}") + } + + /** + * Gets all received custom events. + */ + List<CustomApplicationEvent> getCustomEvents() { + new ArrayList<>(customEvents) + } + + /** + * Gets all received user action events. + */ + List<UserActionEvent> getUserActionEvents() { + new ArrayList<>(userActionEvents) + } + + /** + * Gets ordered results from priority event handling. + */ + List<String> getOrderedResults() { + new ArrayList<>(orderedResults) + } + + /** + * Gets total event count. + */ + int getEventCount() { + eventCount.get() + } + + /** + * Gets async results. + */ + List<String> getAsyncResults() { + new ArrayList<>(asyncResults) + } + + /** + * Gets conditional results. + */ + List<String> getConditionalResults() { + new ArrayList<>(conditionalResults) + } + + /** + * Prepare for async event testing. + */ + void prepareAsyncLatch(int count) { + asyncLatch = new CountDownLatch(count) + } + + /** + * Wait for async events. + */ + boolean awaitAsyncEvents(long timeout, TimeUnit unit) { + asyncLatch.await(timeout, unit) + } + + /** + * Clears all recorded events. + */ + void clearEvents() { + customEvents.clear() + userActionEvents.clear() + orderedResults.clear() + asyncResults.clear() + conditionalResults.clear() + eventCount.set(0) + } +} diff --git a/grails-test-examples/app1/grails-app/services/functionaltests/springevents/EventPublisherService.groovy b/grails-test-examples/app1/grails-app/services/functionaltests/springevents/EventPublisherService.groovy new file mode 100644 index 0000000000..d665a292cb --- /dev/null +++ b/grails-test-examples/app1/grails-app/services/functionaltests/springevents/EventPublisherService.groovy @@ -0,0 +1,78 @@ +/* + * 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.springevents + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.ApplicationEventPublisherAware +import grails.gorm.transactions.Transactional + +/** + * Service that publishes Spring application events. + */ +class EventPublisherService implements ApplicationEventPublisherAware { + + ApplicationEventPublisher applicationEventPublisher + + @Override + void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + this.applicationEventPublisher = publisher + } + + /** + * Publishes a custom application event. + */ + void publishCustomEvent(String message, Map<String, Object> payload = [:]) { + def event = new CustomApplicationEvent(this, message, payload) + applicationEventPublisher.publishEvent(event) + } + + /** + * Publishes a user action event. + */ + void publishUserAction(String userId, String action, Map<String, String> metadata = [:]) { + def event = new UserActionEvent(this, userId, action, metadata) + applicationEventPublisher.publishEvent(event) + } + + /** + * Publishes a priority event. + */ + void publishPriorityEvent(int priority, String data) { + def event = new PriorityEvent(this, priority, data) + applicationEventPublisher.publishEvent(event) + } + + /** + * Publishes multiple events in sequence. + */ + void publishMultipleEvents(int count) { + count.times { i -> + publishCustomEvent("Event #${i + 1}", [index: i]) + } + } + + /** + * Publishes event within a transactional context. + */ + @Transactional + void publishEventTransactional(String message) { + publishCustomEvent("TRANSACTIONAL:${message}") + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/caching/CachingSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/caching/CachingSpec.groovy new file mode 100644 index 0000000000..695c4902a6 --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/caching/CachingSpec.groovy @@ -0,0 +1,427 @@ +/* + * 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.caching + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +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 org.springframework.beans.factory.annotation.Autowired +import spock.lang.Narrative +import spock.lang.Specification + +/** + * Integration tests for Grails caching with @Cacheable, @CacheEvict, @CachePut. + * + * Tests verify that cached methods return consistent data without + * re-executing the method body, and that cache eviction works correctly. + * + * Note: These tests focus on caching BEHAVIOR (data consistency) rather than + * counting method invocations, since @Cacheable proxies prevent method body + * execution on cache hits. + */ +@Integration(applicationClass = Application) +@Narrative(''' +Grails caching provides method-level caching via annotations @Cacheable, +@CacheEvict, and @CachePut. This allows expensive operations to be cached +and only recomputed when necessary. +''') +class CachingSpec extends Specification { + + @Autowired + CacheTestService cacheTestService + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + def setup() { + // Evict all caches before each test to ensure clean state + cacheTestService.evictBasicCache() + cacheTestService.evictAllFromParamCache() + cacheTestService.evictAllKeyedCache() + } + + // ========== Basic @Cacheable Tests - Data Consistency ========== + + def "cached method returns same result on subsequent calls"() { + when: "calling cached method twice" + def result1 = cacheTestService.getBasicData() + def result2 = cacheTestService.getBasicData() + + then: "same result is returned (proving caching works)" + result1 == result2 + result1.startsWith("Basic data:") + } + + def "cached method returns different result after eviction"() { + given: "cached data exists" + def result1 = cacheTestService.getBasicData() + + when: "cache is evicted and method called again" + cacheTestService.evictBasicCache() + // Small delay to ensure timestamp changes + Thread.sleep(10) + def result2 = cacheTestService.getBasicData() + + then: "new result is generated (timestamps differ)" + result1 != result2 + result1.startsWith("Basic data:") + result2.startsWith("Basic data:") + } + + def "multiple eviction and fetch cycles work correctly"() { + when: "performing multiple evict/fetch cycles" + def results = [] + 3.times { + cacheTestService.evictBasicCache() + Thread.sleep(5) + results << cacheTestService.getBasicData() + } + + then: "each cycle produces a different result" + results.unique().size() == 3 + } + + // ========== Parameter-Based Cache Key Tests ========== + + def "cached method with parameter uses parameter as key"() { + when: "calling with different IDs" + def result1 = cacheTestService.getDataById(1L) + def result2 = cacheTestService.getDataById(2L) + def result3 = cacheTestService.getDataById(1L) + + then: "different IDs create different cache entries" + result1 != result2 + + and: "same ID returns cached result" + result1 == result3 + } + + def "evicting specific cache entry leaves others intact"() { + given: "multiple cached entries" + def result1a = cacheTestService.getDataById(1L) + def result2a = cacheTestService.getDataById(2L) + + when: "evicting only ID 1 and fetching again" + cacheTestService.evictById(1L) + Thread.sleep(5) + def result1b = cacheTestService.getDataById(1L) + def result2b = cacheTestService.getDataById(2L) + + then: "ID 1 was recomputed (different result)" + result1a != result1b + + and: "ID 2 was still cached (same result)" + result2a == result2b + } + + def "evicting all entries clears entire cache"() { + given: "multiple cached entries" + def result1a = cacheTestService.getDataById(1L) + def result2a = cacheTestService.getDataById(2L) + def result3a = cacheTestService.getDataById(3L) + + when: "evicting all entries and fetching again" + cacheTestService.evictAllFromParamCache() + Thread.sleep(5) + def result1b = cacheTestService.getDataById(1L) + def result2b = cacheTestService.getDataById(2L) + + then: "all entries were recomputed" + result1a != result1b + result2a != result2b + } + + def "cache handles many different parameter values"() { + when: "caching many different IDs" + def results = (1L..10L).collect { id -> + cacheTestService.getDataById(id) + } + + and: "fetching them again" + def resultsAgain = (1L..10L).collect { id -> + cacheTestService.getDataById(id) + } + + then: "all results match their cached values" + results == resultsAgain + + and: "all results are unique (different IDs produce different results)" + results.unique().size() == 10 + } + + // ========== Complex Cache Key Tests ========== + + def "cache key includes multiple parameters"() { + when: "calling with different parameter combinations" + def result1 = cacheTestService.getComplexData('books', 1) + def result2 = cacheTestService.getComplexData('books', 2) + def result3 = cacheTestService.getComplexData('movies', 1) + def result4 = cacheTestService.getComplexData('books', 1) + + then: "each combination has its own cache entry" + result1 != result2 + result1 != result3 + result2 != result3 + + and: "same combination returns cached result" + result1 == result4 + result1.category == 'books' + result1.page == 1 + } + + def "complex data structure is cached correctly"() { + when: "fetching complex data" + def result = cacheTestService.getComplexData('tech', 5) + + then: "all fields are present" + result.category == 'tech' + result.page == 5 + result.timestamp != null + result.items.size() == 5 + result.items == ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'] + } + + // ========== @CachePut Tests with Key Closures ========== + + def "CachePut updates cache with new value using key closure"() { + given: "existing cached value for a key" + def original = cacheTestService.getByKey('mykey') + original.startsWith("Value for mykey:") + + when: "updating cache with new value" + def updated = cacheTestService.updateByKey('mykey', "New value for mykey") + + then: "updated value is returned" + updated == "New value for mykey" + + when: "getting by key again" + def afterUpdate = cacheTestService.getByKey('mykey') + + then: "cached value is the updated one" + afterUpdate == updated + } + + def "CachePut can be called multiple times"() { + when: "updating cache multiple times" + cacheTestService.updateByKey('testkey', "Value 1") + cacheTestService.updateByKey('testkey', "Value 2") + def finalValue = cacheTestService.updateByKey('testkey', "Value 3") + + then: "last update wins" + finalValue == "Value 3" + cacheTestService.getByKey('testkey') == "Value 3" + } + + def "CachePut for one key does not affect other keys"() { + given: "two different cached keys" + def key1Original = cacheTestService.getByKey('key1') + def key2Original = cacheTestService.getByKey('key2') + + when: "updating only key1" + cacheTestService.updateByKey('key1', "Updated key1") + + then: "key1 is updated, key2 is unchanged" + cacheTestService.getByKey('key1') == "Updated key1" + cacheTestService.getByKey('key2') == key2Original + } + + // ========== Conditional Caching Tests ========== + + def "conditional data is cached based on input"() { + when: "fetching non-empty data" + def result1 = cacheTestService.getConditionalData(false) + def result2 = cacheTestService.getConditionalData(false) + + then: "results are cached and consistent" + result1 == result2 + result1 == ['item1', 'item2', 'item3'] + } + + def "different boolean parameters create different cache entries"() { + when: "fetching with different parameters" + def nonEmpty = cacheTestService.getConditionalData(false) + def empty = cacheTestService.getConditionalData(true) + + then: "different results based on parameter" + nonEmpty == ['item1', 'item2', 'item3'] + empty == [] + } + + // ========== HTTP Endpoint Tests ========== + + def "basic cache works via HTTP"() { + given: + def client = createClient() + + // Evict cache to start fresh + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/evictBasic'), String) + + when: "calling endpoint twice" + HttpResponse<String> response1 = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/basicData'), + String + ) + HttpResponse<String> response2 = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/basicData'), + String + ) + + then: "same data returned (caching works)" + response1.status == HttpStatus.OK + response2.status == HttpStatus.OK + + def json1 = new JsonSlurper().parseText(response1.body()) + def json2 = new JsonSlurper().parseText(response2.body()) + + json1.data == json2.data + + cleanup: + client.close() + } + + def "parameter cache works via HTTP"() { + given: + def client = createClient() + + // Evict cache + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/evictAllParam'), String) + + when: "calling with same ID twice" + HttpResponse<String> response1 = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/dataById?id=42'), + String + ) + HttpResponse<String> response2 = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/dataById?id=42'), + String + ) + + then: "cached result returned" + def json1 = new JsonSlurper().parseText(response1.body()) + def json2 = new JsonSlurper().parseText(response2.body()) + + json1.data == json2.data + + cleanup: + client.close() + } + + def "eviction works via HTTP"() { + given: + def client = createClient() + + // Evict cache and populate it + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/evictBasic'), String) + def firstCall = client.toBlocking().exchange(HttpRequest.GET('/cacheTest/basicData'), String) + def firstData = new JsonSlurper().parseText(firstCall.body()).data + + when: "evicting via HTTP then calling again after delay" + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/evictBasic'), String) + Thread.sleep(10) // Ensure timestamp changes + HttpResponse<String> afterEvict = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/basicData'), + String + ) + + then: "new data generated after eviction" + def afterEvictData = new JsonSlurper().parseText(afterEvict.body()).data + firstData != afterEvictData + + cleanup: + client.close() + } + + def "different IDs return different cached values via HTTP"() { + given: + def client = createClient() + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/evictAllParam'), String) + + when: "fetching different IDs" + def response1 = client.toBlocking().exchange(HttpRequest.GET('/cacheTest/dataById?id=100'), String) + def response2 = client.toBlocking().exchange(HttpRequest.GET('/cacheTest/dataById?id=200'), String) + def response3 = client.toBlocking().exchange(HttpRequest.GET('/cacheTest/dataById?id=100'), String) + + then: "different IDs have different data, same ID returns same data" + def json1 = new JsonSlurper().parseText(response1.body()) + def json2 = new JsonSlurper().parseText(response2.body()) + def json3 = new JsonSlurper().parseText(response3.body()) + + json1.data != json2.data + json1.data == json3.data + + cleanup: + client.close() + } + + def "complex data endpoint works with caching"() { + given: + def client = createClient() + + when: "fetching complex data" + def response1 = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/complexData?category=electronics&page=3'), + String + ) + def response2 = client.toBlocking().exchange( + HttpRequest.GET('/cacheTest/complexData?category=electronics&page=3'), + String + ) + + then: "data is cached" + def json1 = new JsonSlurper().parseText(response1.body()) + def json2 = new JsonSlurper().parseText(response2.body()) + + json1.data == json2.data + json1.data.category == 'electronics' + json1.data.page == 3 + + cleanup: + client.close() + } + + def "CachePut works via HTTP with key closure"() { + given: + def client = createClient() + + // Evict keyed cache and get initial value + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/evictAllKeyed'), String) + def initial = new JsonSlurper().parseText( + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/byKey?key=httpkey'), String).body() + ).data + + when: "updating via HTTP" + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/updateByKey?key=httpkey&value=HTTPUpdated'), String) + def afterUpdate = new JsonSlurper().parseText( + client.toBlocking().exchange(HttpRequest.GET('/cacheTest/byKey?key=httpkey'), String).body() + ).data + + then: "cache contains updated value" + afterUpdate == "HTTPUpdated" + afterUpdate != initial + + cleanup: + client.close() + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/springevents/SpringEventsSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/springevents/SpringEventsSpec.groovy new file mode 100644 index 0000000000..01262944bf --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/springevents/SpringEventsSpec.groovy @@ -0,0 +1,379 @@ +/* + * 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.springevents + +import functionaltests.Application +import grails.testing.mixin.integration.Integration +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 org.springframework.beans.factory.annotation.Autowired +import spock.lang.Narrative +import spock.lang.Specification + +/** + * Integration tests for Spring application events in Grails. + * + * Tests event publishing, listening, conditional handling, + * ordered listeners, and event payloads. + */ +@Integration(applicationClass = Application) +@Narrative(''' +Spring's ApplicationEvent mechanism allows decoupled communication between +components. Grails integrates with Spring events via @EventListener annotations +and ApplicationEventPublisher. +''') +class SpringEventsSpec extends Specification { + + @Autowired + EventListenerService eventListenerService + + @Autowired + EventPublisherService eventPublisherService + + private HttpClient createClient() { + HttpClient.create(new URL("http://localhost:$serverPort")) + } + + def setup() { + eventListenerService.clearEvents() + } + + // ========== Basic Event Publishing ========== + + def "custom event is published and received"() { + when: "publishing a custom event" + eventPublisherService.publishCustomEvent("Test Message", [key: "value"]) + + then: "event is received by listener" + eventListenerService.eventCount == 1 + eventListenerService.customEvents.size() == 1 + eventListenerService.customEvents[0].message == "Test Message" + eventListenerService.customEvents[0].payload.key == "value" + } + + def "event createdAt is set"() { + when: "publishing an event" + def before = new Date() + eventPublisherService.publishCustomEvent("Timestamp Test") + def after = new Date() + + then: "createdAt is within expected range" + eventListenerService.customEvents[0].createdAt >= before + eventListenerService.customEvents[0].createdAt <= after + } + + def "user action event is published and received"() { + when: "publishing a user action event" + eventPublisherService.publishUserAction("user-123", "LOGIN", [ip: "192.168.1.1"]) + + then: "event is received with correct data" + eventListenerService.userActionEvents.size() == 1 + eventListenerService.userActionEvents[0].userId == "user-123" + eventListenerService.userActionEvents[0].action == "LOGIN" + eventListenerService.userActionEvents[0].metadata.ip == "192.168.1.1" + } + + // ========== Multiple Events ========== + + def "multiple events are received in order"() { + when: "publishing multiple events" + eventPublisherService.publishMultipleEvents(5) + + then: "all events are received" + eventListenerService.eventCount == 5 + eventListenerService.customEvents.size() == 5 + + and: "events have correct sequence" + eventListenerService.customEvents[0].message == "Event #1" + eventListenerService.customEvents[4].message == "Event #5" + } + + def "events from different types are tracked separately"() { + when: "publishing different event types" + eventPublisherService.publishCustomEvent("Custom 1") + eventPublisherService.publishUserAction("user1", "ACTION1") + eventPublisherService.publishCustomEvent("Custom 2") + eventPublisherService.publishUserAction("user2", "ACTION2") + + then: "events are tracked by type" + eventListenerService.customEvents.size() == 2 + eventListenerService.userActionEvents.size() == 2 + eventListenerService.eventCount == 4 + } + + // ========== Ordered Event Listeners ========== + + def "event listeners are executed in order"() { + when: "publishing a priority event" + eventPublisherService.publishPriorityEvent(1, "test") + + then: "listeners execute in @Order sequence" + eventListenerService.orderedResults.size() == 3 + eventListenerService.orderedResults[0] == "first-test" + eventListenerService.orderedResults[1] == "second-test" + eventListenerService.orderedResults[2] == "third-test" + } + + // ========== Conditional Event Listeners ========== + + def "conditional listener handles matching events"() { + when: "publishing event that matches condition" + eventPublisherService.publishCustomEvent("IMPORTANT: Critical Alert", [:]) + + then: "conditional handler is triggered" + eventListenerService.conditionalResults.any { it.startsWith("CONDITIONAL:IMPORTANT") } + } + + def "conditional listener ignores non-matching events"() { + when: "publishing event that doesn't match condition" + eventPublisherService.publishCustomEvent("NORMAL: Regular message", [:]) + + then: "conditional handler is not triggered" + !eventListenerService.conditionalResults.any { it.startsWith("CONDITIONAL:") } + } + + // ========== Event Publishing via HTTP ========== + + def "event can be published via HTTP endpoint"() { + given: + def client = createClient() + + when: "calling publish endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishCustom?message=HTTP+Event'), + String + ) + + then: "event is published successfully" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.published == true + json.message == "HTTP Event" + + cleanup: + client.close() + } + + def "user action event can be published via HTTP"() { + given: + def client = createClient() + + when: "calling user action publish endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishUserAction?userId=web-user&userAction=CLICK'), + String + ) + + then: "event is published" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.published == true + json.userId == "web-user" + json.userAction == "CLICK" + + cleanup: + client.close() + } + + def "priority event ordering works via HTTP"() { + given: + def client = createClient() + + when: "calling priority publish endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishPriority?data=http-test'), + String + ) + + then: "ordered results are returned" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.orderedResults.size() == 3 + json.orderedResults[0] == "first-http-test" + + cleanup: + client.close() + } + + def "multiple events can be published via HTTP"() { + given: + def client = createClient() + + when: "calling multiple publish endpoint" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishMultiple?count=3'), + String + ) + + then: "all events are received" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.count == 3 + json.receivedCount == 3 + + cleanup: + client.close() + } + + def "conditional event works via HTTP for matching message"() { + given: + def client = createClient() + + when: "publishing important message" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishConditional?important=true'), + String + ) + + then: "conditional handler triggers" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.conditionalResults.any { it.startsWith("CONDITIONAL:") } + + cleanup: + client.close() + } + + def "conditional event skipped via HTTP for non-matching message"() { + given: + def client = createClient() + + when: "publishing normal message" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishConditional?important=false'), + String + ) + + then: "conditional handler does not trigger" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + !json.conditionalResults.any { it.toString().startsWith("CONDITIONAL:") } + + cleanup: + client.close() + } + + // ========== Event Stats ========== + + def "event stats are accessible via HTTP"() { + given: + def client = createClient() + eventPublisherService.publishCustomEvent("Stats Test 1") + eventPublisherService.publishCustomEvent("Stats Test 2") + eventPublisherService.publishUserAction("user", "action") + + when: "getting stats" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/stats'), + String + ) + + then: "stats are returned" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.totalEvents == 3 + json.customEvents == 2 + json.userActionEvents == 1 + + cleanup: + client.close() + } + + // ========== Clear Events ========== + + def "events can be cleared via HTTP"() { + given: + def client = createClient() + eventPublisherService.publishCustomEvent("To be cleared") + + when: "clearing events" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/clearEvents'), + String + ) + + then: "events are cleared" + response.status == HttpStatus.OK + eventListenerService.eventCount == 0 + + cleanup: + client.close() + } + + // ========== Transactional Event Publishing ========== + + def "event can be published in transactional context"() { + given: + def client = createClient() + + when: "publishing transactional event" + HttpResponse<String> response = client.toBlocking().exchange( + HttpRequest.GET('/springEvent/publishTransactional?message=tx-test'), + String + ) + + then: "event is published" + response.status == HttpStatus.OK + def json = new JsonSlurper().parseText(response.body()) + json.published == true + + and: "event is received" + eventListenerService.customEvents.any { it.message.contains("tx-test") } + + cleanup: + client.close() + } + + // ========== Event Payload Tests ========== + + def "event payload can contain complex data"() { + when: "publishing event with complex payload" + def payload = [ + nested: [ + list: [1, 2, 3], + map: [a: 'b', c: 'd'] + ], + string: "value", + number: 42 + ] + eventPublisherService.publishCustomEvent("Complex Payload", payload) + + then: "payload is preserved" + def event = eventListenerService.customEvents[0] + event.payload.nested.list == [1, 2, 3] + event.payload.nested.map.a == 'b' + event.payload.string == "value" + event.payload.number == 42 + } + + def "event payload handles null values"() { + when: "publishing event with null in payload" + eventPublisherService.publishCustomEvent("Null Payload", [key: null]) + + then: "null is preserved" + def event = eventListenerService.customEvents[0] + event.payload.containsKey('key') + event.payload.key == null + } +} diff --git a/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/CustomApplicationEvent.groovy b/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/CustomApplicationEvent.groovy new file mode 100644 index 0000000000..38a324e0bd --- /dev/null +++ b/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/CustomApplicationEvent.groovy @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests.springevents + +import org.springframework.context.ApplicationEvent + +/** + * Custom application event for testing. + */ +class CustomApplicationEvent extends ApplicationEvent { + + String message + Map<String, Object> payload + Date createdAt + + CustomApplicationEvent(Object source, String message, Map<String, Object> payload = [:]) { + super(source) + this.message = message + this.payload = payload + this.createdAt = new Date() + } +} diff --git a/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/PriorityEvent.groovy b/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/PriorityEvent.groovy new file mode 100644 index 0000000000..1eb8084057 --- /dev/null +++ b/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/PriorityEvent.groovy @@ -0,0 +1,37 @@ +/* + * 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.springevents + +import org.springframework.context.ApplicationEvent + +/** + * Event with priority for ordering tests. + */ +class PriorityEvent extends ApplicationEvent { + + int priority + String data + + PriorityEvent(Object source, int priority, String data) { + super(source) + this.priority = priority + this.data = data + } +} diff --git a/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/UserActionEvent.groovy b/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/UserActionEvent.groovy new file mode 100644 index 0000000000..cd5c75f64e --- /dev/null +++ b/grails-test-examples/app1/src/main/groovy/functionaltests/springevents/UserActionEvent.groovy @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests.springevents + +import org.springframework.context.ApplicationEvent + +/** + * Event representing a user action for testing. + */ +class UserActionEvent extends ApplicationEvent { + + String userId + String action + Map<String, String> metadata + + UserActionEvent(Object source, String userId, String action, Map<String, String> metadata = [:]) { + super(source) + this.userId = userId + this.action = action + this.metadata = metadata + } +}
