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

@@ -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();
}
}