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
+216 -11

No files matched your search

@@ -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() {