diff --git a/src/main/java/com/danielagapov/spawn/user/api/UserInterestController.java b/src/main/java/com/danielagapov/spawn/user/api/UserInterestController.java index 9bbecb6f..ca0b2770 100644 --- a/src/main/java/com/danielagapov/spawn/user/api/UserInterestController.java +++ b/src/main/java/com/danielagapov/spawn/user/api/UserInterestController.java @@ -1,8 +1,6 @@ package com.danielagapov.spawn.user.api; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; import com.danielagapov.spawn.user.internal.services.IUserInterestService; -import com.danielagapov.spawn.shared.util.LoggingUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -16,57 +14,46 @@ public class UserInterestController { private final IUserInterestService userInterestService; - private final ILogger logger; @Autowired - public UserInterestController(IUserInterestService userInterestService, ILogger logger) { + public UserInterestController(IUserInterestService userInterestService) { this.userInterestService = userInterestService; - this.logger = logger; } @GetMapping public ResponseEntity> getUserInterests(@PathVariable UUID userId) { - try { - List interests = userInterestService.getUserInterests(userId); - interests = interests.stream() - .map(interest -> interest.replaceAll("^\"|\"$", "")) - .toList(); - return ResponseEntity.ok(interests); - } catch (Exception e) { - logger.error("Error getting user interests for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); - throw e; - } + List interests = userInterestService.getUserInterests(userId); + return ResponseEntity.ok(interests); + } + + @PutMapping + public ResponseEntity> replaceUserInterests( + @PathVariable UUID userId, + @RequestBody List interests) { + List saved = userInterestService.replaceUserInterests(userId, interests); + return ResponseEntity.ok(saved); } @PostMapping public ResponseEntity addUserInterest( @PathVariable UUID userId, @RequestBody String userInterestName) { - userInterestName = userInterestName.replaceAll("^\"|\"$", ""); - try { - String result = userInterestService.addUserInterest(userId, userInterestName); - return new ResponseEntity<>(result, HttpStatus.CREATED); - } catch (Exception e) { - logger.error("Error adding user interest for user: " + LoggingUtils.formatUserIdInfo(userId) + " - interest: " + userInterestName + ": " + e.getMessage()); - throw e; + // @RequestBody with a plain JSON string arrives wrapped in quotes; strip them. + String cleaned = userInterestName.replaceAll("^\"|\"$", "").trim(); + if (cleaned.isEmpty()) { + return ResponseEntity.badRequest().build(); } + String result = userInterestService.addUserInterest(userId, cleaned); + return new ResponseEntity<>(result, HttpStatus.CREATED); } @DeleteMapping("/{interest}") public ResponseEntity removeUserInterest( @PathVariable UUID userId, @PathVariable String interest) { - try { - boolean removed = userInterestService.removeUserInterest(userId, interest); - - if (removed) { - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } else { - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } - } catch (Exception e) { - logger.error("Error removing user interest for user: " + LoggingUtils.formatUserIdInfo(userId) + " - interest: " + interest + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } + boolean removed = userInterestService.removeUserInterest(userId, interest); + return removed + ? new ResponseEntity<>(HttpStatus.NO_CONTENT) + : new ResponseEntity<>(HttpStatus.NOT_FOUND); } } \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/user/internal/repositories/UserInterestRepository.java b/src/main/java/com/danielagapov/spawn/user/internal/repositories/UserInterestRepository.java index dec1544d..69a5ac0b 100644 --- a/src/main/java/com/danielagapov/spawn/user/internal/repositories/UserInterestRepository.java +++ b/src/main/java/com/danielagapov/spawn/user/internal/repositories/UserInterestRepository.java @@ -2,6 +2,9 @@ import com.danielagapov.spawn.user.internal.domain.UserInterest; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -12,4 +15,11 @@ public interface UserInterestRepository extends JpaRepository { List findByUserId(UUID userId); Optional findByUserIdAndInterest(UUID userId, String interest); + + @Query("SELECT ui FROM UserInterest ui WHERE ui.user.id = :userId AND LOWER(ui.interest) = LOWER(:interest)") + Optional findByUserIdAndInterestIgnoreCase(@Param("userId") UUID userId, @Param("interest") String interest); + + @Modifying + @Query("DELETE FROM UserInterest ui WHERE ui.user.id = :userId") + void deleteAllByUserId(@Param("userId") UUID userId); } \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/user/internal/services/IUserInterestService.java b/src/main/java/com/danielagapov/spawn/user/internal/services/IUserInterestService.java index e02c0b8e..840fee1b 100644 --- a/src/main/java/com/danielagapov/spawn/user/internal/services/IUserInterestService.java +++ b/src/main/java/com/danielagapov/spawn/user/internal/services/IUserInterestService.java @@ -4,25 +4,8 @@ import java.util.UUID; public interface IUserInterestService { - /** - * Get all interests for a user - * @param userId The ID of the user - * @return A list of user interest strings - */ List getUserInterests(UUID userId); - - /** - * Add a new interest for a user - * @param user id and interest name - * @return The created user interest string - */ String addUserInterest(UUID userId, String interestName); - - /** - * Remove an interest for a user - * @param userId The ID of the user - * @param encodedInterestName The URL-encoded name of the interest to remove - * @return true if the interest was successfully removed, false if not found - */ - boolean removeUserInterest(UUID userId, String encodedInterestName); + boolean removeUserInterest(UUID userId, String interestName); + List replaceUserInterests(UUID userId, List interests); } \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/user/internal/services/UserInterestService.java b/src/main/java/com/danielagapov/spawn/user/internal/services/UserInterestService.java index c5135fa6..c8fe041e 100644 --- a/src/main/java/com/danielagapov/spawn/user/internal/services/UserInterestService.java +++ b/src/main/java/com/danielagapov/spawn/user/internal/services/UserInterestService.java @@ -10,12 +10,9 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Service @@ -35,8 +32,7 @@ public UserInterestService(UserInterestRepository userInterestRepository, IUserR @Override @Cacheable(value = "userInterests", key = "#userId") public List getUserInterests(UUID userId) { - List interests = userInterestRepository.findByUserId(userId); - return interests.stream() + return userInterestRepository.findByUserId(userId).stream() .map(UserInterest::getInterest) .collect(Collectors.toList()); } @@ -44,54 +40,56 @@ public List getUserInterests(UUID userId) { @Override @CacheEvict(value = "userInterests", key = "#userId") public String addUserInterest(UUID userId, String interestName) { + String trimmed = interestName.trim(); + + Optional existing = userInterestRepository.findByUserIdAndInterestIgnoreCase(userId, trimmed); + if (existing.isPresent()) { + return existing.get().getInterest(); + } + User user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found with id: " + userId)); - UserInterest userInterest = new UserInterest(user, interestName); + UserInterest userInterest = new UserInterest(user, trimmed); userInterest = userInterestRepository.save(userInterest); - return userInterest.getInterest(); } @Override @CacheEvict(value = "userInterests", key = "#userId") - public boolean removeUserInterest(UUID userId, String encodedInterestName) { - try { - // URL decode the interest name to handle spaces and special characters - String decodedInterest = URLDecoder.decode(encodedInterestName, StandardCharsets.UTF_8); - - logger.info("Attempting to remove interest '" + decodedInterest + "' (encoded: '" + encodedInterestName + "') for user: " + LoggingUtils.formatUserIdInfo(userId)); - - // Debug: Log all existing interests for this user - List allUserInterests = userInterestRepository.findByUserId(userId); - logger.info("User " + LoggingUtils.formatUserIdInfo(userId) + " currently has " + allUserInterests.size() + " interests:"); - for (UserInterest existingInterest : allUserInterests) { - logger.info(" - '" + existingInterest.getInterest() + "' (length: " + existingInterest.getInterest().length() + ")"); - } - - Optional userInterestOpt = userInterestRepository.findByUserIdAndInterest(userId, decodedInterest); - - if (userInterestOpt.isPresent()) { - userInterestRepository.delete(userInterestOpt.get()); - logger.info("Successfully removed interest '" + decodedInterest + "' for user: " + LoggingUtils.formatUserIdInfo(userId)); - return true; - } else { - logger.warn("Interest '" + decodedInterest + "' not found for user: " + LoggingUtils.formatUserIdInfo(userId)); - logger.warn("Exact search failed. Trying case-insensitive search..."); - - // Try case-insensitive search for debugging - for (UserInterest existingInterest : allUserInterests) { - if (existingInterest.getInterest().equalsIgnoreCase(decodedInterest)) { - logger.warn("Found case-insensitive match: '" + existingInterest.getInterest() + "' vs '" + decodedInterest + "'"); - break; - } - } - - return false; + public boolean removeUserInterest(UUID userId, String interestName) { + // Spring already URL-decodes @PathVariable, so no manual decoding needed. + // Use case-insensitive lookup to be resilient to casing mismatches. + Optional userInterestOpt = userInterestRepository.findByUserIdAndInterestIgnoreCase(userId, interestName); + + if (userInterestOpt.isPresent()) { + userInterestRepository.delete(userInterestOpt.get()); + return true; + } + + logger.warn("Interest '" + interestName + "' not found for user: " + LoggingUtils.formatUserIdInfo(userId)); + return false; + } + + @Override + @Transactional + @CacheEvict(value = "userInterests", key = "#userId") + public List replaceUserInterests(UUID userId, List interests) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found with id: " + userId)); + + userInterestRepository.deleteAllByUserId(userId); + + List saved = new ArrayList<>(); + Set seen = new HashSet<>(); + for (String interest : interests) { + String trimmed = interest.trim(); + if (!trimmed.isEmpty() && seen.add(trimmed.toLowerCase())) { + UserInterest entity = new UserInterest(user, trimmed); + userInterestRepository.save(entity); + saved.add(trimmed); } - } catch (Exception e) { - logger.error("Error removing interest '" + encodedInterestName + "' for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); - throw new RuntimeException("Failed to remove user interest", e); } + return saved; } } \ No newline at end of file diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/UserInterestServiceTest.java b/src/test/java/com/danielagapov/spawn/ServiceTests/UserInterestServiceTest.java index 4ee85854..5dadbf45 100644 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/UserInterestServiceTest.java +++ b/src/test/java/com/danielagapov/spawn/ServiceTests/UserInterestServiceTest.java @@ -43,13 +43,13 @@ public class UserInterestServiceTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - + testUserId = UUID.randomUUID(); testUser = new User(); testUser.setId(testUserId); testUser.setUsername("testuser"); testUser.setEmail("test@example.com"); - + testUserInterest = new UserInterest(); testUserInterest.setId(UUID.randomUUID()); testUserInterest.setUser(testUser); @@ -59,17 +59,14 @@ void setUp() { @Test void getUserInterests_ShouldReturnListOfInterests_WhenUserHasInterests() { - // Arrange UserInterest interest1 = new UserInterest(testUser, "hiking"); UserInterest interest2 = new UserInterest(testUser, "cooking"); List userInterests = Arrays.asList(interest1, interest2); - + when(userInterestRepository.findByUserId(testUserId)).thenReturn(userInterests); - // Act List result = userInterestService.getUserInterests(testUserId); - // Assert assertEquals(2, result.size()); assertTrue(result.contains("hiking")); assertTrue(result.contains("cooking")); @@ -78,43 +75,52 @@ void getUserInterests_ShouldReturnListOfInterests_WhenUserHasInterests() { @Test void getUserInterests_ShouldReturnEmptyList_WhenUserHasNoInterests() { - // Arrange when(userInterestRepository.findByUserId(testUserId)).thenReturn(Arrays.asList()); - // Act List result = userInterestService.getUserInterests(testUserId); - // Assert assertTrue(result.isEmpty()); verify(userInterestRepository).findByUserId(testUserId); } @Test void addUserInterest_ShouldReturnInterestName_WhenSuccessful() { - // Arrange String interestName = "photography"; + when(userInterestRepository.findByUserIdAndInterestIgnoreCase(testUserId, interestName)) + .thenReturn(Optional.empty()); when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); when(userInterestRepository.save(any(UserInterest.class))).thenReturn(testUserInterest); - // Act String result = userInterestService.addUserInterest(testUserId, interestName); - // Assert - assertEquals("hiking", result); // testUserInterest has "hiking" as interest + assertEquals("hiking", result); verify(userRepository).findById(testUserId); verify(userInterestRepository).save(any(UserInterest.class)); } + @Test + void addUserInterest_ShouldReturnExisting_WhenDuplicate() { + String interestName = "hiking"; + when(userInterestRepository.findByUserIdAndInterestIgnoreCase(testUserId, interestName)) + .thenReturn(Optional.of(testUserInterest)); + + String result = userInterestService.addUserInterest(testUserId, interestName); + + assertEquals("hiking", result); + verify(userRepository, never()).findById(any()); + verify(userInterestRepository, never()).save(any(UserInterest.class)); + } + @Test void addUserInterest_ShouldThrowException_WhenUserNotFound() { - // Arrange String interestName = "photography"; + when(userInterestRepository.findByUserIdAndInterestIgnoreCase(testUserId, interestName)) + .thenReturn(Optional.empty()); when(userRepository.findById(testUserId)).thenReturn(Optional.empty()); - // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, + RuntimeException exception = assertThrows(RuntimeException.class, () -> userInterestService.addUserInterest(testUserId, interestName)); - + assertTrue(exception.getMessage().contains("User not found")); verify(userRepository).findById(testUserId); verify(userInterestRepository, never()).save(any(UserInterest.class)); @@ -122,121 +128,91 @@ void addUserInterest_ShouldThrowException_WhenUserNotFound() { @Test void removeUserInterest_ShouldReturnTrue_WhenInterestExists() { - // Arrange - String encodedInterest = "hiking"; - when(userInterestRepository.findByUserId(testUserId)) - .thenReturn(Arrays.asList(testUserInterest)); - when(userInterestRepository.findByUserIdAndInterest(testUserId, "hiking")) + when(userInterestRepository.findByUserIdAndInterestIgnoreCase(testUserId, "hiking")) .thenReturn(Optional.of(testUserInterest)); - // Act - boolean result = userInterestService.removeUserInterest(testUserId, encodedInterest); + boolean result = userInterestService.removeUserInterest(testUserId, "hiking"); + + assertTrue(result); + verify(userInterestRepository).findByUserIdAndInterestIgnoreCase(testUserId, "hiking"); + verify(userInterestRepository).delete(testUserInterest); + } + + @Test + void removeUserInterest_ShouldReturnTrue_WhenCaseDiffers() { + when(userInterestRepository.findByUserIdAndInterestIgnoreCase(testUserId, "Hiking")) + .thenReturn(Optional.of(testUserInterest)); + + boolean result = userInterestService.removeUserInterest(testUserId, "Hiking"); - // Assert assertTrue(result); - verify(userInterestRepository).findByUserId(testUserId); - verify(userInterestRepository).findByUserIdAndInterest(testUserId, "hiking"); verify(userInterestRepository).delete(testUserInterest); - verify(logger).info(contains("Attempting to remove interest")); - verify(logger).info(contains("Successfully removed interest")); } @Test void removeUserInterest_ShouldReturnFalse_WhenInterestNotFound() { - // Arrange - String encodedInterest = "nonexistent"; - when(userInterestRepository.findByUserId(testUserId)) - .thenReturn(Arrays.asList(testUserInterest)); - when(userInterestRepository.findByUserIdAndInterest(testUserId, "nonexistent")) + when(userInterestRepository.findByUserIdAndInterestIgnoreCase(testUserId, "nonexistent")) .thenReturn(Optional.empty()); - // Act - boolean result = userInterestService.removeUserInterest(testUserId, encodedInterest); + boolean result = userInterestService.removeUserInterest(testUserId, "nonexistent"); - // Assert assertFalse(result); - verify(userInterestRepository).findByUserId(testUserId); - verify(userInterestRepository).findByUserIdAndInterest(testUserId, "nonexistent"); verify(userInterestRepository, never()).delete(any(UserInterest.class)); - verify(logger).info(contains("Attempting to remove interest")); - verify(logger).warn(contains("Interest 'nonexistent' not found")); } @Test - void removeUserInterest_ShouldHandleUrlEncodedInterest_WhenInterestHasSpaces() { - // Arrange - String encodedInterest = "rock%20climbing"; - String decodedInterest = "rock climbing"; - - UserInterest spaceInterest = new UserInterest(testUser, decodedInterest); - when(userInterestRepository.findByUserId(testUserId)) - .thenReturn(Arrays.asList(spaceInterest)); - when(userInterestRepository.findByUserIdAndInterest(testUserId, decodedInterest)) - .thenReturn(Optional.of(spaceInterest)); - - // Act - boolean result = userInterestService.removeUserInterest(testUserId, encodedInterest); - - // Assert - assertTrue(result); - verify(userInterestRepository).findByUserId(testUserId); - verify(userInterestRepository).findByUserIdAndInterest(testUserId, decodedInterest); - verify(userInterestRepository).delete(spaceInterest); - verify(logger, times(3)).info(contains("rock climbing")); // Called three times: attempting + debug listing + successfully + void replaceUserInterests_ShouldReplaceAll() { + List newInterests = Arrays.asList("cooking", "hiking", "photography"); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userInterestRepository.save(any(UserInterest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + List result = userInterestService.replaceUserInterests(testUserId, newInterests); + + assertEquals(3, result.size()); + assertTrue(result.contains("cooking")); + assertTrue(result.contains("hiking")); + assertTrue(result.contains("photography")); + verify(userInterestRepository).deleteAllByUserId(testUserId); + verify(userInterestRepository, times(3)).save(any(UserInterest.class)); } @Test - void removeUserInterest_ShouldHandleUrlEncodedInterest_WhenInterestHasSpecialCharacters() { - // Arrange - String encodedInterest = "caf%C3%A9%20culture"; - String decodedInterest = "café culture"; - - UserInterest specialInterest = new UserInterest(testUser, decodedInterest); - when(userInterestRepository.findByUserId(testUserId)) - .thenReturn(Arrays.asList(specialInterest)); - when(userInterestRepository.findByUserIdAndInterest(testUserId, decodedInterest)) - .thenReturn(Optional.of(specialInterest)); - - // Act - boolean result = userInterestService.removeUserInterest(testUserId, encodedInterest); - - // Assert - assertTrue(result); - verify(userInterestRepository).findByUserId(testUserId); - verify(userInterestRepository).findByUserIdAndInterest(testUserId, decodedInterest); - verify(userInterestRepository).delete(specialInterest); - verify(logger, times(3)).info(contains("café culture")); // Called three times: attempting + debug listing + successfully + void replaceUserInterests_ShouldDeduplicateCaseInsensitive() { + List newInterests = Arrays.asList("Hiking", "hiking", "HIKING"); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userInterestRepository.save(any(UserInterest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + List result = userInterestService.replaceUserInterests(testUserId, newInterests); + + assertEquals(1, result.size()); + assertEquals("Hiking", result.get(0)); + verify(userInterestRepository, times(1)).save(any(UserInterest.class)); } @Test - void removeUserInterest_ShouldThrowException_WhenRepositoryThrowsException() { - // Arrange - String encodedInterest = "hiking"; - when(userInterestRepository.findByUserIdAndInterest(testUserId, "hiking")) - .thenThrow(new RuntimeException("Database connection failed")); + void replaceUserInterests_ShouldSkipEmptyAndWhitespace() { + List newInterests = Arrays.asList("hiking", "", " ", "cooking"); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userInterestRepository.save(any(UserInterest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); - // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, - () -> userInterestService.removeUserInterest(testUserId, encodedInterest)); - - assertTrue(exception.getMessage().contains("Failed to remove user interest")); - verify(logger).error(contains("Error removing interest")); + List result = userInterestService.replaceUserInterests(testUserId, newInterests); + + assertEquals(2, result.size()); + assertTrue(result.contains("hiking")); + assertTrue(result.contains("cooking")); + verify(userInterestRepository, times(2)).save(any(UserInterest.class)); } @Test - void removeUserInterest_ShouldLogError_WhenUnexpectedExceptionOccurs() { - // Arrange - String encodedInterest = "hiking"; - when(userInterestRepository.findByUserIdAndInterest(testUserId, "hiking")) - .thenReturn(Optional.of(testUserInterest)); - doThrow(new RuntimeException("Delete failed")) - .when(userInterestRepository).delete(testUserInterest); + void replaceUserInterests_ShouldThrowException_WhenUserNotFound() { + when(userRepository.findById(testUserId)).thenReturn(Optional.empty()); - // Act & Assert RuntimeException exception = assertThrows(RuntimeException.class, - () -> userInterestService.removeUserInterest(testUserId, encodedInterest)); - - assertTrue(exception.getMessage().contains("Failed to remove user interest")); - verify(logger).error(contains("Error removing interest")); + () -> userInterestService.replaceUserInterests(testUserId, Arrays.asList("hiking"))); + + assertTrue(exception.getMessage().contains("User not found")); } -} \ No newline at end of file +}