diff --git a/readme.md b/readme.md index d58a706..946f732 100644 --- a/readme.md +++ b/readme.md @@ -53,7 +53,9 @@ To Be Played is split into three repositories: and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view - `listed_user_game`, V24). + `listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then + playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime + sums, most played, genres, moods and platforms still use every listed game. - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older changes are not known. @@ -80,7 +82,7 @@ To Be Played is split into three repositories: - **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the feature is off and the rest works. - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games - and average completion. Readable only + and average completion (the numbers count a linked set once; the lists keep every edition). Readable only with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by default. A section the user @@ -131,7 +133,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | -| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | +| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/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`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | @@ -204,7 +206,7 @@ flowchart LR Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] LibRepo --> Db[("PostgreSQL tbp_core")] - Stats -->|"listed_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db + Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db Auth -->|"service token
/internal/accounts"| Account Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db ``` @@ -220,12 +222,14 @@ flowchart LR | `speedrun_adapter` | speedrun.com client: game search by name, verified runs | **One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in -SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` +SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls. The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view -`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. +`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that +count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes +a column of `user_game` drops this view first, then `listed_user_game`. Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build: @@ -348,6 +352,9 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game. read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`. +- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library + filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row, + and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once. - The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions` (size of the set in the user's library, `null` below 2). 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 4d42e90..61e9a0d 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -109,7 +109,8 @@ public class LibraryController { @RequestParam(required = false) GameStatus status, @RequestParam(required = false) String q, @RequestParam(required = false) Integer maxMinutes, - @RequestParam(defaultValue = "false") boolean hidden + @RequestParam(defaultValue = "false") boolean hidden, + @RequestParam(defaultValue = "false") boolean hideDuplicates ) { Long userId = principal.getIdAsLong(); principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId)); @@ -117,7 +118,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, PageRequest.of(safePage, safeSize, safeSort)); + var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, PageRequest.of(safePage, safeSize, safeSort)); return ResponseEntity.ok(PageDto.of(dtoPage)); } diff --git a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java index aa9f4ee..1b33f61 100644 --- a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java +++ b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java @@ -30,7 +30,8 @@ public record UserStatsDto( Map byStatus) {} /** - * The pile is every game with status BACKLOG. Times to beat only count games IGDB knows. + * The pile is every game with status BACKLOG; a linked set counts once and is on the pile only when every owned + * edition is BACKLOG. Times to beat only count games IGDB knows. * * @param minutesPerWeek average playtime per week of the last 90 days * @param paceWeeks weeks to play the pile's normal time at that pace; null without playtime in the 90 days diff --git a/src/main/java/core_service/modules/library/domain/CountedUserGame.java b/src/main/java/core_service/modules/library/domain/CountedUserGame.java new file mode 100644 index 0000000..3775f6b --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CountedUserGame.java @@ -0,0 +1,40 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +/** + * The games a user's numbers count: read-only view, see V28. + */ +@Entity +@Immutable +@Table(name = "counted_user_game") +@IdClass(UserGameId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CountedUserGame { + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "appid") + private Long appid; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private GameStatus status; + + @Column(name = "playtime_minutes", nullable = false) + private int playtimeMinutes; +} diff --git a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java index 5ec4ab6..15601ef 100644 --- a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java +++ b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java @@ -11,7 +11,7 @@ import java.time.LocalDate; import java.util.List; /** - * The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}. + * The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}, and a linked set once (V28). */ @Repository @RequiredArgsConstructor @@ -22,7 +22,7 @@ public class BacklogSnapshotRepository { count(*) filter (where ug.playtime_minutes = 0) as never_played, coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes, count(*) as listed_games - from listed_user_game ug left join game_time_to_beat t on t.appid = ug.appid + from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid """; private final NamedParameterJdbcTemplate jdbc; 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 ec79b93..d9eaf78 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -74,11 +74,11 @@ public interface UserGameRepository extends JpaRepository @Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc") List findListedPlayedInTwoWeeks(@Param("userId") Long userId, Pageable pageable); - @Query("select count(ug) from UserGame ug where " + LISTED + "and ug.playtimeMinutes = 0") + @Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0") long countListedNeverPlayed(@Param("userId") Long userId); - @Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " + - "where " + LISTED + "group by ug.status") + @Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " + + "where c.userId = :userId group by c.status") List countByStatus(@Param("userId") Long userId); /** @@ -104,7 +104,7 @@ public interface UserGameRepository extends JpaRepository * * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing */ - @Query(value = "select * from listed_user_game where user_id = :userId and status = 'BACKLOG' " + + @Query(value = "select * from counted_user_game where user_id = :userId and status = 'BACKLOG' " + "and appid <> :excludeAppid " + // Weighted random pick: the smallest -ln(u) / weight wins. "order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " + @@ -136,7 +136,7 @@ public interface UserGameRepository extends JpaRepository return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count); } - @Query(value = "select ug.* from listed_user_game ug where ug.user_id = :userId " + + @Query(value = "select ug.* from counted_user_game ug where ug.user_id = :userId " + "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + @@ -158,6 +158,7 @@ public interface UserGameRepository extends JpaRepository * @param status null for every status * @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 */ @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + "where ug.userId = :userId and ug.removedAt is null " + @@ -165,20 +166,22 @@ public interface UserGameRepository extends JpaRepository "and (:status is null or ug.status = :status) " + "and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " + "and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " + - " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))") + " 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))") 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, Pageable pageable); /** * Normal time to beat of the user's backlog, only games IGDB has data for. */ - @Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " + - "where " + LISTED + - "and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG") + @Query("select coalesce(sum(t.normallyMinutes), 0) from CountedUserGame c join GameTimeToBeat t on t.appid = c.appid " + + "where c.userId = :userId and c.status = core_service.modules.library.domain.GameStatus.BACKLOG") long sumBacklogMinutes(@Param("userId") Long userId); /** diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 4cdada3..5287faa 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -50,7 +50,7 @@ public class UserStatsRepository { return jdbc.queryForObject(""" select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete, count(t.normally_minutes) as known - from listed_user_game ug join game_time_to_beat t on t.appid = ug.appid + from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid where %s and ug.status = 'BACKLOG' """.formatted(OF_USER), user(userId), (rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known"))); @@ -66,7 +66,7 @@ public class UserStatsRepository { public Optional oldestUnplayed(long userId) { return jdbc.query(""" select ug.appid, sg.name, sg.image_url, ug.first_seen_at - from listed_user_game ug join steam_game sg on sg.appid = ug.appid + from counted_user_game ug join steam_game sg on sg.appid = ug.appid where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 order by ug.first_seen_at, ug.appid limit 1 """.formatted(OF_USER), user(userId), @@ -148,7 +148,7 @@ public class UserStatsRepository { return jdbc.queryForObject(""" select count(*) filter (where ug.playtime_minutes > 0) as started, count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour - from listed_user_game ug where %s + from counted_user_game ug where %s """.formatted(OF_USER), user(userId), (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); } @@ -161,7 +161,7 @@ public class UserStatsRepository { return jdbc.query(""" select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone) as integer) as year, count(*) as games - from listed_user_game ug left join (%s) f on f.appid = ug.appid + from counted_user_game ug left join (%s) f on f.appid = ug.appid where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null group by year order by year """.formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()), @@ -170,7 +170,7 @@ public class UserStatsRepository { public long finishedBeforeTracking(long userId) { Long games = jdbc.queryForObject(""" - select count(*) from listed_user_game ug + select count(*) from counted_user_game ug where %s and ug.status in %s and ug.finished_at_estimate is null and not exists (select 1 from user_game_status_change sc where sc.user_id = ug.user_id and sc.appid = ug.appid and %s) @@ -190,7 +190,7 @@ public class UserStatsRepository { or sc.from_status is not null or sc.changed_at > uss.history_since)) as added, count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG') as done - from user_game_status_change sc join listed_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid + from user_game_status_change sc join counted_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid left join user_steam_sync uss on uss.user_id = sc.user_id where %s and sc.changed_at >= :monthStart """.formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)), 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 321f8c8..baf7e1c 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -87,11 +87,11 @@ public class LibraryQueryService { } public Page listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery, - Integer maxMinutes, Pageable pageable) { + Integer maxMinutes, boolean hideDuplicates, 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, stable); + userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, stable); List games = toDtos(page.getContent()); return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); } diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index c9046b1..4268e41 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -28,13 +28,13 @@ public class StatsRepository { public long countLibraryEntries() { Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class); + "SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class); return v != null ? v : 0L; } public long countGamesPlayed() { Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class); + "SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class); return v != null ? v : 0L; } @@ -53,7 +53,7 @@ public class StatsRepository { public long biggestLibrary() { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( - SELECT COUNT(*) AS c FROM listed_user_game + SELECT COUNT(*) AS c FROM counted_user_game WHERE %s GROUP BY user_id ) t """.formatted(NOT_DELETED_OR_BLOCKED), Long.class); @@ -63,7 +63,7 @@ public class StatsRepository { public long biggestBacklog() { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( - SELECT COUNT(*) AS c FROM listed_user_game + SELECT COUNT(*) AS c FROM counted_user_game WHERE %s AND playtime_minutes = 0 GROUP BY user_id ) t """.formatted(NOT_DELETED_OR_BLOCKED), Long.class); @@ -131,7 +131,7 @@ public class StatsRepository { return jdbc.queryForList(""" SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?) AS INTEGER) AS year, COUNT(*) AS games - FROM listed_user_game ug + FROM counted_user_game ug LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at FROM user_game_status_change sc WHERE sc.to_status IN ('FINISHED', 'COMPLETED') 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 73fa0ff..233f629 100644 --- a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -174,6 +174,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest { void link_ChangesEveryUsersNumbersAndShowsTheEditions() throws Exception { requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent()); + mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2)); linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); queue().andExpect(jsonPath("$.content", hasSize(0))); @@ -185,6 +186,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.editions[0].appid").value(GAME_2)); mockMvc.perform(as(get("/api/library/games"), user, Role.USER)).andExpect(status().isOk()) .andExpect(jsonPath("$.content[*].editions", hasItem(2))); + mockMvc.perform(as(get("/api/library/summary"), user, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.byStatus.BACKLOG").value(1)); } requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_LINKED")); linkAs(Role.AUDITOR, GAME_2, GAME).andExpect(status().isConflict()); @@ -267,4 +271,14 @@ class GameLinkFlowTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.content", hasSize(0))).andExpect(jsonPath("$.totalElements").value(1)) .andExpect(jsonPath("$.page").value(1)); } + + @Test + void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception { + linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); + + mockMvc.perform(as(get("/api/library/games?hideDuplicates=true"), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)); + mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(2)); + } } diff --git a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java index 3ddb5a7..6863de0 100644 --- a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java @@ -73,6 +73,16 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest { assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0)); } + @Test + void writeAll_ALinkedSetCountsOnce() { + userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), game(1L, 620L, 0, GameStatus.BACKLOG))); + jdbc.update("insert into game_link (appid, set_id) values (220, 1), (620, 1)"); + + repository.writeAll(DAY); + + assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600)); + } + @Test void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() { UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG); 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 5246004..cd06236 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Propagation; @@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private UserGameRepository userGameRepository; + @Autowired + private JdbcTemplate jdbc; + @Autowired private SteamGameRepository steamGameRepository; @@ -65,7 +69,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { } private List appids(GameStatus status, String query) { - return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE) + return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, PAGE) .map(UserGame::getAppid).getContent(); } @@ -194,7 +198,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Test void findOwnedGames_CountsAllMatchesAcrossPages() { - var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, + var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false, PageRequest.of(0, 1, Sort.by("appid"))); assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent()); @@ -202,7 +206,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { } private List sortedAppids(Sort sort) { - return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) + return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) .map(UserGame::getAppid).getContent(); } @@ -253,7 +257,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, PAGE).map(UserGame::getAppid).getContent(); + List shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, PAGE).map(UserGame::getAppid).getContent(); assertEquals(List.of(620L), shortGames); } @@ -280,7 +284,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, + assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID))); } @@ -345,6 +349,41 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { Set.copyOf(userGameRepository.countByStatus(USER_ID))); } + private void link(long... appids) { + jdbc.update("delete from game_link"); + for (long appid : appids) { + jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid); + } + } + + @Test + void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() { + link(380L, 700L); + userGameRepository.flush(); + + 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, + 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, + PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent()); + } + + @Test + void backlogEditionOfAPlayingSet_IsNeverPicked() { + link(220L, 380L); + userGameRepository.flush(); + + for (int i = 0; i < 20; i++) { + assertEquals(700L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid()); + } + assertEquals(List.of(700L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10) + .stream().map(UserGame::getAppid).toList()); + assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)), + Set.copyOf(userGameRepository.countByStatus(USER_ID))); + } + @Test void countNeverPlayed_CountsOnlyOwnActiveGamesWithoutPlaytime() { UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index 3fb6460..bb9d94d 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -94,6 +94,48 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1)); } + private void link(long... appids) { + for (long appid : appids) { + jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid); + } + } + + @Test + void pileTimes_ALinkedSetCountsOnce() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()))); + gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()), + new GameTimeToBeat(620L, 300, 500, 800, Instant.now()))); + link(220L, 620L); + + assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1)); + } + + @Test + void oldestUnplayed_LeavesOutABacklogEditionOfAPlayedSet() { + userGameRepository.saveAllAndFlush(List.of( + game(620L, 0, GameStatus.BACKLOG, Instant.EPOCH), + game(700L, 60, GameStatus.PLAYING, Instant.now()), + game(220L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z")))); + link(620L, 700L); + + assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(220L); + } + + @Test + void finishedPerYear_ALinkedSetCountsOnce() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.BACKLOG, Instant.now()), + game(620L, 300, GameStatus.BACKLOG, Instant.now()))); + setStatus(220L, "FINISHED"); + setStatus(620L, "FINISHED"); + link(220L, 620L); + + assertThat(repository.finishedPerYear(USER_ID, BERLIN)) + .containsExactly(new UserStats.YearCount(LocalDate.now(BERLIN).getYear(), 1)); + } + @Test void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() { userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now()))); diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index 8d5d84a..9d14d7c 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -66,6 +66,22 @@ class StatsRepositoryTest extends AbstractPersistenceTest { assertEquals(0, statsRepository.biggestBacklog()); } + @Test + void aLinkedSet_CountsOnceInLibraryBacklogAndFinishedPerYear() { + jdbc.update("insert into game_link (appid, set_id) values (220, 1), (1000, 1)"); + userGameRepository.saveAllAndFlush(List.of(new UserGame(5L, 220L, 0, Instant.now(), Instant.now()), + new UserGame(5L, 1000L, 0, Instant.now(), Instant.now()))); + + assertEquals(1, statsRepository.biggestLibrary()); + assertEquals(1, statsRepository.biggestBacklog()); + assertEquals(2, statsRepository.countDistinctGames()); + + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5"); + + assertEquals(1L, statsRepository.finishedPerYear("Europe/Berlin").stream() + .mapToLong(row -> ((Number) row.get("games")).longValue()).sum()); + } + @Test void hiddenGames_LeaveTheTopLists() { assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));