diff --git a/postify/pom.xml b/postify/pom.xml index 7cea5f7..b013601 100644 --- a/postify/pom.xml +++ b/postify/pom.xml @@ -45,11 +45,21 @@ lombok true + + + org.springframework.boot + spring-boot-restclient + org.springframework.boot spring-boot-starter-webmvc-test test + + + org.springframework.boot + spring-boot-starter-jdbc + diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/DatabaseConnectionCheck.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/DatabaseConnectionCheck.java new file mode 100644 index 0000000..ab13703 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/DatabaseConnectionCheck.java @@ -0,0 +1,21 @@ +package net.hackyourfuture.backend.week6.postify; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +public class DatabaseConnectionCheck { + private final JdbcTemplate jdbcTemplate; + + public DatabaseConnectionCheck(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void checkConnection() { + String result = this.jdbcTemplate.queryForObject("SELECT 'connected'", String.class); + System.out.println("Database status: " + result); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiClient.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiClient.java new file mode 100644 index 0000000..cbf3954 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiClient.java @@ -0,0 +1,40 @@ +package net.hackyourfuture.backend.week6.postify.client; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +/** + * Wraps the external lyrics.ovh API call. + * Returns Optional.empty() when the API has no lyrics for the song. + */ +@Component +public class LyricsApiClient { + private final RestClient lyricsRestClient; + + public LyricsApiClient(RestClient lyricsRestClient) { + this.lyricsRestClient = lyricsRestClient; + } + + public Optional fetchLyrics(String artistName, String trackTitle) { + try { + LyricsResponse response = lyricsRestClient.get() + .uri("/{artist}/{title}", artistName, trackTitle) + .retrieve() + .body(LyricsResponse.class); + + if (response == null || response.lyrics() == null || response.lyrics().isBlank()) { + return Optional.empty(); + } + return Optional.of(response.lyrics()); + } catch (HttpClientErrorException.NotFound e) { + return Optional.empty(); + } + } + + // Shape of the lyrics.ovh response: {"lyrics": "..."} + private record LyricsResponse(String lyrics) { + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/RestClientConfig.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/RestClientConfig.java new file mode 100644 index 0000000..a1f8bc1 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/RestClientConfig.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.backend.week6.postify.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient lyricsRestClient(RestClient.Builder builder, + @Value("${lyrics.api.base-url}") String baseUrl) { + return builder + .baseUrl(baseUrl) + .build(); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackLyricsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackLyricsController.java new file mode 100644 index 0000000..d102258 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackLyricsController.java @@ -0,0 +1,23 @@ +package net.hackyourfuture.backend.week6.postify.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import net.hackyourfuture.backend.week6.postify.dto.TrackLyrics; +import net.hackyourfuture.backend.week6.postify.service.TrackLyricsService; + +@RestController +public class TrackLyricsController { + private final TrackLyricsService service; + + public TrackLyricsController(TrackLyricsService service) { + this.service = service; + } + + @GetMapping("/tracks/{id}/lyrics") + public TrackLyrics getLyrics(@PathVariable Long id) { + return service.getLyrics(id); + } + +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserStatisticsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserStatisticsController.java new file mode 100644 index 0000000..74b159d --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserStatisticsController.java @@ -0,0 +1,22 @@ +package net.hackyourfuture.backend.week6.postify.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import net.hackyourfuture.backend.week6.postify.dto.UserStatistics; +import net.hackyourfuture.backend.week6.postify.service.UserStatisticsService; + +@RestController +public class UserStatisticsController { + private final UserStatisticsService service; + + public UserStatisticsController(UserStatisticsService service){ + this.service = service; + } + @GetMapping("/users/{id}/stats") + public UserStatistics getStats(@PathVariable Long id) { + return service.getStats(id); + } + +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyrics.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyrics.java new file mode 100644 index 0000000..f208a4b --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyrics.java @@ -0,0 +1,13 @@ +package net.hackyourfuture.backend.week6.postify.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class TrackLyrics { + private long trackId; + private String trackTitle; + private String artistName; + private String lyrics; +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatistics.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatistics.java new file mode 100644 index 0000000..a15d857 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatistics.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.backend.week6.postify.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UserStatistics { + private long userId; + private String userName; + private String userCountry; + private long totalStreams; + private long uniqueTracksStreamed; + private long uniqueArtistsStreamed; + private String favoriteGenre; + private long totalListeningTimeSeconds; + +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/LyricsNotFoundException.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/LyricsNotFoundException.java new file mode 100644 index 0000000..009c2ba --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/LyricsNotFoundException.java @@ -0,0 +1,11 @@ +package net.hackyourfuture.backend.week6.postify.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class LyricsNotFoundException extends RuntimeException { + public LyricsNotFoundException(String artistName, String trackTitle) { + super("No lyrics found for \"" + trackTitle + "\" by " + artistName + "."); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/TrackNotFoundException.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/TrackNotFoundException.java new file mode 100644 index 0000000..e992901 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/TrackNotFoundException.java @@ -0,0 +1,11 @@ +package net.hackyourfuture.backend.week6.postify.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class TrackNotFoundException extends RuntimeException { + public TrackNotFoundException(Long id) { + super("Track not found: " + id); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/UserNotFoundException.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/UserNotFoundException.java new file mode 100644 index 0000000..8a8237c --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/UserNotFoundException.java @@ -0,0 +1,11 @@ +package net.hackyourfuture.backend.week6.postify.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(Long id) { + super("User not found: " + id); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/StreamRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/StreamRepository.java new file mode 100644 index 0000000..6bd946f --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/StreamRepository.java @@ -0,0 +1,63 @@ +package net.hackyourfuture.backend.week6.postify.repository; + +import java.util.Optional; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import net.hackyourfuture.backend.week6.postify.dto.UserStatistics; + +@Repository +public class StreamRepository { + private final JdbcTemplate jdbcTemplate; + + public StreamRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Optional findStats(Long userId) { + String sql = """ + SELECT users.user_id, users.user_name, users.user_country, + COUNT(streams.stream_id) AS total_streams, + COUNT(DISTINCT streams.track_id) AS unique_tracks, + COUNT(DISTINCT albums.artist_id) AS unique_artists, + COALESCE(SUM(tracks.track_duration_s), 0) AS total_listening_seconds + FROM users + LEFT JOIN streams ON streams.user_id = users.user_id + LEFT JOIN tracks ON tracks.track_id = streams.track_id + LEFT JOIN albums ON albums.album_id = tracks.album_id + WHERE users.user_id = ? + GROUP BY users.user_id, users.user_name, users.user_country + """; + + return jdbcTemplate.query(sql, (rs, rowNum) -> UserStatistics.builder() + .userId(rs.getLong("user_id")) + .userName(rs.getString("user_name")) + .userCountry(rs.getString("user_country")) + .totalStreams(rs.getLong("total_streams")) + .uniqueTracksStreamed(rs.getLong("unique_tracks")) + .uniqueArtistsStreamed(rs.getLong("unique_artists")) + .totalListeningTimeSeconds(rs.getLong("total_listening_seconds")) + .build(), + userId) + .stream() + .findFirst(); + } + + + public Optional findFavoriteGenre(Long userId) { + String sql = """ + SELECT tracks.genre + FROM streams + JOIN tracks ON tracks.track_id = streams.track_id + WHERE streams.user_id = ? AND tracks.genre IS NOT NULL + GROUP BY tracks.genre + ORDER BY COUNT(*) DESC + LIMIT 1 + """; + + return jdbcTemplate.query(sql, (rs, rowNum) -> rs.getString("genre"), userId) + .stream() + .findFirst(); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java new file mode 100644 index 0000000..e2d03f1 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java @@ -0,0 +1,37 @@ +package net.hackyourfuture.backend.week6.postify.repository; + +import java.util.Optional; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import net.hackyourfuture.backend.week6.postify.dto.TrackLyrics; + +@Repository +public class TrackRepository { + private final JdbcTemplate jdbcTemplate; + + public TrackRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + + public Optional findTrackWithArtist(Long trackId) { + String sql = """ + SELECT tracks.track_id, tracks.track_title, artists.artist_name + FROM tracks + JOIN albums ON albums.album_id = tracks.album_id + JOIN artists ON artists.artist_id = albums.artist_id + WHERE tracks.track_id = ? + """; + + return jdbcTemplate.query(sql, (rs, rowNum) -> TrackLyrics.builder() + .trackId(rs.getLong("track_id")) + .trackTitle(rs.getString("track_title")) + .artistName(rs.getString("artist_name")) + .build(), + trackId) + .stream() + .findFirst(); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackLyricsService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackLyricsService.java new file mode 100644 index 0000000..b1b061e --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackLyricsService.java @@ -0,0 +1,31 @@ +package net.hackyourfuture.backend.week6.postify.service; + +import org.springframework.stereotype.Service; + +import net.hackyourfuture.backend.week6.postify.client.LyricsApiClient; +import net.hackyourfuture.backend.week6.postify.dto.TrackLyrics; +import net.hackyourfuture.backend.week6.postify.exception.LyricsNotFoundException; +import net.hackyourfuture.backend.week6.postify.exception.TrackNotFoundException; +import net.hackyourfuture.backend.week6.postify.repository.TrackRepository; + +@Service +public class TrackLyricsService { + private final TrackRepository trackRepository; + private final LyricsApiClient lyricsApiClient; + + public TrackLyricsService(TrackRepository trackRepository, LyricsApiClient lyricsApiClient) { + this.trackRepository = trackRepository; + this.lyricsApiClient = lyricsApiClient; + } + + public TrackLyrics getLyrics(Long trackId) { + TrackLyrics track = trackRepository.findTrackWithArtist(trackId) + .orElseThrow(() -> new TrackNotFoundException(trackId)); + + String lyrics = lyricsApiClient.fetchLyrics(track.getArtistName(), track.getTrackTitle()) + .orElseThrow(() -> new LyricsNotFoundException(track.getArtistName(), track.getTrackTitle())); + + track.setLyrics(lyrics); + return track; + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/UserStatisticsService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/UserStatisticsService.java new file mode 100644 index 0000000..480ab66 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/UserStatisticsService.java @@ -0,0 +1,27 @@ +package net.hackyourfuture.backend.week6.postify.service; + +import org.springframework.stereotype.Service; + +import net.hackyourfuture.backend.week6.postify.dto.UserStatistics; +import net.hackyourfuture.backend.week6.postify.exception.UserNotFoundException; +import net.hackyourfuture.backend.week6.postify.repository.StreamRepository; + +@Service +public class UserStatisticsService { + private final StreamRepository streamRepository; + + public UserStatisticsService(StreamRepository streamRepository) { + this.streamRepository = streamRepository; + } + + public UserStatistics getStats(Long id) { + // Fetch the aggregate row. Empty means the user id does not exist -> 404. + UserStatistics stats = streamRepository.findStats(id) + .orElseThrow(() -> new UserNotFoundException(id)); + + // Favorite genre is a separate query; absent when the user has no streams. + streamRepository.findFavoriteGenre(id).ifPresent(stats::setFavoriteGenre); + + return stats; + } +} diff --git a/postify/src/main/resources/application.properties b/postify/src/main/resources/application.properties index 8f4842f..1cf6dfa 100644 --- a/postify/src/main/resources/application.properties +++ b/postify/src/main/resources/application.properties @@ -1 +1,7 @@ -spring.application.name=postify +spring.datasource.url=jdbc:postgresql://localhost:5432/POSTIFY +spring.datasource.username=hyfuser +spring.datasource.password=hyfpassword +spring.datasource.driver-class-name=org.postgresql.Driver + +# External lyrics API (https://lyricsovh.docs.apiary.io) +lyrics.api.base-url=https://api.lyrics.ovh/v1 diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/TrackLyricsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/TrackLyricsControllerTest.java new file mode 100644 index 0000000..706d12f --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/TrackLyricsControllerTest.java @@ -0,0 +1,76 @@ +package net.hackyourfuture.backend.week6.postify; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +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; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import net.hackyourfuture.backend.week6.postify.client.LyricsApiClient; +import net.hackyourfuture.backend.week6.postify.exception.LyricsNotFoundException; +import net.hackyourfuture.backend.week6.postify.exception.TrackNotFoundException; + +/** + * Integration tests for GET /tracks/{id}/lyrics. + * Requires the POSTIFY Postgres database (Docker) with seed data; + * the external lyrics API client is replaced with a Mockito mock, + * so no internet connection is needed. + */ +@SpringBootTest +@AutoConfigureMockMvc +class TrackLyricsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LyricsApiClient lyricsApiClient; + + // Seed data: track 41 = 'LUNCH' by Billie Eilish + + @Test + void returnsLyricsForTrackThatHasLyrics() throws Exception { + when(lyricsApiClient.fetchLyrics("Billie Eilish", "LUNCH")) + .thenReturn(Optional.of("Mocked lyrics line one")); + + mockMvc.perform(get("/tracks/41/lyrics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.trackId").value(41)) + .andExpect(jsonPath("$.trackTitle").value("LUNCH")) + .andExpect(jsonPath("$.artistName").value("Billie Eilish")) + .andExpect(jsonPath("$.lyrics").value("Mocked lyrics line one")); + } + + @Test + void returns404ForUnknownTrack() throws Exception { + mockMvc.perform(get("/tracks/999999/lyrics")) + .andExpect(status().isNotFound()) + .andExpect(result -> assertThat(result.getResolvedException()) + .isInstanceOf(TrackNotFoundException.class)); + + // The service must not call the API for a track that is not in the database. + verifyNoInteractions(lyricsApiClient); + } + + @Test + void returns404WithMessageWhenApiHasNoLyrics() throws Exception { + when(lyricsApiClient.fetchLyrics("Billie Eilish", "LUNCH")) + .thenReturn(Optional.empty()); + + mockMvc.perform(get("/tracks/41/lyrics")) + .andExpect(status().isNotFound()) + .andExpect(result -> assertThat(result.getResolvedException()) + .isInstanceOf(LyricsNotFoundException.class) + .hasMessageContaining("No lyrics found for \"LUNCH\" by Billie Eilish")); + } +} diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/UserStatisticsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/UserStatisticsControllerTest.java new file mode 100644 index 0000000..8be41d4 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/UserStatisticsControllerTest.java @@ -0,0 +1,39 @@ +package net.hackyourfuture.backend.week6.postify; + +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; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration tests for GET /users/{id}/stats. + * Requires the POSTIFY Postgres database (Docker) with seed data. + */ +@SpringBootTest +@AutoConfigureMockMvc +class UserStatisticsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void returnsStatsForExistingUser() throws Exception { + mockMvc.perform(get("/users/1/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(1)) + .andExpect(jsonPath("$.userName").value("lena_v")) + .andExpect(jsonPath("$.userCountry").value("NL")) + .andExpect(jsonPath("$.favoriteGenre").value("Nederpop")); + } + + @Test + void returns404ForUnknownUser() throws Exception { + mockMvc.perform(get("/users/999999/stats")) + .andExpect(status().isNotFound()); + } +}