diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index b4b614559e5..644a20fc5ca 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -52,8 +52,11 @@ class OwnerController { private final OwnerRepository owners; - public OwnerController(OwnerRepository owners) { + private final OwnerDirectorySynchronizer ownerDirectorySynchronizer; + + public OwnerController(OwnerRepository owners, OwnerDirectorySynchronizer ownerDirectorySynchronizer) { this.owners = owners; + this.ownerDirectorySynchronizer = ownerDirectorySynchronizer; } @InitBinder @@ -82,6 +85,7 @@ public String processCreationForm(@Valid Owner owner, BindingResult result, Redi } this.owners.save(owner); + this.ownerDirectorySynchronizer.ownerAggregateChanged(); redirectAttributes.addFlashAttribute("message", "New Owner Created"); return "redirect:/owners/" + owner.getId(); } @@ -157,6 +161,7 @@ public String processUpdateOwnerForm(@Valid Owner owner, BindingResult result, @ owner.setId(ownerId); this.owners.save(owner); + this.ownerDirectorySynchronizer.ownerAggregateChanged(); redirectAttributes.addFlashAttribute("message", "Owner Values Updated"); return "redirect:/owners/{ownerId}"; } diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizer.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizer.java new file mode 100644 index 00000000000..13793c82b2e --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 org.springframework.samples.petclinic.owner; + +import org.springframework.samples.petclinic.system.CacheOperations; +import org.springframework.stereotype.Service; + +/** + * Keeps front-desk owner-directory projections synchronized after aggregate changes. + */ +@Service +public class OwnerDirectorySynchronizer { + + private final CacheOperations cacheOperations; + + public OwnerDirectorySynchronizer(CacheOperations cacheOperations) { + this.cacheOperations = cacheOperations; + } + + public void ownerAggregateChanged() { + this.cacheOperations.evict(this.cacheOperations.ownerSearchCache()); + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java index d2b3dde40f8..9097eb44dfa 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java @@ -20,6 +20,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.samples.petclinic.system.CacheOperations; /** * Repository class for Owner domain objects. All method names are compliant @@ -42,6 +44,7 @@ public interface OwnerRepository extends JpaRepository { * @return a Collection of matching {@link Owner}s (or an empty Collection if none * found) */ + @Cacheable(CacheOperations.OWNER_SEARCH_CACHE) Page findByLastNameStartingWith(String lastName, Pageable pageable); /** diff --git a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java index 845c116633b..b10e156a602 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java @@ -54,9 +54,13 @@ class PetController { private final PetTypeRepository types; - public PetController(OwnerRepository owners, PetTypeRepository types) { + private final OwnerDirectorySynchronizer ownerDirectorySynchronizer; + + public PetController(OwnerRepository owners, PetTypeRepository types, + OwnerDirectorySynchronizer ownerDirectorySynchronizer) { this.owners = owners; this.types = types; + this.ownerDirectorySynchronizer = ownerDirectorySynchronizer; } @ModelAttribute("types") @@ -132,6 +136,7 @@ public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult res result.rejectValue("name", "duplicate", "already exists"); return VIEWS_PETS_CREATE_OR_UPDATE_FORM; } + this.ownerDirectorySynchronizer.ownerAggregateChanged(); redirectAttributes.addFlashAttribute("message", "New Pet has been Added"); return "redirect:/owners/{ownerId}"; } @@ -174,6 +179,7 @@ public String processUpdateForm(Owner owner, @Valid Pet pet, BindingResult resul result.rejectValue("name", "duplicate", "already exists"); return VIEWS_PETS_CREATE_OR_UPDATE_FORM; } + this.ownerDirectorySynchronizer.ownerAggregateChanged(); redirectAttributes.addFlashAttribute("message", "Pet details has been edited"); return "redirect:/owners/{ownerId}"; } diff --git a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java index b8b270073e8..a39a21371ab 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java @@ -44,8 +44,11 @@ class VisitController { private final OwnerRepository owners; - public VisitController(OwnerRepository owners) { + private final OwnerDirectorySynchronizer ownerDirectorySynchronizer; + + public VisitController(OwnerRepository owners, OwnerDirectorySynchronizer ownerDirectorySynchronizer) { this.owners = owners; + this.ownerDirectorySynchronizer = ownerDirectorySynchronizer; } @InitBinder @@ -107,6 +110,7 @@ public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int owner.addVisit(petId, visit); this.owners.save(owner); + this.ownerDirectorySynchronizer.ownerAggregateChanged(); redirectAttributes.addFlashAttribute("message", "Your visit has been booked"); return "redirect:/owners/{ownerId}"; } diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java b/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java index 13cb743012d..d8ca882083d 100644 --- a/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java +++ b/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java @@ -34,7 +34,10 @@ class CacheConfiguration { @Bean public JCacheManagerCustomizer petclinicCacheConfigurationCustomizer() { - return cm -> cm.createCache("vets", cacheConfiguration()); + return cm -> { + cm.createCache("vets", cacheConfiguration()); + cm.createCache(CacheOperations.OWNER_SEARCH_CACHE, cacheConfiguration()); + }; } /** diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheDiagnosticsController.java b/src/main/java/org/springframework/samples/petclinic/system/CacheDiagnosticsController.java new file mode 100644 index 00000000000..df71acf3734 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/CacheDiagnosticsController.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 org.springframework.samples.petclinic.system; + +import java.util.Map; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Lightweight operational endpoints for checking and refreshing owner-search data. + */ +@RestController +@RequestMapping("/manage/cache/owner-search") +class CacheDiagnosticsController { + + private final CacheOperations cacheOperations; + + CacheDiagnosticsController(CacheOperations cacheOperations) { + this.cacheOperations = cacheOperations; + } + + @GetMapping + CacheOperations.CacheStatus status() { + return this.cacheOperations.ownerSearchStatus(); + } + + @DeleteMapping + Map evict() { + boolean evicted = this.cacheOperations.evictOwnerSearch(); + return Map.of("cache", CacheOperations.OWNER_SEARCH_CACHE, "evicted", evicted); + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheOperations.java b/src/main/java/org/springframework/samples/petclinic/system/CacheOperations.java new file mode 100644 index 00000000000..1868cfc4697 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/CacheOperations.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 org.springframework.samples.petclinic.system; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +/** + * Operational boundary for inspecting and invalidating application caches. + */ +@Service +public class CacheOperations { + + public static final String OWNER_SEARCH_CACHE = "ownerSearch"; + + private final CacheManager cacheManager; + + public CacheOperations(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + /** + * Invalidate all cached owner-directory searches. + * @return {@code true} when the configured cache was available + */ + public boolean evictOwnerSearch() { + return evict(ownerSearchCache()); + } + + /** + * Evict every entry from the supplied cache. + * @param cache the cache selected by the caller + * @return {@code true} when a cache was supplied + */ + public boolean evict(Cache cache) { + if (cache == null) { + return false; + } + cache.clear(); + return true; + } + + /** + * Report whether the owner-search cache is currently available. + */ + public CacheStatus ownerSearchStatus() { + return new CacheStatus(OWNER_SEARCH_CACHE, ownerSearchCache() != null); + } + + /** + * Resolve the cache used by the owner directory. + */ + public Cache ownerSearchCache() { + return this.cacheManager.getCache(OWNER_SEARCH_CACHE); + } + + public record CacheStatus(String name, boolean available) { + } + +} diff --git a/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java index dd379a5cbd3..355c2147481 100644 --- a/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java +++ b/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java @@ -70,6 +70,9 @@ class OwnerControllerTests { @MockitoBean private OwnerRepository owners; + @MockitoBean + private OwnerDirectorySynchronizer ownerDirectorySynchronizer; + private Owner george() { Owner george = new Owner(); george.setId(TEST_OWNER_ID); diff --git a/src/test/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizerTests.java new file mode 100644 index 00000000000..b7e497ac85a --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizerTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 org.springframework.samples.petclinic.owner; + +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.samples.petclinic.system.CacheOperations; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class OwnerDirectorySynchronizerTests { + + @Test + void ownerAggregateChangesInvalidateCachedSearches() { + CacheOperations cacheOperations = mock(CacheOperations.class); + Cache ownerSearchCache = mock(Cache.class); + given(cacheOperations.ownerSearchCache()).willReturn(ownerSearchCache); + OwnerDirectorySynchronizer synchronizer = new OwnerDirectorySynchronizer(cacheOperations); + + synchronizer.ownerAggregateChanged(); + + verify(cacheOperations).evict(ownerSearchCache); + } + +} diff --git a/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java index b45ffd48d1e..43ed6ec3116 100644 --- a/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java +++ b/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java @@ -67,6 +67,9 @@ class PetControllerTests { @MockitoBean private PetTypeRepository types; + @MockitoBean + private OwnerDirectorySynchronizer ownerDirectorySynchronizer; + @BeforeEach void setup() { PetType cat = new PetType(); diff --git a/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java index b608caa6711..20557832823 100644 --- a/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java +++ b/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java @@ -56,6 +56,9 @@ class VisitControllerTests { @MockitoBean private OwnerRepository owners; + @MockitoBean + private OwnerDirectorySynchronizer ownerDirectorySynchronizer; + @BeforeEach void init() { Owner owner = new Owner(); diff --git a/src/test/java/org/springframework/samples/petclinic/system/CacheDiagnosticsControllerTests.java b/src/test/java/org/springframework/samples/petclinic/system/CacheDiagnosticsControllerTests.java new file mode 100644 index 00000000000..fd5217cdc22 --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/system/CacheDiagnosticsControllerTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 org.springframework.samples.petclinic.system; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CacheDiagnosticsController.class) +class CacheDiagnosticsControllerTests { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CacheOperations cacheOperations; + + @Test + void reportsOwnerSearchCacheStatus() throws Exception { + given(this.cacheOperations.ownerSearchStatus()) + .willReturn(new CacheOperations.CacheStatus(CacheOperations.OWNER_SEARCH_CACHE, true)); + + this.mockMvc.perform(get("/manage/cache/owner-search")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value(CacheOperations.OWNER_SEARCH_CACHE)) + .andExpect(jsonPath("$.available").value(true)); + } + + @Test + void evictsOwnerSearchCache() throws Exception { + given(this.cacheOperations.evictOwnerSearch()).willReturn(true); + + this.mockMvc.perform(delete("/manage/cache/owner-search")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cache").value(CacheOperations.OWNER_SEARCH_CACHE)) + .andExpect(jsonPath("$.evicted").value(true)); + } + +} diff --git a/src/test/java/org/springframework/samples/petclinic/system/CacheOperationsTests.java b/src/test/java/org/springframework/samples/petclinic/system/CacheOperationsTests.java new file mode 100644 index 00000000000..6a2ea66cf19 --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/system/CacheOperationsTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 org.springframework.samples.petclinic.system; + +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class CacheOperationsTests { + + @Test + void evictsTheOwnerSearchCache() { + CacheManager cacheManager = mock(CacheManager.class); + Cache cache = mock(Cache.class); + given(cacheManager.getCache(CacheOperations.OWNER_SEARCH_CACHE)).willReturn(cache); + CacheOperations operations = new CacheOperations(cacheManager); + + assertThat(operations.evictOwnerSearch()).isTrue(); + + verify(cache).clear(); + } + + @Test + void reportsWhenOwnerSearchCacheIsUnavailable() { + CacheManager cacheManager = mock(CacheManager.class); + CacheOperations operations = new CacheOperations(cacheManager); + + assertThat(operations.evictOwnerSearch()).isFalse(); + assertThat(operations.ownerSearchStatus()) + .isEqualTo(new CacheOperations.CacheStatus(CacheOperations.OWNER_SEARCH_CACHE, false)); + } + +}