Public Access
feat(library): picker for what to play next and a status-only update
GET /api/library/picker returns random backlog games for a time budget, HIGH priority first. PUT /games/{appid}/status changes only the status, so the picker does not need the game detail (and its Steam calls) to keep rating, notes and priority.
This commit is contained in:
1 parent
0a5186782f
commit
86db6f7167
9 files changed
+314
-3
No files matched your search
@@ -17,6 +17,8 @@ To Be Played is split into three repositories:
|
|||||||
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
|
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
|
||||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||||
|
- **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat), HIGH priority first, then
|
||||||
|
MEDIUM, then the rest. Games already shown can be excluded for a reroll.
|
||||||
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
||||||
dashboard banner.
|
dashboard banner.
|
||||||
- **Wishlist**: synced from Steam with store price and discount.
|
- **Wishlist**: synced from Steam with store price and discount.
|
||||||
@@ -38,7 +40,9 @@ on normal endpoints; the profile ticket is accepted only by `GET /api/public/pro
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
|
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
|
||||||
|
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
||||||
| `POST` | `/api/library/sync` | JWT |
|
| `POST` | `/api/library/sync` | JWT |
|
||||||
|
| `GET` | `/api/library/picker?maxMinutes=&count=&exclude=` | JWT |
|
||||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
||||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
@@ -102,7 +106,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- "What to play next" picker: time budget and mood on top of the current random suggestion.
|
- Mood filter for the picker (needs genres or themes from IGDB).
|
||||||
- Achievement summary per game and library-wide, "closest to 100 %".
|
- Achievement summary per game and library-wide, "closest to 100 %".
|
||||||
- Sale alerts for wishlist games.
|
- Sale alerts for wishlist games.
|
||||||
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
|
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
import core_service.modules.library.api.dto.PageDto;
|
import core_service.modules.library.api.dto.PageDto;
|
||||||
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
|
import core_service.modules.library.api.dto.StatusUpdateDto;
|
||||||
import core_service.modules.library.api.dto.SteamProfileDto;
|
import core_service.modules.library.api.dto.SteamProfileDto;
|
||||||
import core_service.modules.library.api.dto.SyncStatusDto;
|
import core_service.modules.library.api.dto.SyncStatusDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
@@ -33,7 +35,9 @@ 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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -45,6 +49,8 @@ public class LibraryController {
|
|||||||
private static final int DEFAULT_SIZE = 20;
|
private static final int DEFAULT_SIZE = 20;
|
||||||
private static final int MIN_ACTIVITY_DAYS = 7;
|
private static final int MIN_ACTIVITY_DAYS = 7;
|
||||||
private static final int MAX_ACTIVITY_DAYS = 90;
|
private static final int MAX_ACTIVITY_DAYS = 90;
|
||||||
|
private static final int MAX_PICKER_GAMES = 10;
|
||||||
|
private static final int MAX_PICKER_EXCLUDES = 100;
|
||||||
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
||||||
|
|
||||||
private final LibrarySyncService syncService;
|
private final LibrarySyncService syncService;
|
||||||
@@ -142,6 +148,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}/status")
|
||||||
|
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid,
|
||||||
|
@Valid @RequestBody StatusUpdateDto update) {
|
||||||
|
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/activity")
|
@GetMapping("/activity")
|
||||||
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@RequestParam(defaultValue = "30") int days) {
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
@@ -160,6 +176,18 @@ public class LibraryController {
|
|||||||
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
|
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/picker")
|
||||||
|
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestParam(required = false) Integer maxMinutes,
|
||||||
|
@RequestParam(required = false) List<Long> exclude,
|
||||||
|
@RequestParam(defaultValue = "3") int count) {
|
||||||
|
List<Long> excluded = exclude == null ? List.of()
|
||||||
|
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
|
||||||
|
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
||||||
|
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
||||||
|
return ResponseEntity.ok(queryService.pickBacklogGames(principal.getIdAsLong(), budget, excluded, safeCount));
|
||||||
|
}
|
||||||
|
|
||||||
@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,5 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record PickerDto(List<GameDto> games) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record StatusUpdateDto(@NotNull GameStatus status) {}
|
||||||
@@ -14,6 +14,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -46,6 +47,30 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
|
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
|
||||||
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
|
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random backlog games, HIGH priority first, then MEDIUM, then the rest.
|
||||||
|
*
|
||||||
|
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||||
|
* @param excludeAppids never picked; may be empty
|
||||||
|
*/
|
||||||
|
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) {
|
||||||
|
List<Long> excluded = new ArrayList<>(excludeAppids);
|
||||||
|
// "not in ()" is invalid SQL, and no appid is -1
|
||||||
|
excluded.add(-1L);
|
||||||
|
return queryPickerGames(userId, maxMinutes, excluded, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " +
|
||||||
|
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
|
||||||
|
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
|
||||||
|
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
|
||||||
|
"order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " +
|
||||||
|
"limit :count", nativeQuery = true)
|
||||||
|
List<UserGame> queryPickerGames(@Param("userId") Long userId,
|
||||||
|
@Param("maxMinutes") Integer maxMinutes,
|
||||||
|
@Param("excludeAppids") Collection<Long> excludeAppids,
|
||||||
|
@Param("count") int count);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param status null for every status
|
* @param status null for every status
|
||||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
|
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 core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
@@ -32,6 +33,21 @@ public class BacklogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes only the status and keeps rating, notes and priority.
|
||||||
|
*
|
||||||
|
* @return empty when the game is not in the user's active library
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Optional<BacklogEntryDto> updateStatus(Long userId, Long appid, GameStatus status) {
|
||||||
|
return userGameRepository.findById(new UserGameId(userId, appid))
|
||||||
|
.filter(game -> game.getRemovedAt() == null)
|
||||||
|
.map(game -> {
|
||||||
|
game.setStatus(status);
|
||||||
|
return toDto(game);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static BacklogEntryDto toDto(UserGame game) {
|
private static BacklogEntryDto toDto(UserGame game) {
|
||||||
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
|
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
|
|||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
import core_service.modules.library.api.dto.PublicLibraryDto;
|
import core_service.modules.library.api.dto.PublicLibraryDto;
|
||||||
import core_service.modules.library.api.dto.RecentGameDto;
|
import core_service.modules.library.api.dto.RecentGameDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
@@ -21,6 +22,7 @@ import core_service.modules.library.repo.WishlistGameRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -64,12 +66,21 @@ public class LibraryQueryService {
|
|||||||
UserGameRepository.querySort(pageable.getSort()));
|
UserGameRepository.querySort(pageable.getSort()));
|
||||||
Page<UserGame> page = userGameRepository.findOwnedGames(
|
Page<UserGame> page = userGameRepository.findOwnedGames(
|
||||||
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
||||||
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
|
List<GameDto> games = toDtos(page.getContent());
|
||||||
|
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List<Long> excludeAppids, int count) {
|
||||||
|
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<GameDto> toDtos(List<UserGame> games) {
|
||||||
|
List<Long> appids = games.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));
|
||||||
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
|
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
|
||||||
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
|
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
|
||||||
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid())));
|
return games.stream().map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GamePriority;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
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;
|
||||||
@@ -135,4 +136,53 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
|
|||||||
.content("{\"status\":\"PLAYING\"}"))
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_KeepsRatingNotesAndPriority() throws Exception {
|
||||||
|
UserGame game = stored();
|
||||||
|
game.setRating(4);
|
||||||
|
game.setNotes("co-op with Tom");
|
||||||
|
game.setPriority(GamePriority.HIGH);
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("PLAYING"))
|
||||||
|
.andExpect(jsonPath("$.notes").value("co-op with Tom"));
|
||||||
|
|
||||||
|
UserGame updated = stored();
|
||||||
|
assertEquals(GameStatus.PLAYING, updated.getStatus());
|
||||||
|
assertEquals(4, updated.getRating());
|
||||||
|
assertEquals("co-op with Tom", updated.getNotes());
|
||||||
|
assertEquals(GamePriority.HIGH, updated.getPriority());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_MissingStatus_Is400() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
assertEquals(GameStatus.BACKLOG, stored().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_RemovedGame_Is404() throws Exception {
|
||||||
|
UserGame game = stored();
|
||||||
|
game.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_GameNotOwned_Is404() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GamePriority;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
|
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.GameTimeToBeatRepository;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dev profile authenticates every request as user 1.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class LibraryControllerPickerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clean() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
gameTimeToBeatRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
|
||||||
|
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
|
||||||
|
UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now());
|
||||||
|
game.setStatus(status);
|
||||||
|
game.setPriority(priority);
|
||||||
|
userGameRepository.save(game);
|
||||||
|
if (normallyMinutes != null) {
|
||||||
|
gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
|
||||||
|
game(10L, GameStatus.PLAYING, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_WithoutBudget_PicksOnlyBacklogGamesIncludingUnknownLength() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, null);
|
||||||
|
game(30L, GameStatus.FINISHED, null, 60);
|
||||||
|
game(40L, GameStatus.DROPPED, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)))
|
||||||
|
.andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_WithBudget_SkipsLongerAndUnknownGames() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 300);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, 301);
|
||||||
|
game(30L, GameStatus.BACKLOG, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_ZeroBudgetMeansAnyLength() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 300);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0"))
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_SkipsExcludedGames() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(30L, GameStatus.BACKLOG, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("exclude", "10", "30"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_FillsSlotsByPriorityFirst() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60);
|
||||||
|
game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60);
|
||||||
|
game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60);
|
||||||
|
game(50L, GameStatus.BACKLOG, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("count", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40)))
|
||||||
|
.andExpect(jsonPath("$.games[0].appid").value(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
|
||||||
|
for (long appid = 1; appid <= 12; appid++) {
|
||||||
|
game(appid, GameStatus.BACKLOG, null, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(3)));
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("count", "500"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(10)));
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("count", "0"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_SkipsRemovedGames() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, 60);
|
||||||
|
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 20L)).orElseThrow();
|
||||||
|
removed.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(removed);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_OnlyShowsTheCallersGames() throws Exception {
|
||||||
|
steamGameRepository.save(new SteamGame(99L, "Other", null, Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user