diff --git a/readme.md b/readme.md index 96c65d0..6a80fce 100644 --- a/readme.md +++ b/readme.md @@ -253,7 +253,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | -| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT | +| `GET` | `/api/library/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT | | `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | 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 4de859b..8568b96 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -103,7 +103,8 @@ public class LibraryController { private static final int MAX_PICKER_EXCLUDES = 100; private static final int MAX_BACKLOG_GAMES = 20; private static final int MAX_DASHBOARD_GAMES = 20; - private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); + private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority", + "name", "lastPlayedAt", "timeToBeat"); private final LibrarySyncService syncService; private final LibraryQueryService queryService; 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 fd8abcb..47cecad 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -366,7 +366,8 @@ public interface UserGameRepository extends JpaRepository // ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path: // join it only for the playtime sort or the PLAYING status, with a second query. @Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + - "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE, + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + + "left join GameTimeToBeat tb on tb.appid = ug.appid " + OWNED_WHERE, countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE) Page findOwnedEntries(@Param("userId") Long userId, @Param("hidden") boolean hidden, @@ -418,7 +419,9 @@ public interface UserGameRepository extends JpaRepository /** * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and - * appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. + * appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. A game never + * played or without a known time to beat comes last in both directions; the name sorts case-insensitive. + * The time to beat needs {@code left join GameTimeToBeat tb on tb.appid = ug.appid}. */ static Sort querySort(Sort requested) { Sort result = Sort.unsorted(); @@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") .iterator().next().nullsLast()); + case "name" -> Sort.by(JpaSort.unsafe(order.getDirection(), "lower(sg.name)").iterator().next()); + case "lastPlayedAt" -> Sort.by(order.nullsLast()); + case "timeToBeat" -> Sort.by(JpaSort.unsafe(order.getDirection(), "tb.normallyMinutes") + .iterator().next().nullsLast()); default -> Sort.by(order); }); } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSortTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSortTest.java new file mode 100644 index 0000000..78f3549 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSortTest.java @@ -0,0 +1,69 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractMockedAdaptersTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The dev profile authenticates every request as user 1. + */ +@AutoConfigureMockMvc +class LibraryControllerSortTest extends AbstractMockedAdaptersTest { + + private static final long USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now())); + steamGameRepository.save(new SteamGame(620L, "portal 2", null, Instant.now())); + steamGameRepository.save(new SteamGame(700L, "Antichamber", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now())); + } + + @Test + void sortByName_ListsTheNamesAZ() throws Exception { + mockMvc.perform(get("/api/library/games?sort=name,asc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].appid").value(700)) + .andExpect(jsonPath("$.content[1].appid").value(220)) + .andExpect(jsonPath("$.content[2].appid").value(620)); + } + + @Test + void sortByTimeToBeatAndLastPlayed_AreAccepted() throws Exception { + mockMvc.perform(get("/api/library/games?sort=timeToBeat,asc")).andExpect(status().isOk()); + mockMvc.perform(get("/api/library/games?sort=lastPlayedAt,desc")).andExpect(status().isOk()); + } + + @Test + void sortByLastSeenAt_StillWorks() throws Exception { + mockMvc.perform(get("/api/library/games?sort=lastSeenAt,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(3)); + } +} diff --git a/src/test/java/core_service/modules/library/repo/OwnedGamesQueryGuardTest.java b/src/test/java/core_service/modules/library/repo/OwnedGamesQueryGuardTest.java index c6a1415..7e866f2 100644 --- a/src/test/java/core_service/modules/library/repo/OwnedGamesQueryGuardTest.java +++ b/src/test/java/core_service/modules/library/repo/OwnedGamesQueryGuardTest.java @@ -36,7 +36,8 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest { private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid "; private static final String OLD_SELECT = "select ug " + OLD_FROM - + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "; + + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + + "left join GameTimeToBeat tb on tb.appid = ug.appid "; private static final String OLD_COUNT = "select count(ug) " + OLD_FROM; private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " + "and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " + @@ -55,7 +56,9 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest { private static final List SORTS = List.of( Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"), Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"), - Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority")); + Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"), + Sort.by(Sort.Direction.ASC, "name"), Sort.by(Sort.Direction.DESC, "lastPlayedAt"), + Sort.by(Sort.Direction.ASC, "timeToBeat")); @Autowired private UserGameRepository userGameRepository; diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 54bb2aa..cde2363 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -348,6 +348,35 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority"))); } + @Test + void querySort_NameAsc_IsCaseInsensitiveAZ() { + steamGameRepository.saveAndFlush(new SteamGame(620L, "portal 2", null, Instant.now())); + + assertEquals(List.of(700L, 220L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "name"))); + assertEquals(List.of(620L, 380L, 220L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "name"))); + } + + @Test + void querySort_LastPlayedDesc_PutsNeverPlayedLast() { + Instant now = Instant.now(); + userGameRepository.setLastPlayedAt(USER_ID, 380L, now.minusSeconds(3600)); + userGameRepository.setLastPlayedAt(USER_ID, 700L, now); + + assertEquals(List.of(700L, 380L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "lastPlayedAt"))); + assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "lastPlayedAt"))); + } + + @Test + void querySort_TimeToBeatAsc_PutsUnknownLast() { + gameTimeToBeatRepository.saveAll(List.of( + new GameTimeToBeat(620L, 300, 480, 900, Instant.now()), + new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()), + new GameTimeToBeat(380L, 100, null, 200, Instant.now()))); + + assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.ASC, "timeToBeat"))); + assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "timeToBeat"))); + } + private void setBacklog(long appid, Integer rating, GamePriority priority) { UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); game.setRating(rating);