feat(library): backlog status, rating, notes and priority per game

New games start as BACKLOG, or PLAYING when they have playtime. The sync
never changes a status later. PUT /api/library/games/{appid}/backlog sets
all four fields; /api/library/games filters by status and name (q).
This commit is contained in:
lobo committed 2026-09-24 10:51:17 +02:00
1 parent b46e2badbd
commit 0ac93394d9
15 files changed
+438 -11

No files matched your search

@@ -0,0 +1,129 @@
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.domain.UserGameId;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
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 LibraryControllerBacklogTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final long APPID = 220L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(APPID, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
}
private UserGame stored() {
return userGameRepository.findById(new UserGameId(USER_ID, APPID)).orElseThrow();
}
@Test
void updateBacklog_StoresAllFields() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED"))
.andExpect(jsonPath("$.rating").value(4));
UserGame game = stored();
assertEquals(GameStatus.FINISHED, game.getStatus());
assertEquals(4, game.getRating());
assertEquals("Great ending", game.getNotes());
assertEquals("HIGH", game.getPriority().name());
}
@Test
void updateBacklog_NullsClearOptionalFieldsAndBlankNotesAreNull() throws Exception {
UserGame game = stored();
game.setRating(5);
game.setNotes("old");
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"DROPPED\",\"notes\":\" \"}"))
.andExpect(status().isOk());
game = stored();
assertEquals(GameStatus.DROPPED, game.getStatus());
assertNull(game.getRating());
assertNull(game.getNotes());
assertNull(game.getPriority());
}
@ParameterizedTest
@ValueSource(strings = {
"{}",
"{\"status\":\"WISHLIST\"}",
"{\"status\":\"PLAYING\",\"rating\":0}",
"{\"status\":\"PLAYING\",\"rating\":6}",
"{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"})
void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest());
assertEquals(GameStatus.BACKLOG, stored().getStatus());
}
@Test
void updateBacklog_NotesTooLong_Is400() throws Exception {
String notes = "x".repeat(2001);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
void updateBacklog_RemovedGame_Is404() throws Exception {
UserGame game = stored();
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void updateBacklog_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/backlog").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}
}
@@ -0,0 +1,92 @@
package core_service.modules.library.repo;
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.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class UserGameRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final PageRequest PAGE = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "playtimeMinutes"));
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@BeforeEach
void seed() {
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(380L, "Half-Life 2: Episode One", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(700L, "100% Orange Juice", null, Instant.now())));
userGameRepository.saveAll(List.of(
new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()),
new UserGame(USER_ID, 380L, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now()),
new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now()),
new UserGame(2L, 220L, 50, Instant.now(), Instant.now())));
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), PAGE)
.map(UserGame::getAppid).getContent();
}
@Test
void findOwnedGames_NoFilter_ReturnsOwnActiveLibrarySorted() {
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
removed.setRemovedAt(Instant.now());
userGameRepository.flush();
assertEquals(List.of(620L, 220L, 380L), appids(null, null));
}
@Test
void findOwnedGames_ByStatus() {
assertEquals(List.of(620L, 220L), appids(GameStatus.PLAYING, null));
assertEquals(List.of(), appids(GameStatus.FINISHED, null));
}
@Test
void findOwnedGames_ByName_IgnoresCase() {
assertEquals(List.of(220L, 380L), appids(null, "half-LIFE"));
assertEquals(List.of(380L), appids(GameStatus.BACKLOG, "half-life"));
}
@Test
void findOwnedGames_ByName_TreatsWildcardsAsText() {
assertEquals(List.of(700L), appids(null, "100%"));
assertEquals(List.of(), appids(null, "_"));
}
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"),
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
assertEquals(2, page.getTotalElements());
}
@Test
void containsPattern_BlankIsNoFilter() {
assertNull(UserGameRepository.containsPattern(" "));
assertNull(UserGameRepository.containsPattern(null));
}
}
@@ -1,5 +1,8 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -8,12 +11,17 @@ import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesRespon
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
@@ -89,4 +97,33 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
}
@ParameterizedTest
@CsvSource({"0, BACKLOG", "1, PLAYING"})
void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true)));
worker.runSync(USER_ID, STEAM_ID);
ArgumentCaptor<UserGame> saved = ArgumentCaptor.forClass(UserGame.class);
verify(userGameRepository).save(saved.capture());
assertEquals(expected, saved.getValue().getStatus());
}
@Test
void runSync_ExistingGame_KeepsStatus() {
UserGame existing = new UserGame(USER_ID, 220L, 0, Instant.EPOCH, Instant.EPOCH);
existing.setStatus(GameStatus.DROPPED);
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 90, "icon", true)));
worker.runSync(USER_ID, STEAM_ID);
assertEquals(GameStatus.DROPPED, existing.getStatus());
assertEquals(90, existing.getPlaytimeMinutes());
}
}