Public Access
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:
1 parent
474a5dd570
commit
0482fb0c88
7 files changed
+216
-11
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.BacklogEntryDto;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
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.PageDto;
|
||||||
import core_service.modules.library.api.dto.SyncStatusDto;
|
import core_service.modules.library.api.dto.SyncStatusDto;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
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")));
|
.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")
|
@GetMapping("/sync-status")
|
||||||
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
return queryService.getSyncStatus(principal.getIdAsLong())
|
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;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.GameStatus;
|
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.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -13,6 +14,7 @@ import org.springframework.data.repository.query.Param;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
|
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
|
||||||
|
|
||||||
@@ -20,6 +22,19 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
|
|
||||||
long countByUserIdAndRemovedAtIsNull(Long userId);
|
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 status null for every status
|
||||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
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.GameStatus;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -38,17 +40,35 @@ public class LibraryQueryService {
|
|||||||
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
|
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
|
||||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
|
||||||
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
return page.map(ug -> {
|
return page.map(ug -> toDto(ug, meta.get(ug.getAppid())));
|
||||||
SteamGame g = meta.get(ug.getAppid());
|
}
|
||||||
return new GameDto(
|
|
||||||
ug.getAppid(),
|
/**
|
||||||
g != null ? g.getName() : null,
|
* @param excludeAppid the current suggestion, so a new pick differs from it; null for none
|
||||||
g != null ? g.getImgIconUrl() : null,
|
*/
|
||||||
ug.getPlaytimeMinutes(),
|
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
|
||||||
ug.getStatus(),
|
Map<GameStatus, Long> byStatus = new EnumMap<>(GameStatus.class);
|
||||||
ug.getRating(),
|
for (GameStatus status : GameStatus.values()) {
|
||||||
ug.getPriority());
|
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<UserSteamSync> getSyncStatus(Long userId) {
|
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;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.GameStatus;
|
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.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
@@ -18,9 +19,11 @@ import org.springframework.transaction.support.TransactionTemplate;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class UserGameRepositoryTest extends AbstractPersistenceTest {
|
class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
@@ -98,6 +101,47 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertNull(UserGameRepository.containsPattern(null));
|
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
|
@Test
|
||||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
void syncFlushKeepsBacklogEditCommittedMeanwhile() {
|
void syncFlushKeepsBacklogEditCommittedMeanwhile() {
|
||||||
|
|||||||
Reference in new issue
Block a user