From 2f8c4dad7ebd56b740701600cfeab9d136cd1cc0 Mon Sep 17 00:00:00 2001 From: yennanliu Date: Sat, 5 Jul 2025 17:49:12 +0800 Subject: [PATCH 1/6] add Recommend service from low level api from scratch --- .../yen/SpotifyPlayList/config/WebConfig.java | 6 ++ .../controller/RecommendationsController.java | 18 ++--- .../CustomSpotifyRecommendationService.java | 75 +++++++++++++++++++ .../src/main/resources/application.properties | 3 +- 4 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java index 330a74aab..798ef37ea 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -31,4 +32,9 @@ public void addCorsMappings(CorsRegistry registry) { } }; } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } } diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java index bb7f578d2..da0a61447 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java @@ -1,13 +1,12 @@ package com.yen.SpotifyPlayList.controller; import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; -import com.yen.SpotifyPlayList.service.RecommendationsService; +import com.yen.SpotifyPlayList.service.CustomSpotifyRecommendationService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import se.michaelthelin.spotify.model_objects.specification.Recommendations; @Slf4j @RestController @@ -15,30 +14,29 @@ public class RecommendationsController { @Autowired - private RecommendationsService recommendationsService; + private CustomSpotifyRecommendationService recommendationsService; @PostMapping("/") - public ResponseEntity getRecommendation(@RequestBody GetRecommendationsDto getRecommendationsDto) { + public ResponseEntity getRecommendation(@RequestBody GetRecommendationsDto getRecommendationsDto) { try { log.info("(getRecommendation) getRecommendationsDto = " + getRecommendationsDto.toString()); - Recommendations recommendations = recommendationsService.getRecommendation(getRecommendationsDto); - return ResponseEntity.status(HttpStatus.OK).body(recommendations); + ResponseEntity recommendations = recommendationsService.getRecommendations(getRecommendationsDto); + return ResponseEntity.status(recommendations.getStatusCode()).body(recommendations.getBody()); } catch (Exception e) { log.error("getRecommendation error : " + e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); } } + // TODO: Implement custom recommendation logic for playlist-based recommendations @GetMapping("/playlist/{playListId}") - public ResponseEntity getRecommendationWithPlayList(@PathVariable("playListId") String playListId) { + public ResponseEntity getRecommendationWithPlayList(@PathVariable("playListId") String playListId) { try { log.info("(getRecommendationWithPlayList) playListId = " + playListId); - Recommendations recommendations = recommendationsService.getRecommendationWithPlayList(playListId); - return ResponseEntity.status(HttpStatus.OK).body(recommendations); + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body("Feature not yet implemented with custom service"); } catch (Exception e) { log.error("getRecommendationWithPlayList error : " + e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); } } - } \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java new file mode 100644 index 000000000..bc159e220 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java @@ -0,0 +1,75 @@ +package com.yen.SpotifyPlayList.service; + +import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@Slf4j +public class CustomSpotifyRecommendationService { + + @Value("${spotify.api.base-url:https://api.spotify.com/v1}") + private String spotifyApiBaseUrl; + + @Autowired + private AuthService authService; + + @Autowired + private RestTemplate restTemplate; + + public ResponseEntity getRecommendations(GetRecommendationsDto request) { + try { + String url = buildRecommendationUrl(request); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(authService.getAccessToken()); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + log.info("Making recommendation request to URL: {}", url); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + entity, + String.class + ); + + log.info("Received recommendation response: {}", response.getStatusCode()); + return response; + + } catch (Exception e) { + log.error("Error getting recommendations: {}", e.getMessage()); + throw new RuntimeException("Failed to get recommendations", e); + } + } + + private String buildRecommendationUrl(GetRecommendationsDto request) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(spotifyApiBaseUrl + "/recommendations"); + + // Add seed parameters + if (request.getSeedArtistId() != null) { + builder.queryParam("seed_artists", request.getSeedArtistId()); + } + if (request.getSeedTrack() != null) { + builder.queryParam("seed_tracks", request.getSeedTrack()); + } + if (request.getSeedGenres() != null) { + builder.queryParam("seed_genres", request.getSeedGenres()); + } + + // Add other parameters + builder.queryParam("limit", request.getAmount()); + builder.queryParam("market", request.getMarket()); + builder.queryParam("min_popularity", request.getMinPopularity()); + builder.queryParam("max_popularity", request.getMaxPopularity()); + builder.queryParam("target_popularity", request.getTargetPopularity()); + + return builder.build().encode().toUriString(); + } +} \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties index 2b43d5573..03c39d7f7 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties @@ -7,4 +7,5 @@ spotify.redirect.url=http://localhost:8080/playlist #spotify.redirectURL=http://:8080/playlist spotify.authorize.scope=playlist-modify-public,playlist-modify-private,user-read-private,user-read-email -spotify.userId=62kytpy7jswykfjtnjn9zv3ou \ No newline at end of file +spotify.userId=62kytpy7jswykfjtnjn9zv3ou +spotify.api.base-url=https://api.spotify.com/v1 \ No newline at end of file From e56f61746bc6650788c1528593599cd1eda3a123 Mon Sep 17 00:00:00 2001 From: yennanliu Date: Sat, 5 Jul 2025 17:55:48 +0800 Subject: [PATCH 2/6] add CustomSpotifyRecommendationServiceTest.java --- ...ustomSpotifyRecommendationServiceTest.java | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java new file mode 100644 index 000000000..7f0a42095 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java @@ -0,0 +1,167 @@ +package com.yen.SpotifyPlayList.service; + +import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +public class CustomSpotifyRecommendationServiceTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private AuthService authService; + + @InjectMocks + private CustomSpotifyRecommendationService recommendationService; + + private static final String MOCK_ACCESS_TOKEN = "mock-access-token"; + private static final String MOCK_RESPONSE = "{\"tracks\": [], \"seeds\": []}"; + + @BeforeEach + void setUp() { + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + } + + @Test + void getRecommendations_Success() { + // Arrange + GetRecommendationsDto dto = new GetRecommendationsDto(); + dto.setSeedArtistId("artist123"); + dto.setSeedTrack("track123"); + dto.setSeedGenres("rock"); + + ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // Act + ResponseEntity response = recommendationService.getRecommendations(dto); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MOCK_RESPONSE, response.getBody()); + + // Verify HTTP call was made with correct headers + verify(restTemplate).exchange( + anyString(), + eq(HttpMethod.GET), + argThat(entity -> { + HttpHeaders headers = entity.getHeaders(); + return headers.getContentType().equals(MediaType.APPLICATION_JSON) && + headers.getFirst(HttpHeaders.AUTHORIZATION).equals("Bearer " + MOCK_ACCESS_TOKEN); + }), + eq(String.class) + ); + } + + @Test + void getRecommendations_WithNullSeeds_Success() { + // Arrange + GetRecommendationsDto dto = new GetRecommendationsDto(); + // Don't set any seeds - test null handling + + ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // Act + ResponseEntity response = recommendationService.getRecommendations(dto); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void getRecommendations_WhenAuthServiceFails_ThrowsException() { + // Arrange + when(authService.getAccessToken()).thenThrow(new RuntimeException("Auth failed")); + GetRecommendationsDto dto = new GetRecommendationsDto(); + + // Act & Assert + Exception exception = assertThrows(RuntimeException.class, () -> { + recommendationService.getRecommendations(dto); + }); + assertTrue(exception.getMessage().contains("Failed to get recommendations")); + } + + @Test + void getRecommendations_WhenSpotifyApiFails_ThrowsException() { + // Arrange + GetRecommendationsDto dto = new GetRecommendationsDto(); + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenThrow(new RuntimeException("API call failed")); + + // Act & Assert + Exception exception = assertThrows(RuntimeException.class, () -> { + recommendationService.getRecommendations(dto); + }); + assertTrue(exception.getMessage().contains("Failed to get recommendations")); + } + + @Test + void getRecommendations_VerifyUrlConstruction() { + // Arrange + GetRecommendationsDto dto = new GetRecommendationsDto(); + dto.setSeedArtistId("artist123"); + dto.setSeedTrack("track123"); + dto.setSeedGenres("rock"); + dto.setAmount(5); + + ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); + when(restTemplate.exchange( + argThat((String url) -> + url.contains("seed_artists=artist123") && + url.contains("seed_tracks=track123") && + url.contains("seed_genres=rock") && + url.contains("limit=5") + ), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // Act + recommendationService.getRecommendations(dto); + + // Verify + verify(restTemplate).exchange( + argThat((String url) -> + url.contains("recommendations") && + url.contains("seed_artists=artist123") && + url.contains("seed_tracks=track123") && + url.contains("seed_genres=rock") && + url.contains("limit=5") + ), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + } +} \ No newline at end of file From 3261391bc8df083de87559da2dd2ecb71afe56aa Mon Sep 17 00:00:00 2001 From: yennanliu Date: Sat, 5 Jul 2025 18:05:14 +0800 Subject: [PATCH 3/6] update AuthService., CustomSpotifyRecommendationService, its UT, add IAuthService interface --- .../SpotifyPlayList/service/AuthService.java | 2 +- .../CustomSpotifyRecommendationService.java | 2 +- .../SpotifyPlayList/service/IAuthService.java | 6 +++ ...ustomSpotifyRecommendationServiceTest.java | 42 +++++++++++-------- 4 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java index 7669d8bb6..ce0259972 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java @@ -18,7 +18,7 @@ @Service @Slf4j -public class AuthService { +public class AuthService implements IAuthService { @Value("${spotify.client.id}") private String clientId; diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java index bc159e220..3d181e14e 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java @@ -17,7 +17,7 @@ public class CustomSpotifyRecommendationService { private String spotifyApiBaseUrl; @Autowired - private AuthService authService; + private IAuthService authService; @Autowired private RestTemplate restTemplate; diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java new file mode 100644 index 000000000..9a9de95e1 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java @@ -0,0 +1,6 @@ +package com.yen.SpotifyPlayList.service; + +public interface IAuthService { + String getAccessToken(); + void setAccessToken(String accessToken); +} \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java index 7f0a42095..725cbfb8d 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java @@ -3,41 +3,45 @@ import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; import org.springframework.http.*; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.RestTemplate; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; -@ExtendWith(MockitoExtension.class) public class CustomSpotifyRecommendationServiceTest { @Mock private RestTemplate restTemplate; @Mock - private AuthService authService; + private IAuthService authService; @InjectMocks private CustomSpotifyRecommendationService recommendationService; private static final String MOCK_ACCESS_TOKEN = "mock-access-token"; private static final String MOCK_RESPONSE = "{\"tracks\": [], \"seeds\": []}"; + private static final String BASE_URL = "https://api.spotify.com/v1"; @BeforeEach void setUp() { - when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(recommendationService, "spotifyApiBaseUrl", BASE_URL); } @Test void getRecommendations_Success() { // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + GetRecommendationsDto dto = new GetRecommendationsDto(); dto.setSeedArtistId("artist123"); dto.setSeedTrack("track123"); @@ -75,6 +79,8 @@ void getRecommendations_Success() { @Test void getRecommendations_WithNullSeeds_Success() { // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + GetRecommendationsDto dto = new GetRecommendationsDto(); // Don't set any seeds - test null handling @@ -110,6 +116,8 @@ void getRecommendations_WhenAuthServiceFails_ThrowsException() { @Test void getRecommendations_WhenSpotifyApiFails_ThrowsException() { // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + GetRecommendationsDto dto = new GetRecommendationsDto(); when(restTemplate.exchange( anyString(), @@ -128,6 +136,8 @@ void getRecommendations_WhenSpotifyApiFails_ThrowsException() { @Test void getRecommendations_VerifyUrlConstruction() { // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + GetRecommendationsDto dto = new GetRecommendationsDto(); dto.setSeedArtistId("artist123"); dto.setSeedTrack("track123"); @@ -136,12 +146,7 @@ void getRecommendations_VerifyUrlConstruction() { ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); when(restTemplate.exchange( - argThat((String url) -> - url.contains("seed_artists=artist123") && - url.contains("seed_tracks=track123") && - url.contains("seed_genres=rock") && - url.contains("limit=5") - ), + anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) @@ -152,13 +157,14 @@ void getRecommendations_VerifyUrlConstruction() { // Verify verify(restTemplate).exchange( - argThat((String url) -> - url.contains("recommendations") && - url.contains("seed_artists=artist123") && - url.contains("seed_tracks=track123") && - url.contains("seed_genres=rock") && - url.contains("limit=5") - ), + argThat(url -> { + String urlStr = url; + return urlStr.contains("/recommendations") && + urlStr.contains("seed_artists=artist123") && + urlStr.contains("seed_tracks=track123") && + urlStr.contains("seed_genres=rock") && + urlStr.contains("limit=5"); + }), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) From baa60ed2c9296beedf6bb181a231b87901793622 Mon Sep 17 00:00:00 2001 From: yennanliu Date: Sat, 5 Jul 2025 18:06:28 +0800 Subject: [PATCH 4/6] fix custom service UT --- ...ustomSpotifyRecommendationServiceTest.java | 75 +++++++++++++------ 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java index 725cbfb8d..c28408648 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java @@ -3,6 +3,7 @@ import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -12,7 +13,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; @@ -49,7 +49,7 @@ void getRecommendations_Success() { ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); when(restTemplate.exchange( - anyString(), + any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) @@ -63,17 +63,26 @@ void getRecommendations_Success() { assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals(MOCK_RESPONSE, response.getBody()); - // Verify HTTP call was made with correct headers + // Verify HTTP call was made with correct URL and headers + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor> entityCaptor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( - anyString(), + urlCaptor.capture(), eq(HttpMethod.GET), - argThat(entity -> { - HttpHeaders headers = entity.getHeaders(); - return headers.getContentType().equals(MediaType.APPLICATION_JSON) && - headers.getFirst(HttpHeaders.AUTHORIZATION).equals("Bearer " + MOCK_ACCESS_TOKEN); - }), + entityCaptor.capture(), eq(String.class) ); + + String capturedUrl = urlCaptor.getValue(); + assertTrue(capturedUrl.startsWith(BASE_URL + "/recommendations")); + assertTrue(capturedUrl.contains("seed_artists=artist123")); + assertTrue(capturedUrl.contains("seed_tracks=track123")); + assertTrue(capturedUrl.contains("seed_genres=rock")); + + HttpHeaders headers = entityCaptor.getValue().getHeaders(); + assertEquals(MediaType.APPLICATION_JSON, headers.getContentType()); + assertEquals("Bearer " + MOCK_ACCESS_TOKEN, headers.getFirst(HttpHeaders.AUTHORIZATION)); } @Test @@ -86,7 +95,7 @@ void getRecommendations_WithNullSeeds_Success() { ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); when(restTemplate.exchange( - anyString(), + any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) @@ -98,6 +107,21 @@ void getRecommendations_WithNullSeeds_Success() { // Assert assertNotNull(response); assertEquals(HttpStatus.OK, response.getStatusCode()); + + // Verify URL doesn't contain seed parameters + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + + String capturedUrl = urlCaptor.getValue(); + assertTrue(capturedUrl.startsWith(BASE_URL + "/recommendations")); + assertFalse(capturedUrl.contains("seed_artists=")); + assertFalse(capturedUrl.contains("seed_tracks=")); + assertFalse(capturedUrl.contains("seed_genres=")); } @Test @@ -111,6 +135,14 @@ void getRecommendations_WhenAuthServiceFails_ThrowsException() { recommendationService.getRecommendations(dto); }); assertTrue(exception.getMessage().contains("Failed to get recommendations")); + + // Verify no HTTP call was made + verify(restTemplate, never()).exchange( + any(String.class), + any(HttpMethod.class), + any(HttpEntity.class), + eq(String.class) + ); } @Test @@ -120,7 +152,7 @@ void getRecommendations_WhenSpotifyApiFails_ThrowsException() { GetRecommendationsDto dto = new GetRecommendationsDto(); when(restTemplate.exchange( - anyString(), + any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) @@ -146,7 +178,7 @@ void getRecommendations_VerifyUrlConstruction() { ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); when(restTemplate.exchange( - anyString(), + any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) @@ -155,19 +187,20 @@ void getRecommendations_VerifyUrlConstruction() { // Act recommendationService.getRecommendations(dto); - // Verify + // Verify URL construction + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); verify(restTemplate).exchange( - argThat(url -> { - String urlStr = url; - return urlStr.contains("/recommendations") && - urlStr.contains("seed_artists=artist123") && - urlStr.contains("seed_tracks=track123") && - urlStr.contains("seed_genres=rock") && - urlStr.contains("limit=5"); - }), + urlCaptor.capture(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class) ); + + String capturedUrl = urlCaptor.getValue(); + assertTrue(capturedUrl.startsWith(BASE_URL + "/recommendations")); + assertTrue(capturedUrl.contains("seed_artists=artist123")); + assertTrue(capturedUrl.contains("seed_tracks=track123")); + assertTrue(capturedUrl.contains("seed_genres=rock")); + assertTrue(capturedUrl.contains("limit=5")); } } \ No newline at end of file From 00e84f688c127715fcc99be6ddaa5825e34af50f Mon Sep 17 00:00:00 2001 From: yennanliu Date: Sat, 5 Jul 2025 18:14:21 +0800 Subject: [PATCH 5/6] add CORS handling, Recommendations url edge case handling --- .../SpotifyPlayList/controller/RecommendationsController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java index da0a61447..2cfbb85c6 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java @@ -11,12 +11,13 @@ @Slf4j @RestController @RequestMapping("/recommend") +@CrossOrigin(origins = "*") // Enable CORS for all origins public class RecommendationsController { @Autowired private CustomSpotifyRecommendationService recommendationsService; - @PostMapping("/") + @PostMapping({"", "/"}) // Handle both /recommend and /recommend/ public ResponseEntity getRecommendation(@RequestBody GetRecommendationsDto getRecommendationsDto) { try { log.info("(getRecommendation) getRecommendationsDto = " + getRecommendationsDto.toString()); From c4171556ee6c6444f0acce288a0aa081f57432c6 Mon Sep 17 00:00:00 2001 From: yennanliu Date: Sat, 5 Jul 2025 18:39:39 +0800 Subject: [PATCH 6/6] BE fix recommend request update IAuthService --- .../CustomSpotifyRecommendationService.java | 149 ++++++++++++++++-- .../SpotifyPlayList/service/IAuthService.java | 4 + .../service/RecommendationsService.java | 3 +- 3 files changed, 141 insertions(+), 15 deletions(-) diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java index 3d181e14e..0272c8e13 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java @@ -6,9 +6,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + @Service @Slf4j public class CustomSpotifyRecommendationService { @@ -16,6 +21,25 @@ public class CustomSpotifyRecommendationService { @Value("${spotify.api.base-url:https://api.spotify.com/v1}") private String spotifyApiBaseUrl; + // List of valid Spotify genres + private static final Set VALID_GENRES = new HashSet<>(Arrays.asList( + "acoustic", "afrobeat", "alt-rock", "alternative", "ambient", "anime", "black-metal", + "bluegrass", "blues", "bossanova", "brazil", "breakbeat", "british", "cantopop", "chicago-house", + "children", "chill", "classical", "club", "comedy", "country", "dance", "dancehall", "death-metal", + "deep-house", "detroit-techno", "disco", "disney", "drum-and-bass", "dub", "dubstep", "edm", + "electro", "electronic", "emo", "folk", "forro", "french", "funk", "garage", "german", "gospel", + "goth", "grindcore", "groove", "grunge", "guitar", "happy", "hard-rock", "hardcore", "hardstyle", + "heavy-metal", "hip-hop", "holidays", "honky-tonk", "house", "idm", "indian", "indie", "indie-pop", + "industrial", "iranian", "j-dance", "j-idol", "j-pop", "j-rock", "jazz", "k-pop", "kids", "latin", + "latino", "malay", "mandopop", "metal", "metal-misc", "metalcore", "minimal-techno", "movies", + "mpb", "new-age", "new-release", "opera", "pagode", "party", "philippines-opm", "piano", "pop", + "pop-film", "post-dubstep", "power-pop", "progressive-house", "psych-rock", "punk", "punk-rock", + "r-n-b", "rainy-day", "reggae", "reggaeton", "road-trip", "rock", "rock-n-roll", "rockabilly", + "romance", "sad", "salsa", "samba", "sertanejo", "show-tunes", "singer-songwriter", "ska", + "sleep", "songwriter", "soul", "soundtracks", "spanish", "study", "summer", "swedish", "synth-pop", + "tango", "techno", "trance", "trip-hop", "turkish", "work-out", "world-music" + )); + @Autowired private IAuthService authService; @@ -24,27 +48,124 @@ public class CustomSpotifyRecommendationService { public ResponseEntity getRecommendations(GetRecommendationsDto request) { try { + // Validate request + validateRequest(request); + + // Ensure we have a valid token + String accessToken = authService.getAccessToken(); + if (accessToken == null || accessToken.isEmpty()) { + log.info("Access token not found, getting new token"); + accessToken = authService.getToken(); + if (accessToken == null || accessToken.isEmpty()) { + throw new RuntimeException("Failed to obtain access token"); + } + } + String url = buildRecommendationUrl(request); HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(authService.getAccessToken()); + headers.setBearerAuth(accessToken); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity entity = new HttpEntity<>(headers); log.info("Making recommendation request to URL: {}", url); - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - entity, - String.class - ); + log.debug("Using access token: {}", accessToken); - log.info("Received recommendation response: {}", response.getStatusCode()); - return response; + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + entity, + String.class + ); + + log.info("Received recommendation response: {} {}", response.getStatusCode(), response.getBody()); + return response; + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + String errorMessage = String.format( + "Invalid seed parameters. Please check that your artist ID (%s), track ID (%s), and genres (%s) are valid.", + request.getSeedArtistId(), + request.getSeedTrack(), + request.getSeedGenres() + ); + throw new IllegalArgumentException(errorMessage); + } + throw e; + } } catch (Exception e) { log.error("Error getting recommendations: {}", e.getMessage()); - throw new RuntimeException("Failed to get recommendations", e); + if (e instanceof IllegalArgumentException) { + throw e; // Rethrow validation errors as-is + } + if (e.getMessage() != null && (e.getMessage().contains("401") || e.getMessage().contains("unauthorized"))) { + // Token might be expired, try to get a new one + try { + log.info("Token might be expired, getting new token"); + String newToken = authService.getToken(); + authService.setAccessToken(newToken); + // Retry the request with new token + return getRecommendations(request); + } catch (Exception retryError) { + log.error("Failed to refresh token and retry: {}", retryError.getMessage()); + throw new RuntimeException("Failed to refresh access token", retryError); + } + } + throw new RuntimeException("Failed to get recommendations: " + e.getMessage(), e); + } + } + + private void validateRequest(GetRecommendationsDto request) { + if (request == null) { + throw new IllegalArgumentException("Request cannot be null"); + } + + boolean hasSeed = false; + + // Validate artist ID + if (request.getSeedArtistId() != null && !request.getSeedArtistId().isEmpty()) { + if (!request.getSeedArtistId().matches("^[0-9A-Za-z]{22}$")) { + throw new IllegalArgumentException( + "Invalid artist ID format. Spotify IDs are 22 characters long and contain only letters and numbers." + ); + } + hasSeed = true; + } + + // Validate track ID + if (request.getSeedTrack() != null && !request.getSeedTrack().isEmpty()) { + if (!request.getSeedTrack().matches("^[0-9A-Za-z]{22}$")) { + throw new IllegalArgumentException( + "Invalid track ID format. Spotify IDs are 22 characters long and contain only letters and numbers." + ); + } + hasSeed = true; + } + + // Validate genres + if (request.getSeedGenres() != null && !request.getSeedGenres().isEmpty()) { + String[] genres = request.getSeedGenres().split(","); + for (String genre : genres) { + String trimmedGenre = genre.trim(); + if (!VALID_GENRES.contains(trimmedGenre)) { + throw new IllegalArgumentException( + "Invalid genre: '" + trimmedGenre + "'. Please use one of the supported Spotify genres." + ); + } + } + hasSeed = true; + } + + if (!hasSeed) { + throw new IllegalArgumentException( + "At least one seed (artist, track, or genre) is required. Please provide at least one valid seed parameter." + ); + } + + // Validate other parameters + if (request.getAmount() < 1 || request.getAmount() > 100) { + throw new IllegalArgumentException("Amount must be between 1 and 100"); } } @@ -52,14 +173,14 @@ private String buildRecommendationUrl(GetRecommendationsDto request) { UriComponentsBuilder builder = UriComponentsBuilder .fromHttpUrl(spotifyApiBaseUrl + "/recommendations"); - // Add seed parameters - if (request.getSeedArtistId() != null) { + // Add seed parameters (already validated) + if (request.getSeedArtistId() != null && !request.getSeedArtistId().isEmpty()) { builder.queryParam("seed_artists", request.getSeedArtistId()); } - if (request.getSeedTrack() != null) { + if (request.getSeedTrack() != null && !request.getSeedTrack().isEmpty()) { builder.queryParam("seed_tracks", request.getSeedTrack()); } - if (request.getSeedGenres() != null) { + if (request.getSeedGenres() != null && !request.getSeedGenres().isEmpty()) { builder.queryParam("seed_genres", request.getSeedGenres()); } diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java index 9a9de95e1..ea2a64a90 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java @@ -1,6 +1,10 @@ package com.yen.SpotifyPlayList.service; +import se.michaelthelin.spotify.SpotifyApi; + public interface IAuthService { String getAccessToken(); void setAccessToken(String accessToken); + SpotifyApi initializeSpotifyApi(); + String getToken(); } \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java index caee4af00..3c51e18a6 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java @@ -44,7 +44,8 @@ public Recommendations getRecommendation(GetRecommendationsDto getRecommendation return recommendations; } catch (IOException | SpotifyWebApiException | ParseException e) { log.error("Error fetching recommendations: {}", e.getMessage()); - throw new SpotifyWebApiException("getRecommendation error: " + e.getMessage()); + e.printStackTrace(); + throw new SpotifyWebApiException("getRecommendation error: "); } }