diff --git a/readme.md b/readme.md index e9cf6cf..b99f286 100644 --- a/readme.md +++ b/readme.md @@ -213,7 +213,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | 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` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT | +| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set; `source=STEAM,PSN` keeps rows whose own source or a listed linked edition's source is selected; ignored with `hidden=true`; `400` on an unknown source), `/api/library/games/{appid}` | 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 | | `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | @@ -246,7 +246,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `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 | | `GET` | `/api/library/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | JWT | -| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link) | JWT | +| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket | | `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket | 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 8e95e67..93a1b82 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -36,6 +36,7 @@ import core_service.modules.library.domain.AuditEntry; import core_service.modules.library.domain.CatalogGame; import core_service.modules.library.domain.CatalogGameDetail; import core_service.modules.library.domain.CatalogHit; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.Mood; @@ -121,7 +122,8 @@ public class LibraryController { boolean syncStarted = syncService.syncIfStale(userId, steamId); long count = queryService.countOwnedGames(userId); return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null, - "canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted)); + "canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted, + "sources", queryService.listSources(userId))); } @GetMapping("/games") @@ -134,7 +136,8 @@ public class LibraryController { @RequestParam(required = false) String q, @RequestParam(required = false) Integer maxMinutes, @RequestParam(defaultValue = "false") boolean hidden, - @RequestParam(defaultValue = "false") boolean hideDuplicates + @RequestParam(defaultValue = "false") boolean hideDuplicates, + @RequestParam(required = false) List source ) { Long userId = principal.getIdAsLong(); syncService.syncIfStale(userId, principal.steamIdText().orElse(null)); @@ -142,7 +145,7 @@ public class LibraryController { int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safePage = Math.max(0, page); Sort safeSort = parseSort(sort); - var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, PageRequest.of(safePage, safeSize, safeSort)); + var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort)); return ResponseEntity.ok(PageDto.of(dtoPage)); } diff --git a/src/main/java/core_service/modules/library/domain/GameLinkMember.java b/src/main/java/core_service/modules/library/domain/GameLinkMember.java new file mode 100644 index 0000000..280d7b5 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameLinkMember.java @@ -0,0 +1,28 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +/** + * A key in a linked set: read-only, see V28. Writes go through {@code GameLinkRepository}. + */ +@Entity +@Immutable +@Table(name = "game_link") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GameLinkMember { + + @Id + @Column(name = "appid") + private Long appid; + + @Column(name = "set_id") + private Long setId; +} 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 1b24598..ec591ce 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -2,6 +2,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.FinishedWithoutDate; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.StatusCount; @@ -320,6 +321,7 @@ public interface UserGameRepository extends JpaRepository * @param namePattern a {@link #containsPattern} result, null for every name * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game * @param hideDuplicates only the games a linked set counts (see V28), not its lesser editions + * @param sources the sources a row or one of its listed linked editions must have; never empty */ @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + "where ug.userId = :userId and ug.removedAt is null " + @@ -329,15 +331,23 @@ public interface UserGameRepository extends JpaRepository "and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " + " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " + "and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " + - " where c.userId = ug.userId and c.appid = ug.appid))") + " where c.userId = ug.userId and c.appid = ug.appid)) " + + "and (ug.source in :sources or exists (select 1 from GameLinkMember l " + + " join GameLinkMember o on o.setId = l.setId and o.appid <> l.appid " + + " join UserGame og on og.userId = ug.userId and og.appid = o.appid " + + " where l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.source in :sources))") Page findOwnedGames(@Param("userId") Long userId, @Param("hidden") boolean hidden, @Param("status") GameStatus status, @Param("namePattern") String namePattern, @Param("maxMinutes") Integer maxMinutes, @Param("hideDuplicates") boolean hideDuplicates, + @Param("sources") Collection sources, Pageable pageable); + @Query("select distinct ug.source from UserGame ug where ug.userId = :userId and ug.removedAt is null") + List findSources(@Param("userId") Long userId); + /** * Normal time to beat of the user's backlog, only games IGDB has data for. */ 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 4b9aa3e..f7b69f3 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -61,6 +61,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.EnumMap; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -110,12 +111,18 @@ public class LibraryQueryService { return userGameRepository.countListed(userId); } + public List listSources(Long userId) { + return userGameRepository.findSources(userId).stream().sorted().toList(); + } + public Page listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery, - Integer maxMinutes, boolean hideDuplicates, Pageable pageable) { + Integer maxMinutes, boolean hideDuplicates, Collection sources, + Pageable pageable) { Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), UserGameRepository.querySort(pageable.getSort())); Page page = userGameRepository.findOwnedGames( - userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, stable); + userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, + sources == null || sources.isEmpty() ? EnumSet.allOf(GameSource.class) : sources, stable); List games = toDtos(page.getContent()); return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); } diff --git a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java index eccdd19..2fddf49 100644 --- a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -282,4 +282,73 @@ class GameLinkFlowTest extends AbstractIntegrationTest { mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(2)); } + + private void setSource(long appid, GameSource source) { + jdbc.update("update user_game set source = ? where user_id = ? and appid = ?", source.name(), USER_A, appid); + } + + private ResultActions games(String query) throws Exception { + return mockMvc.perform(as(get("/api/library/games" + query), USER_A, Role.USER)).andExpect(status().isOk()); + } + + @Test + void sourceFilter_ALinkedSetMatchesByAnyListedEdition() throws Exception { + setSource(GAME_2, GameSource.PSN); + linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); + + games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2)); + games("?source=PSN&hideDuplicates=true").andExpect(jsonPath("$.totalElements").value(1)); + games("?source=STEAM").andExpect(jsonPath("$.totalElements").value(0)); + games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(2)); + } + + @Test + void sourceFilter_WithoutALinkOnlyTheOwnSourceMatches() throws Exception { + setSource(GAME_2, GameSource.PSN); + + games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(GAME_2)); + } + + @Test + void sourceFilter_AHiddenEditionDoesNotCount() throws Exception { + setSource(GAME_2, GameSource.PSN); + linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER_A, GAME_2); + + games("?source=PSN").andExpect(jsonPath("$.totalElements").value(0)); + games("?source=MANUAL").andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void sourceFilter_CombinesWithStatus() throws Exception { + setSource(GAME_2, GameSource.PSN); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER_A, GAME); + + games("?source=MANUAL&status=FINISHED").andExpect(jsonPath("$.totalElements").value(1)); + games("?source=PSN&status=FINISHED").andExpect(jsonPath("$.totalElements").value(0)); + games("?source=PSN&status=BACKLOG").andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void sourceFilter_HiddenViewIgnoresIt() throws Exception { + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER_A, GAME); + + games("?hidden=true&source=PSN").andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void sourceFilter_UnknownValueIs400() throws Exception { + mockMvc.perform(as(get("/api/library/games?source=XBOX"), USER_A, Role.USER)).andExpect(status().isBadRequest()); + } + + @Test + void gamesCount_AnswersTheSources() throws Exception { + setSource(GAME_2, GameSource.PSN); + + mockMvc.perform(as(get("/api/library/games-count"), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.sources[0]").value("MANUAL")) + .andExpect(jsonPath("$.sources[1]").value("PSN")) + .andExpect(jsonPath("$.sources", hasSize(2))); + } } 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 5cfc837..2634527 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -26,6 +26,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.util.EnumSet; import java.util.List; import java.util.Set; @@ -37,6 +38,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { private static final long USER_ID = 1L; private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + private static final Set ALL_SOURCES = EnumSet.allOf(GameSource.class); private static final PageRequest PAGE = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "playtimeMinutes")); @Autowired @@ -79,7 +81,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { } private List appids(GameStatus status, String query) { - return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, PAGE) + return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, ALL_SOURCES, PAGE) .map(UserGame::getAppid).getContent(); } @@ -208,7 +210,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Test void findOwnedGames_CountsAllMatchesAcrossPages() { - var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false, + var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false, ALL_SOURCES, PageRequest.of(0, 1, Sort.by("appid"))); assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent()); @@ -216,7 +218,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { } private List sortedAppids(Sort sort) { - return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) + return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) .map(UserGame::getAppid).getContent(); } @@ -267,7 +269,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { new GameTimeToBeat(620L, 300, 480, 900, Instant.now()), new GameTimeToBeat(380L, null, null, null, Instant.now()))); - List shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, PAGE).map(UserGame::getAppid).getContent(); + List shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent(); assertEquals(List.of(620L), shortGames); } @@ -294,7 +296,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { hide(620L, 700L); assertEquals(List.of(220L, 380L), appids(null, null)); - assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false, + assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false, ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID))); } @@ -359,6 +361,58 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { Set.copyOf(userGameRepository.countByStatus(USER_ID))); } + private void setSource(GameSource source, long... appids) { + entityManager.flush(); + for (long appid : appids) { + jdbc.update("update user_game set source = ? where user_id = ? and appid = ?", source.name(), USER_ID, appid); + } + entityManager.clear(); + } + + private List appidsOfSources(GameSource... sources) { + return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, Set.of(sources), + PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent(); + } + + @Test + void findOwnedGames_BySource_OnlyRowsOfTheSource() { + setSource(GameSource.PSN, 380L, 700L); + + assertEquals(List.of(380L, 700L), appidsOfSources(GameSource.PSN)); + assertEquals(List.of(220L, 620L), appidsOfSources(GameSource.STEAM)); + assertEquals(List.of(), appidsOfSources(GameSource.MANUAL)); + } + + @Test + void findOwnedGames_BySource_SeveralSourcesMatchEither() { + setSource(GameSource.PSN, 380L); + setSource(GameSource.MANUAL, 700L); + + assertEquals(List.of(220L, 380L, 620L), appidsOfSources(GameSource.STEAM, GameSource.PSN)); + assertEquals(List.of(380L, 700L), appidsOfSources(GameSource.PSN, GameSource.MANUAL)); + } + + @Test + void findOwnedGames_BySource_FollowsASteamTakeOver() { + setSource(GameSource.MANUAL, 700L); + assertEquals(List.of(700L), appidsOfSources(GameSource.MANUAL)); + + setSource(GameSource.STEAM, 700L); + + assertEquals(List.of(), appidsOfSources(GameSource.MANUAL)); + assertEquals(List.of(220L, 380L, 620L, 700L), appidsOfSources(GameSource.STEAM)); + } + + @Test + void findSources_DistinctNotRemoved() { + setSource(GameSource.PSN, 380L, 700L); + setSource(GameSource.MANUAL, 620L); + jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = ?", USER_ID, 620L); + + assertEquals(Set.of(GameSource.STEAM, GameSource.PSN), Set.copyOf(userGameRepository.findSources(USER_ID))); + assertEquals(2, userGameRepository.findSources(USER_ID).size()); + } + private void link(long... appids) { jdbc.update("delete from game_link"); for (long appid : appids) { @@ -374,9 +428,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)), Set.copyOf(userGameRepository.countByStatus(USER_ID))); assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID)); - assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, true, + assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, true, ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); - assertEquals(List.of(220L, 380L, 620L, 700L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, + assertEquals(List.of(220L, 380L, 620L, 700L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); }