Public Access
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:
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user