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=<appid> makes a new pick differ from
the current one.
This commit is contained in:
lobo committed 2026-09-24 11:22:29 +02:00
1 parent 474a5dd570
commit 0482fb0c88
7 files changed
+210 -5

No files matched your search

@@ -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<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Long exclude) {
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
}
@GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong())
@@ -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<GameStatus, Long> byStatus,
long neverPlayed,
GameDto suggestion
) {}
@@ -0,0 +1,3 @@
package core_service.modules.library.domain;
public record StatusCount(GameStatus status, long count) {}
@@ -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<UserGame, UserGameId> {
@@ -20,6 +22,19 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
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<StatusCount> 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<UserGame> 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
@@ -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<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return page.map(ug -> {
SteamGame g = meta.get(ug.getAppid());
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<GameStatus, Long> 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(),
g != null ? g.getName() : null,
g != null ? g.getImgIconUrl() : null,
meta != null ? meta.getName() : null,
meta != null ? meta.getImgIconUrl() : null,
ug.getPlaytimeMinutes(),
ug.getStatus(),
ug.getRating(),
ug.getPriority());
});
}
public Optional<UserSteamSync> getSyncStatus(Long userId) {
@@ -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()));
}
}
@@ -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() {