diff --git a/readme.md b/readme.md index 0dbc592..e2d95b9 100644 --- a/readme.md +++ b/readme.md @@ -51,7 +51,7 @@ To Be Played is split into three repositories: groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of 40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the - status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where 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). - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` @@ -129,11 +129,11 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | | `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT | -| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; none = all) | JWT | +| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT | | `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | -| `GET` | `/api/stats/public` | public | +| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token | @@ -180,7 +180,7 @@ flowchart LR Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] LibRepo --> Db[("PostgreSQL tbp_core")] - Stats -->|"listed_user_game, deleted_user, blocked_user"| Db + Stats -->|"listed_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 ``` @@ -196,7 +196,7 @@ 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`, `deleted_user` and `blocked_user`; `account` +SQL, which ArchUnit cannot see: `stats` reads `listed_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. 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 d3b804d..aa9f4ee 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 @@ -19,7 +19,8 @@ public record UserStatsDto( Taste taste, Habits habits, Finished finished, - Trend trend + Trend trend, + Platforms platforms ) { /** @@ -73,4 +74,10 @@ public record UserStatsDto( */ public record Trend(List days, UserStats.MonthMoves thisMonth) {} + /** + * @param perPlatform Steam games as "Steam", manual games by their platform (the spelling of the most games wins), + * most games first + */ + public record Platforms(List perPlatform) {} + } diff --git a/src/main/java/core_service/modules/library/domain/StatGroup.java b/src/main/java/core_service/modules/library/domain/StatGroup.java index 2d4091d..e8e1298 100644 --- a/src/main/java/core_service/modules/library/domain/StatGroup.java +++ b/src/main/java/core_service/modules/library/domain/StatGroup.java @@ -9,7 +9,7 @@ import java.util.Set; * the user shares. */ public enum StatGroup { - OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND; + OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND, PLATFORMS; /** * The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile 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 15afea8..4cdada3 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -3,6 +3,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.UserStats; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; @@ -40,6 +41,9 @@ public class UserStatsRepository { and ug.appid = p.appid and ug.hidden_at is not null) """; + private static final RowMapper TAG_COUNT = + (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")); + private final NamedParameterJdbcTemplate jdbc; public UserStats.PileTimes pileTimes(long userId) { @@ -113,8 +117,24 @@ public class UserStatsRepository { from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) where %s group by tag.name order by minutes desc, games desc, tag.name limit :limit - """.formatted(column, OF_USER), user(userId).addValue("limit", limit), - (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"))); + """.formatted(column, OF_USER), user(userId).addValue("limit", limit), TAG_COUNT); + } + + /** + * Games and playtime per platform: the name is trimmed and compared ignoring case, the label is the most used + * spelling (a tie: the first in alphabetical order). Steam games are "Steam", so a manual game with the platform + * "Steam" joins them. Most games first. + */ + public List gamesPerPlatform(long userId) { + return jdbc.query(""" + select mode() within group (order by g.name) as name, count(*) as games, + coalesce(sum(g.playtime_minutes), 0) as minutes + from (select case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name, + ug.playtime_minutes + from listed_user_game ug where %s) g + where g.name <> '' + group by lower(g.name) order by games desc, minutes desc, name + """.formatted(OF_USER), user(userId), TAG_COUNT); } public Optional longestDay(long userId) { diff --git a/src/main/java/core_service/modules/library/service/UserStatsService.java b/src/main/java/core_service/modules/library/service/UserStatsService.java index 3bf7fd9..b6a2d72 100644 --- a/src/main/java/core_service/modules/library/service/UserStatsService.java +++ b/src/main/java/core_service/modules/library/service/UserStatsService.java @@ -67,7 +67,8 @@ public class UserStatsService { groups.contains(StatGroup.TASTE) ? taste(userId) : null, groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null, groups.contains(StatGroup.FINISHED) ? finished(userId) : null, - groups.contains(StatGroup.TREND) ? trend(userId) : null); + groups.contains(StatGroup.TREND) ? trend(userId) : null, + groups.contains(StatGroup.PLATFORMS) ? platforms(userId) : null); } private UserStatsDto.Overview overview(long userId, Map byStatus, long playtime) { @@ -107,6 +108,10 @@ public class UserStatsService { userStatsRepository.finishedBeforeTracking(userId)); } + private UserStatsDto.Platforms platforms(long userId) { + return new UserStatsDto.Platforms(userStatsRepository.gamesPerPlatform(userId)); + } + private UserStatsDto.Trend trend(long userId) { LocalDate today = LocalDate.now(zone); List days = new ArrayList<>( diff --git a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java index d7ac363..cfc3a00 100644 --- a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java +++ b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java @@ -13,10 +13,17 @@ public record PublicStatsDto( long biggestLibrary, long biggestBacklog, List topOwnedGames, - List topPlayedGames + List topPlayedGames, + List gamesPerPlatform, + List topGenres, + List finishedPerYear ) { /** * @param imageUrl see GameDto */ public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {} + + public record NameCount(String name, long games, long minutes) {} + + public record YearCount(int year, long games) {} } 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 8ed17ff..c9046b1 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -93,4 +93,53 @@ public class StatsRepository { LIMIT ? """.formatted(NOT_DELETED_OR_BLOCKED), limit); } + + /** + * Per platform: the name is trimmed and compared ignoring case, the label is the most used spelling; Steam games + * are "Steam". Columns: name, games, minutes. + */ + public List> gamesPerPlatform() { + return jdbc.queryForList(""" + SELECT MODE() WITHIN GROUP (ORDER BY g.name) AS name, COUNT(*) AS games, + COALESCE(SUM(g.playtime_minutes), 0) AS minutes + FROM (SELECT CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name, playtime_minutes + FROM listed_user_game WHERE %s) g + WHERE g.name <> '' + GROUP BY LOWER(g.name) ORDER BY games DESC, minutes DESC, name + """.formatted(NOT_DELETED_OR_BLOCKED)); + } + + /** + * IGDB genres by playtime of all listed games. Columns: name, games, minutes. + */ + public List> topGenres(int limit) { + return jdbc.queryForList(""" + SELECT tag.name, COUNT(*) AS games, COALESCE(SUM(ug.playtime_minutes), 0) AS minutes + FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid + CROSS JOIN UNNEST(m.genres) AS tag(name) + WHERE %s + GROUP BY tag.name ORDER BY minutes DESC, games DESC, tag.name + LIMIT ? + """.formatted(NOT_DELETED_OR_BLOCKED), limit); + } + + /** + * Same rule as the Finished group of a user: games now FINISHED or COMPLETED, counted once in the year of their + * first move into FINISHED or COMPLETED; {@code finished_at_estimate} wins. Columns: year, games. + */ + public List> finishedPerYear(String zone) { + 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 + 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') + AND sc.from_status NOT IN ('FINISHED', 'COMPLETED') + GROUP BY sc.user_id, sc.appid) f ON f.uid = ug.user_id AND f.appid = ug.appid + WHERE %s AND ug.status IN ('FINISHED', 'COMPLETED') + AND COALESCE(ug.finished_at_estimate, f.finished_at) IS NOT NULL + GROUP BY year ORDER BY year + """.formatted(NOT_DELETED_OR_BLOCKED), zone); + } } diff --git a/src/main/java/core_service/modules/stats/service/PublicStatsService.java b/src/main/java/core_service/modules/stats/service/PublicStatsService.java index 2b5fb90..6d8506d 100644 --- a/src/main/java/core_service/modules/stats/service/PublicStatsService.java +++ b/src/main/java/core_service/modules/stats/service/PublicStatsService.java @@ -4,9 +4,11 @@ import core_service.modules.stats.api.dto.PublicStatsDto; import core_service.modules.stats.repo.StatsRepository; import core_service.shared.security.Public; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import java.time.ZoneId; import java.util.List; import java.util.Map; @@ -16,9 +18,13 @@ import java.util.Map; public class PublicStatsService { private static final int TOP_LIMIT = 5; + private static final int TOP_GENRES = 8; private final StatsRepository statsRepository; + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; + @Cacheable(value = "publicStats", key = "'global'") public PublicStatsDto getPublicStats() { long distinctGames = statsRepository.countDistinctGames(); @@ -45,7 +51,10 @@ public class PublicStatsService { biggestLibrary, biggestBacklog, topOwned, - topPlayed + topPlayed, + mapCounts(statsRepository.gamesPerPlatform()), + mapCounts(statsRepository.topGenres(TOP_GENRES)), + mapYears(statsRepository.finishedPerYear(zone.getId())) ); } @@ -60,4 +69,23 @@ public class PublicStatsService { )) .toList(); } + + private List mapCounts(List> rows) { + return rows.stream() + .map(r -> new PublicStatsDto.NameCount( + (String) r.get("name"), + ((Number) r.get("games")).longValue(), + ((Number) r.get("minutes")).longValue() + )) + .toList(); + } + + private List mapYears(List> rows) { + return rows.stream() + .map(r -> new PublicStatsDto.YearCount( + ((Number) r.get("year")).intValue(), + ((Number) r.get("games")).longValue() + )) + .toList(); + } } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java index eee64e7..d0ebff5 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java @@ -81,7 +81,9 @@ class LibraryControllerStatsTest extends AbstractMockedLibraryServicesTest { .andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620)) .andExpect(jsonPath("$.taste.genres").isArray()) .andExpect(jsonPath("$.habits.underOneHourGames").value(0)) - .andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1)); + .andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1)) + .andExpect(jsonPath("$.platforms.perPlatform[0].name").value("Steam")) + .andExpect(jsonPath("$.platforms.perPlatform[0].games").value(2)); verify(moodService).refreshListedInBackground(USER_ID); } 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 3dc1235..3fb6460 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -228,6 +228,26 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { new UserStats.YearCount(thisYear, 1)); } + @Test + void gamesPerPlatform_GroupsTrimmedIgnoringCase_LabelIsTheMostUsedSpelling() { + steamGameRepository.saveAllAndFlush(List.of( + new SteamGame(8001L, "A", null, Instant.now()), new SteamGame(8002L, "B", null, Instant.now()), + new SteamGame(8003L, "C", null, Instant.now()), new SteamGame(8004L, "D", null, Instant.now()))); + Instant now = Instant.now(); + userGameRepository.saveAllAndFlush(List.of( + game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now), + UserGame.manual(USER_ID, 8001L, "PS5", GameStatus.BACKLOG, 10, now), + UserGame.manual(USER_ID, 8002L, "PS5", GameStatus.BACKLOG, 20, now), + UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now), + UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now))); + seedHiddenAndRemoved(); + + assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly( + new UserStats.TagCount("PS5", 3, 60), + new UserStats.TagCount("Steam", 2, 150), + new UserStats.TagCount("Switch", 1, 5)); + } + @Test void finishedBeforeTracking_FinishedGamesThatCameInFinished() { userGameRepository.saveAllAndFlush(List.of( diff --git a/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java b/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java index f930ec4..cdbf3a7 100644 --- a/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java +++ b/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java @@ -58,6 +58,7 @@ class UserStatsServiceTest extends AbstractUnitTest { assertThat(stats.habits()).isNull(); assertThat(stats.finished()).isNull(); assertThat(stats.trend()).isNull(); + assertThat(stats.platforms()).isNull(); verifyNoInteractions(userGameRepository, backlogSnapshotRepository); } @@ -91,7 +92,7 @@ class UserStatsServiceTest extends AbstractUnitTest { void getStats_NoGroups_RunsNoQuery() { UserStatsDto stats = service.getStats(7L, Set.of()); - assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null)); + assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null, null)); verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository); } } 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 24b4c3c..8d5d84a 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -1,17 +1,24 @@ package core_service.modules.stats.repo; +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.GameMoodsRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.stats.api.dto.PublicStatsDto; +import core_service.modules.stats.service.PublicStatsService; 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 org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Map; @@ -29,6 +36,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest { @Autowired private UserGameRepository userGameRepository; + @Autowired + private GameMoodsRepository gameMoodsRepository; + @Autowired private JdbcTemplate jdbc; @@ -62,19 +72,98 @@ class StatsRepositoryTest extends AbstractPersistenceTest { assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5))); } + @Test + void gamesPerPlatform_GroupsIgnoringCase_LeavesDeletedUsersOut() { + steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()), + new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now()))); + userGameRepository.saveAllAndFlush(List.of( + UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()), + UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()), + UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now()))); + jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)"); + + List> rows = statsRepository.gamesPerPlatform(); + + assertEquals(List.of("PS5", "Steam"), rows.stream().map(row -> row.get("name")).toList()); + assertEquals(List.of(2L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList()); + assertEquals(30L, ((Number) rows.get(0).get("minutes")).longValue()); + } + + @Test + void topGenres_OrderByPlaytime_LeaveHiddenGamesOut() { + gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG", "Action"}, false, Instant.now()); + gameMoodsRepository.upsert(1000L, new String[]{}, new String[]{"Simulator"}, false, Instant.now()); + + List> rows = statsRepository.topGenres(8); + + assertEquals(List.of("Action", "RPG"), rows.stream().map(row -> row.get("name")).toList()); + assertEquals(300L, ((Number) rows.get(0).get("minutes")).longValue()); + } + + @Test + void finishedPerYear_CountsTheFirstMoveIntoFinished_EstimateWins() { + steamGameRepository.saveAllAndFlush(List.of(new SteamGame(620L, "Portal 2", null, Instant.now()), + new SteamGame(700L, "OJ", null, Instant.now()))); + UserGame estimated = new UserGame(1L, 620L, 0, Instant.now(), Instant.now()); + estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z")); + userGameRepository.saveAllAndFlush(List.of(estimated, new UserGame(1L, 700L, 0, Instant.now(), Instant.now()))); + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620"); + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 700"); + // 220 is saved as FINISHED without a move: no year. + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 220"); + jdbc.update("DELETE FROM user_game_status_change WHERE appid = 220"); + + List> rows = statsRepository.finishedPerYear("Europe/Berlin"); + + assertEquals(List.of(2025, LocalDate.now().getYear()), + rows.stream().map(row -> ((Number) row.get("year")).intValue()).toList()); + assertEquals(List.of(1L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList()); + } + private static List appids(List> rows) { return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList(); } + private void finishWithGenreForUser3() { + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 3 AND appid = 620"); + gameMoodsRepository.upsert(620L, new String[]{}, new String[]{"Horror"}, false, Instant.now()); + } + + private void assertLandingListsIgnoreUser3() { + assertEquals(List.of(), statsRepository.topGenres(8)); + assertEquals(List.of(), statsRepository.finishedPerYear("Europe/Berlin")); + } + + @Test + void publicStats_ReadEveryRepositoryKey() { + gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now()); + steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now())); + UserGame estimated = new UserGame(1L, 620L, 0, Instant.now(), Instant.now()); + estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z")); + userGameRepository.saveAndFlush(estimated); + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620"); + PublicStatsService service = new PublicStatsService(statsRepository); + ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin")); + + PublicStatsDto dto = service.getPublicStats(); + + assertEquals("Steam", dto.gamesPerPlatform().get(0).name()); + assertEquals(300L, dto.gamesPerPlatform().get(0).minutes()); + assertEquals("RPG", dto.topGenres().get(0).name()); + assertEquals(2025, dto.finishedPerYear().get(0).year()); + } + @Test void deletedUsers_LeaveEveryNumberAndTheTopLists() { steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now())); userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()), new UserGame(3L, 220L, 0, Instant.now(), Instant.now()))); + finishWithGenreForUser3(); jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)"); hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveTheTopLists(); + assertLandingListsIgnoreUser3(); } @Test @@ -82,9 +171,11 @@ class StatsRepositoryTest extends AbstractPersistenceTest { steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now())); userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()), new UserGame(3L, 220L, 0, Instant.now(), Instant.now()))); + finishWithGenreForUser3(); jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)"); hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveTheTopLists(); + assertLandingListsIgnoreUser3(); } }