feat(library): show one entry per game in every list

Gitea #34 slice 2 of 3. Lists and stats count a set once.
V43 views map an entry to its editions and platforms.
Remove the hideDuplicates param; GameDto gets platforms[].
This commit is contained in:
lobo committed 2026-10-09 11:31:11 +02:00
1 parent 82d3b59165
commit 413381d404
23 files changed
+676 -87

No files matched your search

@@ -279,13 +279,26 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
}
@Test
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception {
void games_ASetIsOneRowWithThePlatformsOfAllItsEditions() throws Exception {
jdbc.update("update user_game set source = 'PSN', platform = 'PS5' where user_id = ? and appid = ?", USER_A, GAME_2);
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));
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].editions").value(2))
.andExpect(jsonPath("$.content[0].platforms", hasSize(2)))
.andExpect(jsonPath("$.content[0].platforms[0].source").value("PSN"))
.andExpect(jsonPath("$.content[0].platforms[0].platform").value("PS5"))
.andExpect(jsonPath("$.content[0].platforms[1].source").value("MANUAL"))
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
}
@Test
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games?hideDuplicates=false"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
}
private void setSource(long appid, GameSource source) {
@@ -301,10 +314,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
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=PSN").andExpect(jsonPath("$.totalElements").value(1));
games("?source=STEAM").andExpect(jsonPath("$.totalElements").value(0));
games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(2));
games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(1));
}
@Test
@@ -426,7 +438,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception {
setSource(GAME_2, GameSource.PSN);
gameEditionService.linkSameGames(null);
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2));
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
splitAs(USER_A, GAME).andExpect(status().isNoContent());
@@ -75,6 +75,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set");
jdbc.update("delete from game_link");
}
private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) {
@@ -146,6 +147,19 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
}
@Test
void summary_TwoPerfectEditionsOfASetCountAsOnePerfectGame() throws Exception {
game(10L, 10, 10, true);
game(20L, 4, 4, true);
game(30L, 4, 4, true);
jdbc.update("insert into game_link (appid, set_id) values (10, 7001), (20, 7001)");
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.startedGames").value(2))
.andExpect(jsonPath("$.perfectGames").value(2));
}
@Test
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
game(10L, null, null, true);
@@ -53,6 +53,7 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
void seed() {
userGameRepository.deleteAll();
jdbc.update("delete from user_game_playtime_day");
jdbc.update("delete from game_link where set_id = 980001");
SteamGame repairedPortal = new SteamGame(620L, "Portal 2", null, REPAIRED_COVER, Instant.now());
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -90,6 +91,19 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
}
@Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
jdbc.update("insert into game_link (appid, set_id) values (220, 980001), (700, 980001)");
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.recentGames", hasSize(2)))
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
.andExpect(jsonPath("$.recentGames[1].appid").value(220))
.andExpect(jsonPath("$.recentGames[1].playtime2Weeks").value(80));
}
@Test
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30)));
@@ -76,6 +76,7 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
jdbc.update("delete from account_name where user_id = ?", id);
}
jdbc.update("delete from follow where user_id = ?", USER_ID);
jdbc.update("delete from game_link where set_id = 960001");
gameReleaseRepository.deleteAll();
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
@@ -124,6 +125,17 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
return ROW + ".games[?(@.appid == " + appid + ")].reason." + field;
}
@Test
void friends_ThePlayStationEditionOfAFriendMatchesTheSteamEditionOfTheCaller() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, -10L, GameStatus.FINISHED, 100);
jdbc.update("insert into game_link (appid, set_id) values (10, 960001), (-10, 960001)");
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)))
.andExpect(jsonPath(reason(10, "friendName"), contains("Max")));
}
@Test
void friends_FinishedAndPlayedGames_WithReason() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
@@ -93,6 +93,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
jdbc.update("delete from game_link where set_id = 980002");
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
@@ -114,6 +115,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
new UserGame(99L, 220L, 5000, Instant.now(), Instant.now())));
}
@Test
void publicLibrary_ListsASetOnce() throws Exception {
jdbc.update("insert into game_link (appid, set_id) values (220, 980002), (700, 980002)");
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalGames").value(2))
.andExpect(jsonPath("$.playing", hasSize(0)))
.andExpect(jsonPath("$.finished", hasSize(2)));
}
@Test
void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
@@ -92,9 +92,9 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
count("PILE", null, "/pile/backlogGames"),
new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes",
s -> s.at("/pile/normalMinutes").asLong()),
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "playtimeMinutes",
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "minutes",
s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")),
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "playtimeMinutes",
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "minutes",
s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")),
new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()),
count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"),
@@ -0,0 +1,120 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GameEntryViewTest extends AbstractPersistenceTest {
private static final long USER = 1L;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_link");
jdbc.update("delete from user_game");
for (long appid : List.of(-10L, 10L, 20L, 30L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void set(long appid, long setId) {
jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId);
}
private void own(long appid, String source, String platform, int playtime) {
jdbc.update("insert into user_game (user_id, appid, source, platform, playtime_minutes, status) values (?, ?, ?, ?, ?, 'BACKLOG')",
USER, appid, source, platform, playtime);
}
private void title(String key, String state, long appid) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, ?, ?, ?)", USER, key, state, appid);
}
private List<String> members() {
return jdbc.queryForList("select appid || '>' || member_appid from user_game_entry_member where user_id = ? "
+ "order by appid, member_appid", String.class, USER);
}
private List<String> platforms(long appid) {
return jdbc.queryForList("select member_appid || ':' || source || ':' || platform from user_game_entry_platform "
+ "where user_id = ? and appid = ? order by member_appid, source", String.class, USER, appid);
}
@Test
void member_EntryHasItselfAndItsListedEditions() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
own(20, "STEAM", null, 5);
set(10, 1);
set(-10, 1);
assertThat(members()).containsExactly("10>-10", "10>10", "20>20");
}
@Test
void member_LeavesOutAHiddenEdition() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
jdbc.update("update user_game set hidden_at = now() where appid = -10");
assertThat(members()).containsExactly("10>10");
}
@Test
void member_ASplitEditionIsItsOwnEntry() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
jdbc.update("insert into user_game_split (user_id, appid) values (?, -10)", USER);
assertThat(members()).containsExactly("-10>-10", "10>10");
}
@Test
void platform_ASteamOwnedPsnTitleGivesAPsnPlatformOnTheSteamRow() {
own(10, "STEAM", null, 600);
title("c1", "STEAM_OWNED", 10);
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
}
@Test
void platform_TwoTitlesOnOneKeyGiveOnePsnRow() {
own(10, "STEAM", null, 600);
title("c1", "STEAM_OWNED", 10);
title("c2", "STEAM_OWNED", 10);
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
}
@Test
void platform_ASetListsEveryEditionWithItsPlatform() {
own(10, "STEAM", null, 600);
own(-10, "PSN", "PS5", 60);
set(10, 1);
set(-10, 1);
assertThat(platforms(10)).containsExactly("-10:PSN:PS5", "10:STEAM:");
}
@Test
void platform_ADismissedTitleGivesNoPlatform() {
own(10, "STEAM", null, 600);
title("c1", "DISMISSED", 10);
assertThat(platforms(10)).containsExactly("10:STEAM:");
}
}
@@ -30,7 +30,7 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
@@ -254,6 +254,47 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.linkedEditions(USER, 100)).isEmpty();
}
private void ownOn(long appid, String source, String platform) {
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', ?, ?)",
USER, appid, source, platform);
}
@Test
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
repository.link(100, 200);
repository.link(100, -7);
ownOn(100, "STEAM", null);
ownOn(200, "PSN", "PS5");
ownOn(-7, "MANUAL", "Switch");
jdbc.update("update user_game set playtime_minutes = 500 where appid = 100");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
var platforms = repository.entryPlatforms(USER, List.of(100L, 300L));
assertThat(platforms.keySet()).containsExactly(100L);
assertThat(platforms.get(100L)).extracting(p -> p.source() + ":" + p.platform())
.containsExactly("STEAM:null", "PSN:PS5", "MANUAL:Switch");
assertThat(repository.entryPlatforms(USER, List.of())).isEmpty();
}
@Test
void entryPlatforms_ASteamRowWithAPlayStationTitleHasBothPlatforms() {
ownOn(100, "STEAM", null);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L))
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("STEAM:null", "PSN:null");
}
@Test
void entryPlatforms_AManualPlayStationRowWithALinkedTitleIsOnePlatform() {
ownOn(-7, "MANUAL", "PS5");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', -7)", USER);
assertThat(repository.entryPlatforms(USER, List.of(-7L)).get(-7L))
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("MANUAL:PS5");
}
@Test
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
repository.link(100, 200);
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.RecentEntry;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -81,7 +82,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, ALL_SOURCES, PAGE)
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, ALL_SOURCES, PAGE)
.map(UserGame::getAppid).getContent();
}
@@ -210,7 +211,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false, ALL_SOURCES,
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, ALL_SOURCES,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -218,7 +219,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
}
@@ -269,7 +270,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent();
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@@ -296,7 +297,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, ALL_SOURCES,
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
}
@@ -317,8 +318,8 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(RecentEntry::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
@@ -370,7 +371,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appidsOfSources(GameSource... sources) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, Set.of(sources),
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, Set.of(sources),
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
}
@@ -421,19 +422,99 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
@Test
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
void linkedBacklogEditions_CountOnceAndTheListKeepsOne() {
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, 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, ALL_SOURCES,
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
}
private List<Long> listed(boolean hidden, String query) {
return userGameRepository.findOwnedGames(USER_ID, hidden, null, UserGameRepository.containsPattern(query), null,
ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
}
@Test
void findOwnedGames_ASetIsOneRow_TheCountedEdition() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L, 620L, 700L), listed(false, null));
}
@Test
void findOwnedGames_TheNameOfTheLesserEditionFindsTheEntry() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L), listed(false, "episode"));
}
@Test
void findOwnedGames_BySource_FindsASteamRowWithAPlayStationTitle() {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 620)", USER_ID);
assertEquals(List.of(620L), appidsOfSources(GameSource.PSN));
}
@Test
void findOwnedGames_BySource_FindsAnEntryThroughItsLesserEdition() {
link(220L, 380L);
setSource(GameSource.PSN, 380L);
assertEquals(List.of(220L), appidsOfSources(GameSource.PSN));
}
@Test
void findOwnedGames_Hidden_ListsEveryHiddenRow() {
link(220L, 380L);
hide(220L, 380L);
assertEquals(List.of(220L, 380L), listed(true, null));
assertEquals(List.of(380L), listed(true, "episode"));
}
@Test
void findEntriesPlayedInTwoWeeks_SumsTheSetAndKeepsAnEntryPlayedOnlyOnTheLesserEdition() {
link(220L, 380L);
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setPlaytime2Weeks(40);
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(50);
userGameRepository.flush();
var recent = userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20);
assertEquals(List.of(220L, 620L), recent.stream().map(RecentEntry::getAppid).toList());
assertEquals(70L, recent.getFirst().getPlaytime2Weeks());
assertEquals(300L, recent.getFirst().getPlaytimeMinutes());
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(0);
userGameRepository.flush();
assertEquals(List.of(620L, 220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
.stream().map(RecentEntry::getAppid).toList());
}
@Test
void countListed_CountsASetOnce() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(3, userGameRepository.countListed(USER_ID));
}
@Test
void findListedByStatusIn_ListsASetOnce() {
link(220L, 380L);
userGameRepository.flush();
assertEquals(List.of(220L, 620L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).sorted().toList());
}
@Test
void backlogEditionOfAPlayingSet_IsNeverPicked() {
link(220L, 380L);
@@ -374,6 +374,81 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1);
}
private void seedSet() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now()),
game(700L, 500, GameStatus.PLAYING, Instant.now())));
link(220L, 620L);
}
@Test
void mostPlayed_ASetIsOneRowWithTheSumOfItsEditions() {
seedSet();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::minutes)
.containsExactly(1200L, 500L);
assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
}
@Test
void mostLoyal_CountsTheMonthsOfEveryEditionOfASet() {
seedSet();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(620L, LocalDate.of(2026, 8, 1), 10);
addDay(620L, LocalDate.of(2026, 9, 1), 10);
addDay(700L, LocalDate.of(2026, 7, 3), 30);
addDay(700L, LocalDate.of(2026, 8, 3), 30);
UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow();
assertThat(loyal.months()).isEqualTo(3);
assertThat(loyal.minutes()).isEqualTo(30);
}
@Test
void genresAndMoods_CountASetOnceWithTheSummedMinutes() {
seedSet();
for (long appid : List.of(220L, 620L, 700L)) {
gameMoodsRepository.upsert(appid, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now());
}
assertThat(repository.genres(USER_ID, 8)).containsExactly(new UserStats.TagCount("Shooter", 2, 1700));
assertThat(repository.moods(USER_ID)).containsExactly(new UserStats.TagCount("ACTION", 2, 1700));
assertThat(repository.countGames(USER_ID, StatKey.GENRE, "Shooter", BERLIN)).isEqualTo(2);
assertThat(repository.games(USER_ID, StatKey.GENRE, "Shooter", BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
}
@Test
void games_PerfectListsASetOnce() {
seedSet();
assertThat(repository.countGames(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
}
@Test
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
seedSet();
LocalDate day = LocalDate.of(2026, 7, 1);
addDay(220L, day, 100);
addDay(620L, day, 100);
assertThat(repository.countGames(USER_ID, StatKey.LONGEST_DAY, day, BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(200L);
}
@Test
void platformStat_ASetCountsOnEveryPlatformItHas() {
seedSet();
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(new UserStats.TagCount("Steam", 3, 1700));
assertThat(repository.games(USER_ID, StatKey.PLATFORM, "Steam", BERLIN, 0, 10)).hasSize(3);
}
private void comingTomorrow(long appid) {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
Date.valueOf(LocalDate.now(BERLIN).plusDays(1)));