diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index eeea7df..1a198a9 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -1,13 +1,17 @@ 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.PageDto; 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.LibraryQueryService; import core_service.modules.library.service.LibrarySyncService; import core_service.modules.library.service.SyncCooldownException; import core_service.shared.security.UserPrincipal; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; 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.PathVariable; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -37,6 +43,7 @@ public class LibraryController { private final LibrarySyncService syncService; private final LibraryQueryService queryService; private final GameDetailService gameDetailService; + private final BacklogService backlogService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -55,7 +62,9 @@ public class LibraryController { @AuthenticationPrincipal UserPrincipal principal, @RequestParam(defaultValue = "0") int page, @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) { 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 safePage = Math.max(0, page); 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)); } @@ -114,6 +123,16 @@ public class LibraryController { .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) + .>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") public ResponseEntity getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { return queryService.getSyncStatus(principal.getIdAsLong()) diff --git a/src/main/java/core_service/modules/library/api/dto/BacklogEntryDto.java b/src/main/java/core_service/modules/library/api/dto/BacklogEntryDto.java new file mode 100644 index 0000000..86a3454 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/BacklogEntryDto.java @@ -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 +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index d36b905..33af0c0 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -1,5 +1,8 @@ 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.util.List; @@ -10,6 +13,10 @@ public record GameDetailDto( int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt, + GameStatus status, + Integer rating, + String notes, + GamePriority priority, boolean hasAchievements, boolean statsVisible, int achievementsTotal, diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index 5033e63..359d7db 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -1,8 +1,14 @@ package core_service.modules.library.api.dto; +import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameStatus; + public record GameDto( Long appid, String name, String imgIconUrl, - int playtimeMinutes + int playtimeMinutes, + GameStatus status, + Integer rating, + GamePriority priority ) {} diff --git a/src/main/java/core_service/modules/library/domain/GamePriority.java b/src/main/java/core_service/modules/library/domain/GamePriority.java new file mode 100644 index 0000000..12b53ab --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GamePriority.java @@ -0,0 +1,7 @@ +package core_service.modules.library.domain; + +public enum GamePriority { + LOW, + MEDIUM, + HIGH +} diff --git a/src/main/java/core_service/modules/library/domain/GameStatus.java b/src/main/java/core_service/modules/library/domain/GameStatus.java new file mode 100644 index 0000000..16346da --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameStatus.java @@ -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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 4f0eadd..e929320 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -2,6 +2,8 @@ package core_service.modules.library.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.IdClass; import jakarta.persistence.Table; @@ -40,11 +42,26 @@ public class UserGame { @Column(name = "removed_at") 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) { this.userId = userId; this.appid = appid; this.playtimeMinutes = playtimeMinutes; this.firstSeenAt = firstSeenAt; this.lastSeenAt = lastSeenAt; + this.status = GameStatus.initialFor(playtimeMinutes); } } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index b9b6b68..28b7f73 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -1,5 +1,6 @@ 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.UserGameId; import org.springframework.data.domain.Page; @@ -11,15 +12,37 @@ import org.springframework.data.repository.query.Param; import java.time.Instant; import java.util.List; +import java.util.Locale; public interface UserGameRepository extends JpaRepository { List findByUserIdAndRemovedAtIsNull(Long userId); - Page findByUserIdAndRemovedAtIsNull(Long userId, Pageable pageable); - 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 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 @Query("update UserGame ug set ug.removedAt = :now " + "where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null") diff --git a/src/main/java/core_service/modules/library/service/BacklogService.java b/src/main/java/core_service/modules/library/service/BacklogService.java new file mode 100644 index 0000000..4db115b --- /dev/null +++ b/src/main/java/core_service/modules/library/service/BacklogService.java @@ -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 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()); + } +} diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 19ee93c..78a8717 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -110,6 +110,10 @@ public class GameDetailService { userGame.getPlaytimeMinutes(), userGame.getFirstSeenAt(), userGame.getLastSeenAt(), + userGame.getStatus(), + userGame.getRating(), + userGame.getNotes(), + userGame.getPriority(), hasAchievements, statsVisible, total, diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 5402798..73d28c3 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -1,6 +1,7 @@ package core_service.modules.library.service; 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.UserGame; import core_service.modules.library.domain.UserSteamSync; @@ -31,11 +32,9 @@ public class LibraryQueryService { return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); } - public Page listOwnedGames(Long userId, Pageable pageable) { - Page page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable); - if (page.isEmpty()) { - return page.map(ug -> new GameDto(ug.getAppid(), null, null, ug.getPlaytimeMinutes())); - } + public Page listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) { + Page page = userGameRepository.findOwnedGames( + userId, status, UserGameRepository.containsPattern(nameQuery), pageable); List appids = page.getContent().stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); @@ -45,7 +44,10 @@ public class LibraryQueryService { ug.getAppid(), g != null ? g.getName() : null, g != null ? g.getImgIconUrl() : null, - ug.getPlaytimeMinutes()); + ug.getPlaytimeMinutes(), + ug.getStatus(), + ug.getRating(), + ug.getPriority()); }); } diff --git a/src/main/resources/db/migration/V3__backlog_fields.sql b/src/main/resources/db/migration/V3__backlog_fields.sql new file mode 100644 index 0000000..7beafb0 --- /dev/null +++ b/src/main/resources/db/migration/V3__backlog_fields.sql @@ -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; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java new file mode 100644 index 0000000..ebb9d2d --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java new file mode 100644 index 0000000..536adeb --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -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 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)); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 72d164a..cca841e 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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 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()); + } }