From 0482fb0c88c9d526b1472848fb82d1e69553abbd Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 11:22:29 +0200 Subject: [PATCH] feat(library): summary endpoint with status counts and a backlog pick GET /api/library/summary returns the game count per status, the never-started count and a random backlog game. exclude= makes a new pick differ from the current one. --- .../library/api/LibraryController.java | 7 ++ .../library/api/dto/LibrarySummaryDto.java | 16 +++ .../modules/library/domain/StatusCount.java | 3 + .../library/repo/UserGameRepository.java | 15 +++ .../library/service/LibraryQueryService.java | 42 ++++++-- .../api/LibraryControllerSummaryTest.java | 100 ++++++++++++++++++ .../library/repo/UserGameRepositoryTest.java | 44 ++++++++ 7 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java create mode 100644 src/main/java/core_service/modules/library/domain/StatusCount.java create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 1a198a9..ef204e5 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -2,6 +2,7 @@ package core_service.modules.library.api; import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.GameDto; +import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.PageDto; import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.domain.GameStatus; @@ -133,6 +134,12 @@ public class LibraryController { .body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); } + @GetMapping("/summary") + public ResponseEntity getSummary(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Long exclude) { + return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude)); + } + @GetMapping("/sync-status") public ResponseEntity getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { return queryService.getSyncStatus(principal.getIdAsLong()) diff --git a/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java b/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java new file mode 100644 index 0000000..a0322f0 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java @@ -0,0 +1,16 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.GameStatus; + +import java.util.Map; + +/** + * @param byStatus has every status, also those with 0 games + * @param suggestion a random backlog game, null when the backlog is empty + */ +public record LibrarySummaryDto( + long total, + Map byStatus, + long neverPlayed, + GameDto suggestion +) {} diff --git a/src/main/java/core_service/modules/library/domain/StatusCount.java b/src/main/java/core_service/modules/library/domain/StatusCount.java new file mode 100644 index 0000000..3de490f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/StatusCount.java @@ -0,0 +1,3 @@ +package core_service.modules.library.domain; + +public record StatusCount(GameStatus status, long count) {} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 28b7f73..e389074 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -1,6 +1,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import org.springframework.data.domain.Page; @@ -13,6 +14,7 @@ import org.springframework.data.repository.query.Param; import java.time.Instant; import java.util.List; import java.util.Locale; +import java.util.Optional; public interface UserGameRepository extends JpaRepository { @@ -20,6 +22,19 @@ public interface UserGameRepository extends JpaRepository long countByUserIdAndRemovedAtIsNull(Long userId); + long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes); + + @Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " + + "where ug.userId = :userId and ug.removedAt is null group by ug.status") + List countByStatus(@Param("userId") Long userId); + + /** + * @param excludeAppid never picked; -1 excludes nothing + */ + @Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " + + "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) + Optional findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); + /** * @param status null for every status * @param namePattern a {@link #containsPattern} result, null for every name diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 73d28c3..0ddb2b7 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -1,6 +1,7 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.GameDto; +import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; @@ -14,6 +15,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -38,17 +40,35 @@ public class LibraryQueryService { List appids = page.getContent().stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); - return page.map(ug -> { - SteamGame g = meta.get(ug.getAppid()); - return new GameDto( - ug.getAppid(), - g != null ? g.getName() : null, - g != null ? g.getImgIconUrl() : null, - ug.getPlaytimeMinutes(), - ug.getStatus(), - ug.getRating(), - ug.getPriority()); - }); + return page.map(ug -> toDto(ug, meta.get(ug.getAppid()))); + } + + /** + * @param excludeAppid the current suggestion, so a new pick differs from it; null for none + */ + public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) { + Map byStatus = new EnumMap<>(GameStatus.class); + for (GameStatus status : GameStatus.values()) { + byStatus.put(status, 0L); + } + userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count())); + long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); + long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); + GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) + .map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null))) + .orElse(null); + return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion); + } + + private static GameDto toDto(UserGame ug, SteamGame meta) { + return new GameDto( + ug.getAppid(), + meta != null ? meta.getName() : null, + meta != null ? meta.getImgIconUrl() : null, + ug.getPlaytimeMinutes(), + ug.getStatus(), + ug.getRating(), + ug.getPriority()); } public Optional getSyncStatus(Long userId) { diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java new file mode 100644 index 0000000..234814e --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java @@ -0,0 +1,100 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; + +import static org.hamcrest.Matchers.nullValue; +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; + +/** + * The dev profile authenticates every request as user 1. + */ +@AutoConfigureMockMvc +class LibraryControllerSummaryTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @BeforeEach + void clean() { + userGameRepository.deleteAll(); + } + + @Test + void summary_EmptyLibrary_HasAllStatusesWithZeroAndNoSuggestion() throws Exception { + mockMvc.perform(get("/api/library/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(0)) + .andExpect(jsonPath("$.neverPlayed").value(0)) + .andExpect(jsonPath("$.byStatus.BACKLOG").value(0)) + .andExpect(jsonPath("$.byStatus.DROPPED").value(0)) + .andExpect(jsonPath("$.suggestion").value(nullValue())); + } + + @Test + void summary_CountsStatusesAndSuggestsABacklogGame() throws Exception { + steamGameRepository.saveAll(List.of( + new SteamGame(220L, "Half-Life 2", "icon", Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()))); + UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now()); + finished.setStatus(GameStatus.FINISHED); + userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished)); + + mockMvc.perform(get("/api/library/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(2)) + .andExpect(jsonPath("$.neverPlayed").value(1)) + .andExpect(jsonPath("$.byStatus.BACKLOG").value(1)) + .andExpect(jsonPath("$.byStatus.FINISHED").value(1)) + .andExpect(jsonPath("$.byStatus.PLAYING").value(0)) + .andExpect(jsonPath("$.suggestion.appid").value(220)) + .andExpect(jsonPath("$.suggestion.name").value("Half-Life 2")) + .andExpect(jsonPath("$.suggestion.status").value("BACKLOG")); + } + + @Test + void summary_Exclude_SuggestsAnotherBacklogGame() throws Exception { + steamGameRepository.saveAll(List.of( + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()))); + userGameRepository.saveAll(List.of( + new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), + new UserGame(USER_ID, 620L, 0, Instant.now(), Instant.now()))); + + for (int i = 0; i < 5; i++) { + mockMvc.perform(get("/api/library/summary").param("exclude", "220")) + .andExpect(jsonPath("$.suggestion.appid").value(620)); + } + } + + @Test + void summary_ExcludeTheOnlyBacklogGame_HasNoSuggestion() throws Exception { + steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now())); + + mockMvc.perform(get("/api/library/summary").param("exclude", "220")) + .andExpect(jsonPath("$.suggestion").value(nullValue())); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 08fa239..4884dcf 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -1,6 +1,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; @@ -18,9 +19,11 @@ import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class UserGameRepositoryTest extends AbstractPersistenceTest { @@ -98,6 +101,47 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertNull(UserGameRepository.containsPattern(null)); } + @Test + void countByStatus_CountsOnlyOwnActiveGames() { + UserGame dropped = userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow(); + dropped.setStatus(GameStatus.DROPPED); + UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); + removed.setRemovedAt(Instant.now()); + userGameRepository.flush(); + + assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.DROPPED, 1)), + Set.copyOf(userGameRepository.countByStatus(USER_ID))); + } + + @Test + void countNeverPlayed_CountsOnlyOwnActiveGamesWithoutPlaytime() { + UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); + removed.setRemovedAt(Instant.now()); + userGameRepository.save(new UserGame(2L, 380L, 0, Instant.now(), Instant.now())); + userGameRepository.flush(); + + assertEquals(1, userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(USER_ID, 0)); + } + + @Test + void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() { + UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); + removed.setRemovedAt(Instant.now()); + userGameRepository.flush(); + + for (int i = 0; i < 5; i++) { + assertEquals(380L, userGameRepository.findRandomBacklogGame(USER_ID, -1).orElseThrow().getAppid()); + } + assertTrue(userGameRepository.findRandomBacklogGame(99L, -1).isEmpty()); + } + + @Test + void findRandomBacklogGame_SkipsTheExcludedGame() { + for (int i = 0; i < 5; i++) { + assertEquals(700L, userGameRepository.findRandomBacklogGame(USER_ID, 380L).orElseThrow().getAppid()); + } + } + @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) void syncFlushKeepsBacklogEditCommittedMeanwhile() {