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 22bc036c9c
commit c815a29993
15 files changed
+438 -11

No files matched your search

@@ -1,13 +1,17 @@
package core_service.modules.library.api; 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.GameDto;
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.service.BacklogService;
import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService; import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.SyncCooldownException;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -18,6 +22,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -37,6 +43,7 @@ public class LibraryController {
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
private final LibraryQueryService queryService; private final LibraryQueryService queryService;
private final GameDetailService gameDetailService; private final GameDetailService gameDetailService;
private final BacklogService backlogService;
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -55,7 +62,9 @@ public class LibraryController {
@AuthenticationPrincipal UserPrincipal principal, @AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort @RequestParam(defaultValue = "playtimeMinutes,desc") String sort,
@RequestParam(required = false) GameStatus status,
@RequestParam(required = false) String q
) { ) {
if (principal.steamId() == null) { if (principal.steamId() == null) {
return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0)); return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0));
@@ -67,7 +76,7 @@ public class LibraryController {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page); int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort); Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, PageRequest.of(safePage, safeSize, safeSort)); var dtoPage = queryService.listOwnedGames(userId, status, q, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage)); return ResponseEntity.ok(PageDto.of(dtoPage));
} }
@@ -114,6 +123,16 @@ 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")));
} }
@PutMapping("/games/{appid}/backlog")
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody BacklogEntryDto entry) {
return backlogService.update(principal.getIdAsLong(), appid, entry)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
@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,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Request and response of the backlog update. The request replaces all four fields; null clears an optional one.
*/
public record BacklogEntryDto(
@NotNull GameStatus status,
@Min(1) @Max(5) Integer rating,
@Size(max = 2000) String notes,
GamePriority priority
) {}
@@ -1,5 +1,8 @@
package core_service.modules.library.api.dto; package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -10,6 +13,10 @@ public record GameDetailDto(
int playtimeMinutes, int playtimeMinutes,
Instant firstSeenAt, Instant firstSeenAt,
Instant lastSeenAt, Instant lastSeenAt,
GameStatus status,
Integer rating,
String notes,
GamePriority priority,
boolean hasAchievements, boolean hasAchievements,
boolean statsVisible, boolean statsVisible,
int achievementsTotal, int achievementsTotal,
@@ -1,8 +1,14 @@
package core_service.modules.library.api.dto; package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
public record GameDto( public record GameDto(
Long appid, Long appid,
String name, String name,
String imgIconUrl, String imgIconUrl,
int playtimeMinutes int playtimeMinutes,
GameStatus status,
Integer rating,
GamePriority priority
) {} ) {}
@@ -0,0 +1,7 @@
package core_service.modules.library.domain;
public enum GamePriority {
LOW,
MEDIUM,
HIGH
}
@@ -0,0 +1,16 @@
package core_service.modules.library.domain;
public enum GameStatus {
BACKLOG,
PLAYING,
FINISHED,
COMPLETED,
DROPPED;
/**
* Status for a game the sync sees for the first time. Later syncs never change it.
*/
public static GameStatus initialFor(int playtimeMinutes) {
return playtimeMinutes > 0 ? PLAYING : BACKLOG;
}
}
@@ -2,6 +2,8 @@ package core_service.modules.library.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.IdClass; import jakarta.persistence.IdClass;
import jakarta.persistence.Table; import jakarta.persistence.Table;
@@ -40,11 +42,26 @@ public class UserGame {
@Column(name = "removed_at") @Column(name = "removed_at")
private Instant removedAt; private Instant removedAt;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private GameStatus status;
@Column(name = "rating")
private Integer rating;
@Column(name = "notes", length = 2000)
private String notes;
@Enumerated(EnumType.STRING)
@Column(name = "priority", length = 8)
private GamePriority priority;
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) { public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
this.userId = userId; this.userId = userId;
this.appid = appid; this.appid = appid;
this.playtimeMinutes = playtimeMinutes; this.playtimeMinutes = playtimeMinutes;
this.firstSeenAt = firstSeenAt; this.firstSeenAt = firstSeenAt;
this.lastSeenAt = lastSeenAt; this.lastSeenAt = lastSeenAt;
this.status = GameStatus.initialFor(playtimeMinutes);
} }
} }
@@ -1,5 +1,6 @@
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.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;
@@ -11,15 +12,37 @@ 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;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> { public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId); List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
Page<UserGame> findByUserIdAndRemovedAtIsNull(Long userId, Pageable pageable);
long countByUserIdAndRemovedAtIsNull(Long userId); long countByUserIdAndRemovedAtIsNull(Long userId);
/**
* @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name
*/
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and (:status is null or ug.status = :status) " +
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\')")
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("status") GameStatus status,
@Param("namePattern") String namePattern,
Pageable pageable);
/**
* Case-insensitive "contains" pattern for {@link #findOwnedGames}; % and _ in the query match literally.
*/
static String containsPattern(String query) {
if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT)
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
}
@Modifying @Modifying
@Query("update UserGame ug set ug.removedAt = :now " + @Query("update UserGame ug set ug.removedAt = :now " +
"where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null") "where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null")
@@ -0,0 +1,38 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class BacklogService {
private final UserGameRepository userGameRepository;
/**
* @return empty when the game is not in the user's active library
*/
@Transactional
public Optional<BacklogEntryDto> update(Long userId, Long appid, BacklogEntryDto entry) {
return userGameRepository.findById(new UserGameId(userId, appid))
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(entry.status());
game.setRating(entry.rating());
game.setNotes(entry.notes() == null || entry.notes().isBlank() ? null : entry.notes());
game.setPriority(entry.priority());
return toDto(game);
});
}
private static BacklogEntryDto toDto(UserGame game) {
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
}
}
@@ -110,6 +110,10 @@ public class GameDetailService {
userGame.getPlaytimeMinutes(), userGame.getPlaytimeMinutes(),
userGame.getFirstSeenAt(), userGame.getFirstSeenAt(),
userGame.getLastSeenAt(), userGame.getLastSeenAt(),
userGame.getStatus(),
userGame.getRating(),
userGame.getNotes(),
userGame.getPriority(),
hasAchievements, hasAchievements,
statsVisible, statsVisible,
total, total,
@@ -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.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;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
@@ -31,11 +32,9 @@ public class LibraryQueryService {
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
} }
public Page<GameDto> listOwnedGames(Long userId, Pageable pageable) { public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) {
Page<UserGame> page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable); Page<UserGame> page = userGameRepository.findOwnedGames(
if (page.isEmpty()) { userId, status, UserGameRepository.containsPattern(nameQuery), pageable);
return page.map(ug -> new GameDto(ug.getAppid(), null, null, ug.getPlaytimeMinutes()));
}
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));
@@ -45,7 +44,10 @@ public class LibraryQueryService {
ug.getAppid(), ug.getAppid(),
g != null ? g.getName() : null, g != null ? g.getName() : null,
g != null ? g.getImgIconUrl() : null, g != null ? g.getImgIconUrl() : null,
ug.getPlaytimeMinutes()); ug.getPlaytimeMinutes(),
ug.getStatus(),
ug.getRating(),
ug.getPriority());
}); });
} }
@@ -0,0 +1,12 @@
-- Per-user backlog tracking. The sync sets status only when it inserts a game.
ALTER TABLE user_game
ADD COLUMN status VARCHAR(16),
ADD COLUMN rating INTEGER CHECK (rating BETWEEN 1 AND 5),
ADD COLUMN notes VARCHAR(2000),
ADD COLUMN priority VARCHAR(8);
UPDATE user_game SET status = CASE WHEN playtime_minutes > 0 THEN 'PLAYING' ELSE 'BACKLOG' END;
ALTER TABLE user_game ALTER COLUMN status SET NOT NULL;
CREATE INDEX idx_user_game_user_status ON user_game (user_id, status) WHERE removed_at IS NULL;
@@ -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; 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.domain.UserSteamSync;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; 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.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test; 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.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -89,4 +97,33 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any()); verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), 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());
}
} }