From 7747bc7b8b7faa2b74a6f8d6fb48850c1018b62d Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 6 Oct 2026 09:06:51 +0200 Subject: [PATCH] feat(library): add hand-ordered backlog with next-up picker source --- readme.md | 16 +- .../repo/AccountCleanupRepository.java | 2 +- .../library/api/LibraryController.java | 18 ++ .../library/api/dto/BacklogOrderDto.java | 5 + .../library/api/dto/BacklogOrderRequest.java | 11 ++ .../library/api/dto/PickReasonDto.java | 4 + .../modules/library/domain/BacklogOrder.java | 25 +++ .../modules/library/domain/PickMix.java | 10 +- .../modules/library/domain/PickSource.java | 3 +- .../library/repo/BacklogOrderRepository.java | 43 +++++ .../library/repo/GameMergeRepository.java | 3 + .../library/repo/UserGameRepository.java | 35 ++++ .../library/service/BacklogService.java | 14 ++ .../library/service/LibraryQueryService.java | 25 ++- .../db/migration/V34__Backlog_Order.sql | 39 ++++ .../library/api/GameMergeFlowTest.java | 12 ++ .../LibraryControllerBacklogOrderTest.java | 171 ++++++++++++++++++ .../api/LibraryControllerPickerTest.java | 55 ++++++ .../library/domain/BacklogOrderTest.java | 36 ++++ .../modules/library/domain/PickMixTest.java | 34 +++- .../repo/BacklogOrderRepositoryTest.java | 82 +++++++++ .../library/repo/BacklogOrderTriggerTest.java | 131 ++++++++++++++ 22 files changed, 754 insertions(+), 20 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/BacklogOrderDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/BacklogOrderRequest.java create mode 100644 src/main/java/core_service/modules/library/domain/BacklogOrder.java create mode 100644 src/main/java/core_service/modules/library/repo/BacklogOrderRepository.java create mode 100644 src/main/resources/db/migration/V34__Backlog_Order.sql create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerBacklogOrderTest.java create mode 100644 src/test/java/core_service/modules/library/domain/BacklogOrderTest.java create mode 100644 src/test/java/core_service/modules/library/repo/BacklogOrderRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/repo/BacklogOrderTriggerTest.java diff --git a/readme.md b/readme.md index 51b9687..9793950 100644 --- a/readme.md +++ b/readme.md @@ -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 | diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index b67bf70..71d0237 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 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"); diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 2822cd5..8b76428 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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 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 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 putBacklogOrder(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody BacklogOrderRequest request) { + backlogService.reorder(principal.getIdAsLong(), request.appids()); + return ResponseEntity.noContent().build(); + } + @GetMapping("/picker") public ResponseEntity getPicker(@AuthenticationPrincipal UserPrincipal principal, @RequestParam(required = false) Integer maxMinutes, diff --git a/src/main/java/core_service/modules/library/api/dto/BacklogOrderDto.java b/src/main/java/core_service/modules/library/api/dto/BacklogOrderDto.java new file mode 100644 index 0000000..cb4984f --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/BacklogOrderDto.java @@ -0,0 +1,5 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +public record BacklogOrderDto(List games) {} diff --git a/src/main/java/core_service/modules/library/api/dto/BacklogOrderRequest.java b/src/main/java/core_service/modules/library/api/dto/BacklogOrderRequest.java new file mode 100644 index 0000000..fe70e28 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/BacklogOrderRequest.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java index 99fbbd8..7ad2936 100644 --- a/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java @@ -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); + } } diff --git a/src/main/java/core_service/modules/library/domain/BacklogOrder.java b/src/main/java/core_service/modules/library/domain/BacklogOrder.java new file mode 100644 index 0000000..b5c9116 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/BacklogOrder.java @@ -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 reorder(List current, List wanted) { + Set ordered = new LinkedHashSet<>(wanted); + ordered.retainAll(new HashSet<>(current)); + ordered.addAll(current); + return new ArrayList<>(ordered); + } +} diff --git a/src/main/java/core_service/modules/library/domain/PickMix.java b/src/main/java/core_service/modules/library/domain/PickMix.java index 271988f..6b20f26 100644 --- a/src/main/java/core_service/modules/library/domain/PickMix.java +++ b/src/main/java/core_service/modules/library/domain/PickMix.java @@ -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 mix(List similar, List longest, List random, int count) { - List> bySource = List.of(similar, longest, random); + public static List mix(List similar, List longest, List random, + List nextUp, int count) { + List> bySource = List.of(similar, longest, random, nextUp); Set used = new HashSet<>(); List picks = new ArrayList<>(); PickSource[] sources = PickSource.values(); diff --git a/src/main/java/core_service/modules/library/domain/PickSource.java b/src/main/java/core_service/modules/library/domain/PickSource.java index 6862084..937bb50 100644 --- a/src/main/java/core_service/modules/library/domain/PickSource.java +++ b/src/main/java/core_service/modules/library/domain/PickSource.java @@ -6,5 +6,6 @@ package core_service.modules.library.domain; public enum PickSource { SIMILAR, LONGEST, - RANDOM + RANDOM, + NEXT_UP } diff --git a/src/main/java/core_service/modules/library/repo/BacklogOrderRepository.java b/src/main/java/core_service/modules/library/repo/BacklogOrderRepository.java new file mode 100644 index 0000000..4acefda --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/BacklogOrderRepository.java @@ -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 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 appids) { + List 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])); + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index ab61a3d..6313f52 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -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); diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index b345e19..e0d12e5 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -140,6 +140,9 @@ public interface UserGameRepository extends JpaRepository " 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 @Param("excludeAppids") Collection 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 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 findNextUpGames(Long userId, Integer maxMinutes, Mood mood, Collection 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 queryNextUpGames(@Param("userId") Long userId, + @Param("maxMinutes") Integer maxMinutes, + @Param("mood") String mood, + @Param("speedrun") boolean speedrun, + @Param("excludeAppids") Collection 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. diff --git a/src/main/java/core_service/modules/library/service/BacklogService.java b/src/main/java/core_service/modules/library/service/BacklogService.java index 4fd7db9..4cdd638 100644 --- a/src/main/java/core_service/modules/library/service/BacklogService.java +++ b/src/main/java/core_service/modules/library/service/BacklogService.java @@ -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 appids) { + List current = backlogOrderRepository.lockOrder(userId); + backlogOrderRepository.writeOrder(userId, BacklogOrder.reorder(current, appids)); + } + public enum HideResult { DONE, NOT_FOUND } /** diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index d156d22..13f6080 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -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 excludeAppids, int count) { @@ -125,7 +129,21 @@ public class LibraryQueryService { List 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 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 withReasons(List picks, Optional 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(); }; } diff --git a/src/main/resources/db/migration/V34__Backlog_Order.sql b/src/main/resources/db/migration/V34__Backlog_Order.sql new file mode 100644 index 0000000..771a4b5 --- /dev/null +++ b/src/main/resources/db/migration/V34__Backlog_Order.sql @@ -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(); diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index a531925..1749740 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -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()); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogOrderTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogOrderTest.java new file mode 100644 index 0000000..589bd91 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogOrderTest.java @@ -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))); + } +} diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index 90a0806..98cfb32 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -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)); + } } diff --git a/src/test/java/core_service/modules/library/domain/BacklogOrderTest.java b/src/test/java/core_service/modules/library/domain/BacklogOrderTest.java new file mode 100644 index 0000000..5453e84 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/BacklogOrderTest.java @@ -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())); + } +} diff --git a/src/test/java/core_service/modules/library/domain/PickMixTest.java b/src/test/java/core_service/modules/library/domain/PickMixTest.java index a810164..01ae4de 100644 --- a/src/test/java/core_service/modules/library/domain/PickMixTest.java +++ b/src/test/java/core_service/modules/library/domain/PickMixTest.java @@ -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 picks = PickMix.mix(games(1), games(2), games(3), 3); + List 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 picks = PickMix.mix(games(1, 4, 7), games(2, 5), games(3, 6), 7); + List 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 picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), 3); + List 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 picks = PickMix.mix(List.of(), games(2), games(3, 4), 3); + List 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 picks = PickMix.mix(games(1), games(1, 2), games(2, 3), 10); + List 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 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 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)); } } diff --git a/src/test/java/core_service/modules/library/repo/BacklogOrderRepositoryTest.java b/src/test/java/core_service/modules/library/repo/BacklogOrderRepositoryTest.java new file mode 100644 index 0000000..4d7271a --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/BacklogOrderRepositoryTest.java @@ -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 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); + } +} diff --git a/src/test/java/core_service/modules/library/repo/BacklogOrderTriggerTest.java b/src/test/java/core_service/modules/library/repo/BacklogOrderTriggerTest.java new file mode 100644 index 0000000..3848cb7 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/BacklogOrderTriggerTest.java @@ -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 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 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(); + } +}