feat(library): add hand-ordered backlog with next-up picker source
Build, Test & Push Core Service / quality-check (push) Successful in 1m25s
Build, Test & Push Core Service / docker (push) Successful in 1m29s

This commit is contained in:
lobo committed 2026-10-06 09:06:51 +02:00
1 parent 7b538b7f30
commit 7747bc7b8b
22 files changed
+754 -20

No files matched your search

+13 -3
View File
@@ -21,13 +21,20 @@ To Be Played is split into three repositories:
can be taken over; its own finish then changes nothing.
- **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.
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from three
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from four
sources, one per slot in turn: a game sharing moods with a random one of the user's 5 most played games (the
most shared moods first), the game waiting longest (oldest `firstSeenAt`), and a random one (HIGH priority first,
then MEDIUM, then the rest). A source with nothing left gives its slot to a random game; no game appears twice.
then MEDIUM, then the rest), and a random one of the first 5 games of the user's own backlog order that pass the
budget, mood and exclude filters (`NEXT_UP`). A source with nothing left gives its slot to a random game; no game appears twice.
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
date added). `PUT /api/library/backlog` replaces the whole order under a row lock; ids not in the backlog are
ignored and games not sent go after the sent ones. A merge keeps the better position of the two games. Coming
games are in the list; `released=true` leaves them out.
- **Coming games**: a game with a release date after today (Berlin date) or Steam "coming soon", and no playtime or
achievement of any user, is not picked and not on the pile (live count, new snapshots, pile stats, time to beat
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
@@ -37,9 +44,10 @@ To Be Played is split into three repositories:
```mermaid
flowchart LR
A["anchor: random of top 5 played"] --> S[similar]
S --> M["PickMix: rotate, dedupe, random fallback"]
S --> M["PickMix: rotate 4 sources, dedupe, random fallback"]
L[longest waiting] --> M
R[random] --> M
N["next up: random of top 5 in order"] --> M
M --> O["games + reason"]
```
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and
@@ -186,6 +194,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` (each game has a `reason` object) | JWT |
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
@@ -21,7 +21,7 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "deleted_user");
@@ -3,6 +3,8 @@ package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogOrderDto;
import core_service.modules.library.api.dto.BacklogOrderRequest;
import core_service.modules.library.api.dto.BacklogPatch;
import core_service.modules.library.api.dto.AuditEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
@@ -96,6 +98,7 @@ public class LibraryController {
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 int MAX_BACKLOG_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private final LibrarySyncService syncService;
@@ -465,6 +468,21 @@ public class LibraryController {
return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong()));
}
@GetMapping("/backlog")
public ResponseEntity<BacklogOrderDto> getBacklog(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Integer limit,
@RequestParam(defaultValue = "false") boolean released) {
Integer safeLimit = limit == null ? null : Math.clamp(limit, 1, MAX_BACKLOG_GAMES);
return ResponseEntity.ok(queryService.listBacklog(principal.getIdAsLong(), released, safeLimit));
}
@PutMapping("/backlog")
public ResponseEntity<Void> putBacklogOrder(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody BacklogOrderRequest request) {
backlogService.reorder(principal.getIdAsLong(), request.appids());
return ResponseEntity.noContent().build();
}
@GetMapping("/picker")
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Integer maxMinutes,
@@ -0,0 +1,5 @@
package core_service.modules.library.api.dto;
import java.util.List;
public record BacklogOrderDto(List<GameDto> games) {}
@@ -0,0 +1,11 @@
package core_service.modules.library.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* @param appids the backlog in the wanted order
*/
public record BacklogOrderRequest(@NotNull @Size(max = 20000) List<@NotNull Long> appids) {}
@@ -25,4 +25,8 @@ public record PickReasonDto(PickSource source, Long anchorAppid, String anchorNa
public static PickReasonDto random() {
return new PickReasonDto(PickSource.RANDOM, null, null, null, null);
}
public static PickReasonDto nextUp() {
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null);
}
}
@@ -0,0 +1,25 @@
package core_service.modules.library.domain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* The user's own order of the backlog.
*/
public final class BacklogOrder {
private BacklogOrder() {}
/**
* @return the wanted appids that are in current, in wanted order, each once; then the rest of current in its order
*/
public static List<Long> reorder(List<Long> current, List<Long> wanted) {
Set<Long> ordered = new LinkedHashSet<>(wanted);
ordered.retainAll(new HashSet<>(current));
ordered.addAll(current);
return new ArrayList<>(ordered);
}
}
@@ -6,7 +6,7 @@ import java.util.List;
import java.util.Set;
/**
* Mixes the picker's three candidate lists into one list of picks.
* Mixes the picker's four candidate lists into one list of picks.
*/
public final class PickMix {
@@ -15,13 +15,15 @@ public final class PickMix {
private PickMix() {}
/**
* Slot i takes the first unused game of source i modulo 3; when that source has none, the first unused random game.
* Slot i takes the first unused game of source i modulo the number of sources; when that source has none, the
* first unused random game.
* Stops early when no game is left.
*
* @param count the most picks
*/
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random, int count) {
List<List<UserGame>> bySource = List.of(similar, longest, random);
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random,
List<UserGame> nextUp, int count) {
List<List<UserGame>> bySource = List.of(similar, longest, random, nextUp);
Set<Long> used = new HashSet<>();
List<Pick> picks = new ArrayList<>();
PickSource[] sources = PickSource.values();
@@ -6,5 +6,6 @@ package core_service.modules.library.domain;
public enum PickSource {
SIMILAR,
LONGEST,
RANDOM
RANDOM,
NEXT_UP
}
@@ -0,0 +1,43 @@
package core_service.modules.library.repo;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
/**
* The user's backlog order. Plain SQL: the rows are written by a trigger on user_game (V34), not by an entity.
*/
@Repository
@RequiredArgsConstructor
public class BacklogOrderRepository {
private final NamedParameterJdbcTemplate jdbc;
/**
* Locks the user's order rows until the transaction ends, so two reorders do not cross.
*
* @return the appids in their current order
*/
public List<Long> lockOrder(long userId) {
return jdbc.query("select appid from backlog_order where user_id = :userId order by position, appid for update",
new MapSqlParameterSource("userId", userId), (rs, row) -> rs.getLong("appid"));
}
/**
* Gives the appids the positions 1..n in the given order.
*/
public void writeOrder(long userId, List<Long> appids) {
List<SqlParameterSource> batch = new ArrayList<>();
for (int i = 0; i < appids.size(); i++) {
batch.add(new MapSqlParameterSource("userId", userId).addValue("appid", appids.get(i))
.addValue("position", i + 1));
}
jdbc.batchUpdate("update backlog_order set position = :position where user_id = :userId and appid = :appid",
batch.toArray(new SqlParameterSource[0]));
}
}
@@ -174,6 +174,9 @@ public class GameMergeRepository {
jdbc.update("delete from user_game_status_change where appid = :to and changed_at = now()", keys);
jdbc.update("update user_game_status_change c set appid = :to where c.appid = :from and exists "
+ "(select 1 from user_game t where t.user_id = c.user_id and t.appid = :to)", keys);
// The kept game takes the better backlog position of the two; the from row's goes with the row (FK cascade).
jdbc.update("update backlog_order t set position = least(t.position, f.position) from backlog_order f "
+ "where f.user_id = t.user_id and f.appid = :from and t.appid = :to", keys);
jdbc.update("delete from user_game f where f.appid = :from and exists "
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
@@ -140,6 +140,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
" where s.appid = ug.appid and s.weblink is not null)) " +
"and " + NOT_COMING;
String IN_BACKLOG_ORDER = "counted_user_game ug join backlog_order bo on bo.user_id = ug.user_id " +
"and bo.appid = ug.appid ";
String PRIORITY_ORDER = "case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end";
/**
@@ -182,6 +185,38 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* The listed backlog in the user's own order.
*
* @param released true leaves out the coming games
*/
@Query(value = "select ug.* from " + IN_BACKLOG_ORDER + "where ug.user_id = :userId and ug.status = 'BACKLOG' " +
"and (:released = false or " + NOT_COMING + ") order by bo.position, ug.appid limit :limit",
nativeQuery = true)
List<UserGame> findBacklogInOrder(@Param("userId") Long userId,
@Param("released") boolean released,
@Param("limit") int limit);
/**
* The first backlog games of the user's own order that pass the picker filters.
*
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
* @param mood only games known to have this mood; null for every game
* @param excludeAppids never picked; may be empty
*/
default List<UserGame> findNextUpGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> excludeAppids, int count) {
return queryNextUpGames(userId, maxMinutes, storedMood(mood), mood == Mood.SPEEDRUN, excludedOrNone(excludeAppids), count);
}
@Query(value = "select ug.* from " + IN_BACKLOG_ORDER + "where " + PICKER_FILTERS +
"order by bo.position, ug.appid limit :count", nativeQuery = true)
List<UserGame> queryNextUpGames(@Param("userId") Long userId,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* Backlog games that share a mood with the anchor game, the most shared moods first, then HIGH priority first.
* Empty when the anchor has no known moods.
@@ -2,10 +2,12 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BacklogPatch;
import core_service.modules.library.domain.BacklogOrder;
import core_service.modules.library.domain.FinishedHintType;
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.repo.BacklogOrderRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@@ -14,6 +16,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service
@@ -22,6 +25,7 @@ import java.util.Optional;
public class BacklogService {
private final UserGameRepository userGameRepository;
private final BacklogOrderRepository backlogOrderRepository;
/**
* Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes
@@ -61,6 +65,16 @@ public class BacklogService {
});
}
/**
* Sets the user's backlog order. Appids not in the backlog are ignored; backlog games not sent go after the sent
* ones in their old order.
*/
@Transactional
public void reorder(Long userId, List<Long> appids) {
List<Long> current = backlogOrderRepository.lockOrder(userId);
backlogOrderRepository.writeOrder(userId, BacklogOrder.reorder(current, appids));
}
public enum HideResult { DONE, NOT_FOUND }
/**
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogOrderDto;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PickReasonDto;
@@ -53,6 +54,7 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -74,6 +76,7 @@ public class LibraryQueryService {
private static final int CLOSEST_GAMES = 5;
private static final int RECENT_GAMES = 5;
private static final int ANCHOR_CANDIDATES = 5;
private static final int NEXT_UP_CANDIDATES = 5;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
@@ -108,8 +111,9 @@ public class LibraryQueryService {
}
/**
* Mixes three sources, one per slot in turn: a backlog game like a game the user played a lot, the game waiting
* longest, and a random one (HIGH priority first). A source with nothing left gives its slot to a random game.
* Mixes four sources, one per slot in turn: a backlog game like a game the user played a lot, the game waiting
* longest, a random one (HIGH priority first), and a random one of the first five of the user's own backlog
* order. A source with nothing left gives its slot to a random game.
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*/
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
@@ -125,7 +129,21 @@ public class LibraryQueryService {
List<UserGame> similar = anchor
.map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count))
.orElse(List.of());
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, count), anchor));
List<UserGame> nextUp = new ArrayList<>(userGameRepository.findNextUpGames(userId, maxMinutes, mood,
excludeAppids, NEXT_UP_CANDIDATES));
Collections.shuffle(nextUp);
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, nextUp, count), anchor));
}
/**
* The listed backlog in the user's own order.
*
* @param released true leaves out the coming games
* @param limit the most games; null for the whole backlog
*/
public BacklogOrderDto listBacklog(Long userId, boolean released, Integer limit) {
return new BacklogOrderDto(toDtos(userGameRepository.findBacklogInOrder(userId, released,
limit == null ? Integer.MAX_VALUE : limit)));
}
private List<PickedGameDto> withReasons(List<PickMix.Pick> picks, Optional<UserGame> anchor) {
@@ -145,6 +163,7 @@ public class LibraryQueryService {
case SIMILAR -> PickReasonDto.similar(anchor.orElseThrow(), anchorName);
case LONGEST -> PickReasonDto.longest(pick.game().getFirstSeenAt());
case RANDOM -> PickReasonDto.random();
case NEXT_UP -> PickReasonDto.nextUp();
};
}
@@ -0,0 +1,39 @@
-- The user's own backlog order (Deployment #3). A row exists exactly while the game is listed BACKLOG
-- (status BACKLOG, not removed, not hidden); the trigger keeps it so, for every code path like V14.
-- Lower position = earlier. Ties (rare, two appends at once) sort by appid.
CREATE TABLE backlog_order (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
position BIGINT NOT NULL,
PRIMARY KEY (user_id, appid),
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX backlog_order_user_position_idx ON backlog_order (user_id, position);
-- First order: priority HIGH, MEDIUM, then the rest, then the date added.
INSERT INTO backlog_order (user_id, appid, position)
SELECT user_id, appid, row_number() OVER (PARTITION BY user_id ORDER BY
CASE priority WHEN 'HIGH' THEN 0 WHEN 'MEDIUM' THEN 1 ELSE 2 END, first_seen_at, appid)
FROM listed_user_game WHERE status = 'BACKLOG';
CREATE FUNCTION sync_backlog_order() RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'BACKLOG' AND NEW.removed_at IS NULL AND NEW.hidden_at IS NULL THEN
IF TG_OP = 'INSERT' OR NOT (OLD.status = 'BACKLOG' AND OLD.removed_at IS NULL AND OLD.hidden_at IS NULL) THEN
INSERT INTO backlog_order (user_id, appid, position)
SELECT NEW.user_id, NEW.appid, coalesce(max(b.position), 0) + 1
FROM backlog_order b WHERE b.user_id = NEW.user_id
ON CONFLICT DO NOTHING;
END IF;
ELSIF TG_OP = 'UPDATE' THEN
DELETE FROM backlog_order WHERE user_id = NEW.user_id AND appid = NEW.appid;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER backlog_order_insert AFTER INSERT ON user_game
FOR EACH ROW EXECUTE FUNCTION sync_backlog_order();
CREATE TRIGGER backlog_order_update AFTER UPDATE OF status, removed_at, hidden_at ON user_game
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.removed_at IS DISTINCT FROM NEW.removed_at
OR OLD.hidden_at IS DISTINCT FROM NEW.hidden_at)
EXECUTE FUNCTION sync_backlog_order();
@@ -232,6 +232,18 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
"STARTED " + MANUAL, "STARTED " + STEAM);
}
@Test
void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
userGameRepository.save(new UserGame(AUDITOR_ID, STEAM_2, 0, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(AUDITOR_ID, STEAM, 0, Instant.now(), Instant.now()));
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForList("select appid from backlog_order where user_id = ? order by position, appid",
Long.class, AUDITOR_ID)).containsExactly(STEAM, STEAM_2);
}
@Test
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
@@ -0,0 +1,171 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
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.repo.GameReleaseRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractMockedLibraryServicesTest;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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 LibraryControllerBacklogOrderTest extends AbstractMockedLibraryServicesTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void clean() {
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
}
private void game(long userId, long appid, GameStatus status) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(userId, appid, 0, Instant.now(), Instant.now());
game.setStatus(status);
userGameRepository.save(game);
}
private ResultActions putOrder(String appidsJson) throws Exception {
return mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"appids\":" + appidsJson + "}"));
}
@Test
void getBacklog_ReturnsTheGamesInOrder_AndLimitReturnsTheFirst() throws Exception {
game(USER_ID, 10, GameStatus.BACKLOG);
game(USER_ID, 20, GameStatus.BACKLOG);
game(USER_ID, 30, GameStatus.BACKLOG);
game(USER_ID, 40, GameStatus.PLAYING);
mockMvc.perform(get("/api/library/backlog"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", contains(10, 20, 30)));
mockMvc.perform(get("/api/library/backlog").param("limit", "2"))
.andExpect(jsonPath("$.games[*].appid", contains(10, 20)));
}
@Test
void getBacklog_ComingGame_IsShownAndCanBeReordered_ReleasedLeavesItOut() throws Exception {
LocalDate berlinToday = LocalDate.now(ZoneId.of("Europe/Berlin"));
game(USER_ID, 10, GameStatus.BACKLOG);
game(USER_ID, 20, GameStatus.BACKLOG);
gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now());
putOrder("[20,10]").andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/backlog"))
.andExpect(jsonPath("$.games[*].appid", contains(20, 10)))
.andExpect(jsonPath("$.games[0].coming").value(true));
mockMvc.perform(get("/api/library/backlog").param("released", "true"))
.andExpect(jsonPath("$.games[*].appid", contains(10)));
}
@Test
void putBacklog_ThenGet_ReturnsTheNewOrder() throws Exception {
game(USER_ID, 10, GameStatus.BACKLOG);
game(USER_ID, 20, GameStatus.BACKLOG);
game(USER_ID, 30, GameStatus.BACKLOG);
putOrder("[30,10,20]").andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/backlog"))
.andExpect(jsonPath("$.games[*].appid", contains(30, 10, 20)));
}
@Test
void putBacklog_AnotherUsersAppid_DoesNotChangeThatUsersOrder() throws Exception {
game(USER_ID, 10, GameStatus.BACKLOG);
game(2L, 20, GameStatus.BACKLOG);
game(2L, 30, GameStatus.BACKLOG);
putOrder("[30,10,20]").andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/backlog"))
.andExpect(jsonPath("$.games[*].appid", contains(10)));
assertThat(userGameRepository.findBacklogInOrder(2L, false, 10))
.extracting(UserGame::getAppid).containsExactly(20L, 30L);
}
@Test
void putBacklog_PartialList_LeavesTheMissingGamesAfterTheSentOnes() throws Exception {
game(USER_ID, 10, GameStatus.BACKLOG);
game(USER_ID, 20, GameStatus.BACKLOG);
game(USER_ID, 30, GameStatus.BACKLOG);
putOrder("[30]").andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/backlog"))
.andExpect(jsonPath("$.games[*].appid", contains(30, 10, 20)));
}
@Test
void getBacklog_Empty_ReturnsNoGames() throws Exception {
mockMvc.perform(get("/api/library/backlog"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games", hasSize(0)));
}
@Test
void putBacklog_NullElement_Is400() throws Exception {
putOrder("[10,null]").andExpect(status().isBadRequest());
}
@Test
void putBacklog_MoreThan20000Appids_Is400() throws Exception {
String tooMany = LongStream.rangeClosed(1, 20001).mapToObj(Long::toString)
.collect(Collectors.joining(",", "[", "]"));
putOrder(tooMany).andExpect(status().isBadRequest());
}
@Test
void getBacklog_GameSetToFinished_IsGone() throws Exception {
game(USER_ID, 10, GameStatus.BACKLOG);
game(USER_ID, 20, GameStatus.BACKLOG);
mockMvc.perform(put("/api/library/games/10/status").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"FINISHED\"}"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/library/backlog"))
.andExpect(jsonPath("$.games[*].appid", contains(20)));
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
@@ -11,24 +12,30 @@ import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractMockedLibraryServicesTest;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Arrays;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;
@@ -63,9 +70,14 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private UserGameAchievementMetaRepository achievementMetaRepository;
@BeforeEach
void clean() {
gameReleaseRepository.deleteAll();
// A game with unlocked achievements is never coming; other test classes leave such rows.
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
@@ -306,4 +318,47 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
}
private void putOrder(Long... appids) throws Exception {
mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"appids\":" + Arrays.toString(appids) + "}"))
.andExpect(status().isNoContent());
}
@Test
void picker_FourSlots_NextUpComesFromTopFiveWithReason() throws Exception {
for (long appid = 1; appid <= 7; appid++) {
game(appid, GameStatus.BACKLOG, null, null);
}
putOrder(7L, 6L, 5L, 4L, 3L, 2L, 1L);
mockMvc.perform(get("/api/library/picker").param("count", "4"))
.andExpect(jsonPath("$.games", hasSize(4)))
.andExpect(jsonPath("$.games[3].reason.source").value("NEXT_UP"))
.andExpect(jsonPath("$.games[3].appid", anyOf(is(7), is(6), is(5), is(4), is(3))));
}
@Test
void picker_NextUp_SkipsExcludedAndFilteredGames() throws Exception {
for (long appid = 1; appid <= 12; appid++) {
game(appid, GameStatus.BACKLOG, null, 60);
}
mockMvc.perform(get("/api/library/picker").param("count", "4").param("exclude", "1", "2", "3", "4", "5"))
.andExpect(jsonPath("$.games[3].reason.source").value("NEXT_UP"))
.andExpect(jsonPath("$.games[3].appid", anyOf(is(6), is(7), is(8), is(9), is(10))));
}
@Test
void picker_NextUp_NeverPicksAComingGame() throws Exception {
LocalDate berlinToday = LocalDate.now(ZoneId.of("Europe/Berlin"));
game(10L, GameStatus.BACKLOG, null, null);
game(20L, GameStatus.BACKLOG, null, null);
gameReleaseRepository.upsert(10L, berlinToday.plusDays(1), false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("count", "4"))
.andExpect(jsonPath("$.games", hasSize(1)))
.andExpect(jsonPath("$.games[0].appid").value(20));
}
}
@@ -0,0 +1,36 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class BacklogOrderTest extends AbstractUnitTest {
@Test
void reorder_KeepsTheSentOrder() {
assertEquals(List.of(3L, 1L, 2L), BacklogOrder.reorder(List.of(1L, 2L, 3L), List.of(3L, 1L, 2L)));
}
@Test
void reorder_IgnoresUnknownAppids() {
assertEquals(List.of(2L, 1L), BacklogOrder.reorder(List.of(1L, 2L), List.of(99L, 2L, 1L, 98L)));
}
@Test
void reorder_KeepsADuplicateOnce() {
assertEquals(List.of(2L, 1L), BacklogOrder.reorder(List.of(1L, 2L), List.of(2L, 2L, 1L, 2L)));
}
@Test
void reorder_AppendsTheMissingInTheirOldOrder() {
assertEquals(List.of(4L, 1L, 2L, 3L, 5L), BacklogOrder.reorder(List.of(1L, 2L, 3L, 4L, 5L), List.of(4L)));
}
@Test
void reorder_EmptyWanted_KeepsCurrent() {
assertEquals(List.of(1L, 2L, 3L), BacklogOrder.reorder(List.of(1L, 2L, 3L), List.of()));
}
}
@@ -9,6 +9,7 @@ import java.util.Arrays;
import java.util.List;
import static core_service.modules.library.domain.PickSource.LONGEST;
import static core_service.modules.library.domain.PickSource.NEXT_UP;
import static core_service.modules.library.domain.PickSource.RANDOM;
import static core_service.modules.library.domain.PickSource.SIMILAR;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -34,7 +35,7 @@ class PickMixTest extends AbstractUnitTest {
@Test
void mix_ThreeSources_OneGameEachInSlotOrder() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), 3);
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), List.of(), 3);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
assertEquals(List.of(1L, 2L, 3L), appids(picks));
@@ -42,15 +43,16 @@ class PickMixTest extends AbstractUnitTest {
@Test
void mix_MoreSlotsThanSources_RotationContinues() {
List<Pick> picks = PickMix.mix(games(1, 4, 7), games(2, 5), games(3, 6), 7);
List<Pick> picks = PickMix.mix(games(1, 5, 9), games(2, 6), games(3, 7), games(4, 8), 9);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, SIMILAR, LONGEST, RANDOM, SIMILAR), sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L), appids(picks));
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR),
sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L), appids(picks));
}
@Test
void mix_SameGameFirstInAllLists_AppearsOnce() {
List<Pick> picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), 3);
List<Pick> picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), List.of(), 3);
assertEquals(List.of(1L, 5L, 6L), appids(picks));
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
@@ -58,7 +60,7 @@ class PickMixTest extends AbstractUnitTest {
@Test
void mix_EmptySimilar_SlotComesFromRandom() {
List<Pick> picks = PickMix.mix(List.of(), games(2), games(3, 4), 3);
List<Pick> picks = PickMix.mix(List.of(), games(2), games(3, 4), List.of(), 3);
assertEquals(List.of(RANDOM, LONGEST, RANDOM), sources(picks));
assertEquals(List.of(3L, 2L, 4L), appids(picks));
@@ -66,7 +68,7 @@ class PickMixTest extends AbstractUnitTest {
@Test
void mix_ListsShorterThanCount_ReturnsAllDistinctGames() {
List<Pick> picks = PickMix.mix(games(1), games(1, 2), games(2, 3), 10);
List<Pick> picks = PickMix.mix(games(1), games(1, 2), games(2, 3), List.of(), 10);
assertEquals(List.of(1L, 2L, 3L), appids(picks).stream().sorted().toList());
assertEquals(3, picks.size());
@@ -74,6 +76,22 @@ class PickMixTest extends AbstractUnitTest {
@Test
void mix_AllEmpty_ReturnsNothing() {
assertTrue(PickMix.mix(List.of(), List.of(), List.of(), 3).isEmpty());
assertTrue(PickMix.mix(List.of(), List.of(), List.of(), List.of(), 3).isEmpty());
}
@Test
void mix_FourSources_NextUpIsTheFourthSlot() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), games(4), 4);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, NEXT_UP), sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L), appids(picks));
}
@Test
void mix_EmptyNextUp_SlotComesFromRandom() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3, 4), List.of(), 4);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, RANDOM), sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L), appids(picks));
}
}
@@ -0,0 +1,82 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserGame;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(BacklogOrderRepository.class)
class BacklogOrderRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 1L;
@Autowired
private BacklogOrderRepository backlogOrderRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from game_link");
jdbc.update("delete from game_release");
jdbc.update("delete from user_game");
for (long appid : List.of(10L, 20L, 30L, 40L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
for (long appid : List.of(10L, 20L, 30L)) {
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, 'BACKLOG')", USER, appid);
}
}
private List<Long> backlog(boolean released) {
return userGameRepository.findBacklogInOrder(USER, released, Integer.MAX_VALUE).stream()
.map(UserGame::getAppid).toList();
}
@Test
void writeOrder_AfterLockOrder_GivesPositionsOneToN() {
assertThat(backlogOrderRepository.lockOrder(USER)).containsExactly(10L, 20L, 30L);
backlogOrderRepository.writeOrder(USER, List.of(30L, 10L, 20L));
assertThat(jdbc.queryForList("select position from backlog_order where user_id = ? order by position",
Long.class, USER)).containsExactly(1L, 2L, 3L);
assertThat(backlogOrderRepository.lockOrder(USER)).containsExactly(30L, 10L, 20L);
}
@Test
void findBacklogInOrder_FollowsThePosition_AndLimit() {
backlogOrderRepository.writeOrder(USER, List.of(30L, 10L, 20L));
assertThat(backlog(false)).containsExactly(30L, 10L, 20L);
assertThat(userGameRepository.findBacklogInOrder(USER, false, 2)).extracting(UserGame::getAppid)
.containsExactly(30L, 10L);
}
@Test
void findBacklogInOrder_LeavesOutALesserEditionOfALinkedSet() {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, 40, 'BACKLOG', 100)", USER);
jdbc.update("insert into game_link (appid, set_id) values (10, 1), (40, 1)");
assertThat(backlog(false)).containsExactly(20L, 30L, 40L);
}
@Test
void findBacklogInOrder_ReleasedLeavesOutAComingGame() {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (20, current_date + 30, now())");
assertThat(backlog(true)).containsExactly(10L, 30L);
assertThat(backlog(false)).containsExactly(10L, 20L, 30L);
}
}
@@ -0,0 +1,131 @@
package core_service.modules.library.repo;
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.jdbc.core.JdbcTemplate;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The trigger of V34 that keeps backlog_order.
*/
class BacklogOrderTriggerTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from user_game");
for (long appid = 1; appid <= 300; appid++) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void own(long userId, long appid, String status) {
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, ?)", userId, appid, status);
}
private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, appid);
}
private List<Long> order(long userId) {
return jdbc.queryForList("select appid from backlog_order where user_id = ? order by position, appid",
Long.class, userId);
}
@Test
void insertedBacklogGame_GoesToTheBottom() {
own(USER, 1, "BACKLOG");
own(USER, 2, "BACKLOG");
own(OTHER_USER, 3, "BACKLOG");
assertThat(order(USER)).containsExactly(1L, 2L);
assertThat(order(OTHER_USER)).containsExactly(3L);
assertThat(jdbc.queryForObject("select position from backlog_order where user_id = ? and appid = 3",
Long.class, OTHER_USER)).isEqualTo(1L);
}
@Test
void insertedPlayingGame_HasNoRow() {
own(USER, 1, "PLAYING");
assertThat(order(USER)).isEmpty();
}
@Test
void leavingBacklog_DeletesTheRow_AndComingBackPutsItAtTheBottom() {
own(USER, 1, "BACKLOG");
own(USER, 2, "BACKLOG");
setStatus(1, "FINISHED");
assertThat(order(USER)).containsExactly(2L);
setStatus(1, "BACKLOG");
assertThat(order(USER)).containsExactly(2L, 1L);
}
@Test
void hideAndRemove_DeleteTheRow_AndUnhidePutsItAtTheBottom() {
own(USER, 1, "BACKLOG");
own(USER, 2, "BACKLOG");
own(USER, 3, "BACKLOG");
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 1", USER);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 2", USER);
assertThat(order(USER)).containsExactly(3L);
jdbc.update("update user_game set hidden_at = null where user_id = ? and appid = 1", USER);
assertThat(order(USER)).containsExactly(3L, 1L);
}
@Test
void updateWithoutStatusHideOrRemoveChange_KeepsThePosition() {
own(USER, 1, "BACKLOG");
own(USER, 2, "BACKLOG");
jdbc.update("update backlog_order set position = 5 where user_id = ? and appid = 1", USER);
jdbc.update("update user_game set status = 'BACKLOG', notes = 'x', hidden_at = null where user_id = ? and appid = 1",
USER);
assertThat(order(USER)).containsExactly(2L, 1L);
}
@Test
void manyNewGamesAfterAReorder_KeepTheFirstPositions() {
own(USER, 1, "BACKLOG");
own(USER, 2, "BACKLOG");
own(USER, 3, "BACKLOG");
jdbc.update("update backlog_order set position = 3 where user_id = ? and appid = 1", USER);
jdbc.update("update backlog_order set position = 2 where user_id = ? and appid = 2", USER);
jdbc.update("update backlog_order set position = 1 where user_id = ? and appid = 3", USER);
for (long appid = 100; appid < 300; appid++) {
own(USER, appid, "BACKLOG");
}
List<Long> expected = new ArrayList<>(List.of(3L, 2L, 1L));
for (long appid = 100; appid < 300; appid++) {
expected.add(appid);
}
assertThat(order(USER)).isEqualTo(expected);
}
@Test
void deletingTheGame_DeletesTheOrderRow() {
own(USER, 1, "BACKLOG");
jdbc.update("delete from user_game where user_id = ? and appid = 1", USER);
assertThat(order(USER)).isEmpty();
}
}