feat(library): add stats page data and pile trend
Build, Test & Push Core Service / quality-check (push) Successful in 1m0s
Build, Test & Push Core Service / docker (push) Successful in 1m18s

Log every status change with a trigger, so no code path can miss it.
Store IGDB genres with the moods. Write a pile snapshot per user each
night. The sync moves an untouched pile game with new playtime to
PLAYING in one guarded update, so a status the user picks wins.
Old finished games get their year from the ending achievement.
GET /api/library/stats serves the groups; the public profile shows
only the groups in the profile ticket.
This commit is contained in:
lobo committed 2026-09-27 07:16:05 +02:00
1 parent b959542e23
commit 0fa2a37f47
48 files changed
+2069 -60

No files matched your search

@@ -1,5 +1,6 @@
package core_service.configuration.security;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts;
@@ -13,7 +14,9 @@ import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -102,7 +105,17 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(42L), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
assertEquals(Optional.of(new ProfileTicket(42L, Set.of())),
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
}
@Test
void verifyProfileTicket_ReadsTheSharedStatGroups() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
.claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"))), jwtService.verifyProfileTicket(ticket));
}
@Test
@@ -91,7 +91,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, ENDING_GAME, Instant.now(), true, 1, 600));
gameTimeToBeatRepository.save(new GameTimeToBeat(MANUAL_GAME, null, 600, null, Instant.now()));
gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, new String[0], false, Instant.now());
}
private UserGame steamGame(boolean statusSetByUser) {
@@ -93,9 +93,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
game(40L, GameStatus.FINISHED, null, null);
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, false, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(status().isOk())
@@ -124,8 +124,8 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 3000);
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
@@ -0,0 +1,107 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerStatsTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
// The same mocks as LibraryControllerPickerTest, so both share one Spring context.
@MockitoBean
private LibrarySyncService syncService;
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now())));
UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now());
finished.setStatus(GameStatus.FINISHED);
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished));
gameTimeToBeatRepository.save(new GameTimeToBeat(220L, 500, 700, 900, Instant.now()));
}
@Test
void stats_NoGroups_HasEveryGroup() throws Exception {
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.overview.totalGames").value(2))
.andExpect(jsonPath("$.overview.neverStarted").value(1))
.andExpect(jsonPath("$.overview.byStatus.DROPPED").value(0))
.andExpect(jsonPath("$.pile.backlogGames").value(1))
.andExpect(jsonPath("$.pile.normalMinutes").value(700))
.andExpect(jsonPath("$.pile.paceWeeks").value(nullValue()))
.andExpect(jsonPath("$.pile.oldestUnplayed.name").value("Half-Life 2"))
.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));
verify(moodService).refreshListedInBackground(USER_ID);
}
@Test
void stats_Groups_OnlyThose() throws Exception {
mockMvc.perform(get("/api/library/stats").param("groups", "PILE,HABITS"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pile.backlogGames").value(1))
.andExpect(jsonPath("$.habits").exists())
.andExpect(jsonPath("$.overview").value(nullValue()))
.andExpect(jsonPath("$.taste").value(nullValue()));
verify(moodService, never()).refreshListedInBackground(USER_ID);
}
@Test
void stats_UnknownGroup_Is400() throws Exception {
mockMvc.perform(get("/api/library/stats").param("groups", "MONEY"))
.andExpect(status().isBadRequest());
}
}
@@ -23,6 +23,7 @@ import java.util.Date;
import java.util.List;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -52,6 +53,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private static String ticket(long userId, List<String> profileStats) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats)
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
@@ -114,6 +121,27 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.averageCompletion").value(62.5));
}
@Test
void publicLibrary_TicketWithoutStatGroups_HasNoStats() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats").value(nullValue()));
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID, List.of())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats").value(nullValue()));
}
@Test
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES", "OVERVIEW", "MONEY"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].name").value("Portal 2"))
.andExpect(jsonPath("$.stats.overview").value(nullValue()))
.andExpect(jsonPath("$.stats.pile").value(nullValue()))
.andExpect(jsonPath("$.stats.trend").value(nullValue()));
}
@Test
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
@@ -0,0 +1,42 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
class StatFormulasTest extends AbstractUnitTest {
@Test
void workYears_Are2080HoursEach() {
assertThat(StatFormulas.workYears(2080 * 60 * 3L)).isEqualTo(3.0, within(1e-9));
}
@Test
void moonTrips_Are195Point3HoursEach() {
assertThat(StatFormulas.moonTrips(Math.round(195.3 * 60 * 2))).isEqualTo(2.0, within(1e-9));
}
@Test
void minutesPerWeek_IsTheAverageOfThe90Days() {
assertThat(StatFormulas.minutesPerWeek(90 * 60)).isEqualTo(420);
}
@Test
void paceWeeks_PileDividedByTheWeeklyPlaytime() {
// 420 minutes a week, 4200 minutes of pile.
assertThat(StatFormulas.paceWeeks(4200, 90 * 60)).isEqualTo(10.0, within(1e-9));
}
@Test
void paceWeeks_NoPlaytime_IsNull() {
assertThat(StatFormulas.paceWeeks(4200, 0)).isNull();
}
@Test
void share_NoWhole_IsNull() {
assertThat(StatFormulas.share(0, 0)).isNull();
assertThat(StatFormulas.share(1, 4)).isEqualTo(0.25);
}
}
@@ -0,0 +1,19 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class StatGroupTest extends AbstractUnitTest {
@Test
void shared_LeavesOutOverviewAndUnknownNames() {
assertThat(StatGroup.shared(List.of("PILE", "OVERVIEW", "MONEY", "TREND")))
.isEqualTo(Set.of(StatGroup.PILE, StatGroup.TREND));
assertThat(StatGroup.shared(List.of())).isEmpty();
}
}
@@ -0,0 +1,106 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserStats;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(BacklogSnapshotRepository.class)
class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
private static final LocalDate DAY = LocalDate.of(2026, 9, 27);
@Autowired
private BacklogSnapshotRepository repository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
// V16 wrote a snapshot of the migration day; the tests start empty.
jdbc.update("delete from backlog_snapshot");
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(1000L, "Playtest", null, Instant.now()),
new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now())));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 1, 600, 1, Instant.now()),
new GameTimeToBeat(1000L, 1, 9999, 1, Instant.now())));
}
private UserGame game(long userId, long appid, int playtime, GameStatus status) {
UserGame game = new UserGame(userId, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
return game;
}
@Test
void writeAll_CountsTheListedGamesOfEveryUser() {
UserGame hidden = game(1L, 1000L, 0, GameStatus.BACKLOG);
hidden.setHiddenAt(Instant.now());
UserGame removed = game(1L, 620L, 0, GameStatus.BACKLOG);
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(
game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed,
UserGame.manual(2L, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())));
assertThat(repository.writeAll(DAY)).isEqualTo(2);
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600));
assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0));
}
@Test
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
userGameRepository.saveAndFlush(game);
repository.writeAll(DAY);
jdbc.update("update user_game set status = 'PLAYING', playtime_minutes = 30 where appid = 220");
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 0, 0));
}
@Test
void findSince_LeavesOutOlderDays() {
userGameRepository.saveAndFlush(game(1L, 220L, 0, GameStatus.BACKLOG));
repository.writeAll(DAY.minusDays(100));
repository.writeAll(DAY.minusDays(1));
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY.minusDays(89))).extracting(UserStats.PileDay::day)
.containsExactly(DAY.minusDays(1), DAY);
}
@Test
void live_IsThePileOfNow() {
userGameRepository.saveAndFlush(game(1L, 220L, 0, GameStatus.BACKLOG));
assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600));
assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0));
}
}
@@ -28,30 +28,41 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
@Test
void upsert_StoresTheEndlessFlag() {
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, true, Instant.now());
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, new String[0], true, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isTrue();
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, false, Instant.now());
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, new String[0], false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isFalse();
}
@Test
void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, false, Instant.now());
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, new String[0], false, Instant.now());
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP");
gameMoodsRepository.upsert(620L, new String[0], false, Instant.now());
gameMoodsRepository.upsert(620L, new String[0], new String[0], false, Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty();
}
@Test
void upsert_StoresTheGenresAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[0], new String[]{"Platform", "Puzzle"}, false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getGenres()).containsExactly("Platform", "Puzzle");
gameMoodsRepository.upsert(620L, new String[0], new String[]{"Puzzle"}, false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getGenres()).containsExactly("Puzzle");
}
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, false, Instant.now().minus(Duration.ofDays(40)));
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, new String[0], false, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
@@ -0,0 +1,111 @@
package core_service.modules.library.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.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The trigger of V14 that writes user_game_status_change.
*/
class StatusChangeLogTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long APPID = 620L;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void seed() {
steamGameRepository.save(new SteamGame(APPID, "Portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
}
/** Each row as "from->to by_user". */
private List<String> log() {
entityManager.flush();
@SuppressWarnings("unchecked")
List<Object[]> rows = entityManager.createNativeQuery(
"select from_status, to_status, by_user from user_game_status_change order by id").getResultList();
return rows.stream().map(row -> row[0] + "->" + row[1] + " " + row[2]).toList();
}
private UserGame game(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
@Test
void syncInsert_LogsTheFirstStatusNotByUser() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
assertThat(log()).containsExactly("null->BACKLOG false");
}
@Test
void manualAdd_LogsTheStatusByUser() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
assertThat(log()).containsExactly("null->PLAYING true");
}
@Test
void statusChangedByUser_IsLoggedWithTheOldStatus() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
entityManager.flush();
UserGame game = game(APPID);
game.setStatus(GameStatus.DROPPED);
game.setStatusSetByUser(true);
assertThat(log()).containsExactly("null->BACKLOG false", "BACKLOG->DROPPED true");
}
@Test
void otherFieldsOrTheSameStatus_LogNothing() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
entityManager.flush();
UserGame game = game(APPID);
game.setStatus(GameStatus.BACKLOG);
game.setStatusSetByUser(true);
game.setRating(4);
game.setNotes("later");
assertThat(log()).containsExactly("null->BACKLOG false");
}
@Test
void finishIfUntouched_BulkUpdateIsLoggedNotByUser() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
entityManager.flush();
assertThat(userGameRepository.finishIfUntouched(USER_ID, APPID)).isEqualTo(1);
assertThat(log()).containsExactly("null->BACKLOG false", "BACKLOG->FINISHED false");
}
@Test
void deletedGame_TakesItsLogRowsAlong() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
entityManager.flush();
userGameRepository.delete(game(-1942L));
assertThat(log()).isEmpty();
}
}
@@ -9,6 +9,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -45,6 +46,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private EntityManager entityManager;
@BeforeEach
void seed() {
steamGameRepository.saveAll(List.of(
@@ -65,6 +69,37 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
.map(UserGame::getAppid).getContent();
}
@Test
void playFromPileIfUntouched_OnlyUntouchedListedBacklogGames() {
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG);
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setStatusSetByUser(true);
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.flush();
int changed = userGameRepository.playFromPileIfUntouched(USER_ID, List.of(220L, 380L, 620L, 700L));
entityManager.clear();
assertEquals(1, changed);
assertEquals(GameStatus.PLAYING, status(220L));
assertEquals(GameStatus.BACKLOG, status(380L));
assertEquals(GameStatus.PLAYING, status(620L));
assertEquals(GameStatus.BACKLOG, status(700L));
}
@Test
void playFromPileIfUntouched_AStatusTheUserChoseWins() {
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow();
game.setStatus(GameStatus.FINISHED);
game.setStatusSetByUser(true);
userGameRepository.flush();
assertEquals(0, userGameRepository.playFromPileIfUntouched(USER_ID, List.of(380L)));
}
private GameStatus status(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().getStatus();
}
@Test
void softDeleteMissingSince_KeepsGamesAddedByHand() {
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
@@ -0,0 +1,300 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserStats;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(UserStatsRepository.class)
class UserStatsRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long OTHER_USER = 2L;
private static final long HIDDEN = 1000L;
private static final long REMOVED = 2000L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
private UserStatsRepository repository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(700L, "100% Orange Juice", null, Instant.now()),
new SteamGame(HIDDEN, "Playtest", null, Instant.now()),
new SteamGame(REMOVED, "Refunded", null, Instant.now())));
}
private UserGame game(long appid, int playtime, GameStatus status, Instant firstSeenAt) {
UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, firstSeenAt);
game.setStatus(status);
return game;
}
/** A hidden and a removed game that must never count, with the biggest numbers of all. */
private void seedHiddenAndRemoved() {
UserGame hidden = game(HIDDEN, 90_000, GameStatus.BACKLOG, Instant.EPOCH);
hidden.setHiddenAt(Instant.now());
UserGame removed = game(REMOVED, 0, GameStatus.BACKLOG, Instant.EPOCH);
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(hidden, removed));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(HIDDEN, 1, 99_999, 99_999, Instant.now()),
new GameTimeToBeat(REMOVED, 1, 99_999, 99_999, Instant.now())));
gameMoodsRepository.upsert(HIDDEN, new String[]{"CHILL"}, new String[]{"Simulator"}, true, Instant.now());
addDay(HIDDEN, LocalDate.now(BERLIN), 5000);
}
private void addDay(long appid, LocalDate day, int minutes) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, ?, ?)",
USER_ID, day, appid, minutes);
}
@Test
void pileTimes_SumsTheListedBacklogOnly() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.FINISHED, Instant.now()),
game(700L, 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())));
seedHiddenAndRemoved();
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
}
@Test
void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() {
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
LocalDate today = LocalDate.now(BERLIN);
addDay(220L, today, 60);
addDay(220L, today.minusDays(89), 30);
addDay(220L, today.minusDays(90), 1000);
assertThat(repository.minutesPlayedSince(USER_ID, today.minusDays(89))).isEqualTo(90);
}
@Test
void oldestUnplayed_IsTheFirstSeenUnplayedPileGame_LowestAppidOnATie() {
Instant firstSync = Instant.parse("2026-01-01T00:00:00Z");
userGameRepository.saveAllAndFlush(List.of(
game(700L, 0, GameStatus.BACKLOG, firstSync),
game(620L, 0, GameStatus.BACKLOG, firstSync),
game(220L, 10, GameStatus.BACKLOG, Instant.EPOCH)));
seedHiddenAndRemoved();
UserStats.OldestGame oldest = repository.oldestUnplayed(USER_ID).orElseThrow();
assertThat(oldest.appid()).isEqualTo(620L);
assertThat(oldest.name()).isEqualTo("Portal 2");
assertThat(oldest.inLibrarySince()).isEqualTo(firstSync);
}
@Test
void mostPlayed_OrdersByPlaytimeAndLeavesOutUnplayedAndHidden() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.FINISHED, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
seedHiddenAndRemoved();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::appid)
.containsExactly(620L, 220L);
}
@Test
void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now()),
game(700L, 900, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(220L, LocalDate.of(2026, 8, 1), 10);
addDay(620L, LocalDate.of(2026, 7, 1), 500);
addDay(620L, LocalDate.of(2026, 7, 2), 500);
addDay(700L, LocalDate.of(2026, 7, 3), 30);
addDay(700L, LocalDate.of(2026, 9, 3), 30);
UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow();
assertThat(loyal.appid()).isEqualTo(700L);
assertThat(loyal.months()).isEqualTo(2);
assertThat(loyal.minutes()).isEqualTo(60);
}
@Test
void mostLoyal_ARemovedGameIsNoCandidate() {
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(REMOVED, LocalDate.of(2026, 7, 1), 10);
addDay(REMOVED, LocalDate.of(2026, 8, 1), 10);
assertThat(repository.mostLoyal(USER_ID)).map(UserStats.LoyalGame::appid).contains(220L);
}
@Test
void genresAndMoods_CountGamesAndPlaytimePerTag() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.FINISHED, Instant.now())));
seedHiddenAndRemoved();
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now());
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, new String[]{"Platform", "Shooter"}, false,
Instant.now());
assertThat(repository.genres(USER_ID, 8)).containsExactly(
new UserStats.TagCount("Shooter", 2, 1200),
new UserStats.TagCount("Platform", 1, 900));
assertThat(repository.moods(USER_ID)).containsExactly(
new UserStats.TagCount("CHILL", 1, 900),
new UserStats.TagCount("COOP", 1, 900),
new UserStats.TagCount("ACTION", 1, 300));
}
@Test
void longestDay_SumsAllGamesOfTheDay() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
addDay(220L, LocalDate.of(2026, 7, 1), 100);
addDay(620L, LocalDate.of(2026, 7, 1), 100);
addDay(620L, LocalDate.of(2026, 7, 2), 150);
assertThat(repository.longestDay(USER_ID)).contains(new DayMinutes(LocalDate.of(2026, 7, 1), 200));
}
@Test
void playedCounts_UnderOneHourIsOneTo59Minutes() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 59, GameStatus.PLAYING, Instant.now()),
game(620L, 60, GameStatus.PLAYING, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
seedHiddenAndRemoved();
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1));
}
@Test
void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.BACKLOG, Instant.now()),
game(620L, 300, GameStatus.BACKLOG, Instant.now()),
game(700L, 300, GameStatus.BACKLOG, Instant.now())));
// 23:30 UTC on New Year's Eve is already the next year in Berlin.
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user, changed_at) " +
"values (?, 220, 'BACKLOG', 'FINISHED', true, timestamptz '2024-12-31 23:30:00+00')", USER_ID);
setStatus(220L, "FINISHED");
setStatus(620L, "FINISHED");
setStatus(620L, "COMPLETED");
setStatus(700L, "FINISHED");
setStatus(700L, "PLAYING");
int thisYear = LocalDate.now(BERLIN).getYear();
assertThat(repository.finishedPerYear(USER_ID, BERLIN)).containsExactly(
new UserStats.YearCount(2025, 1),
new UserStats.YearCount(thisYear, 1));
}
@Test
void finishedBeforeTracking_FinishedGamesThatCameInFinished() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.FINISHED, Instant.now()),
game(620L, 300, GameStatus.BACKLOG, Instant.now())));
setStatus(620L, "COMPLETED");
assertThat(repository.finishedBeforeTracking(USER_ID)).isEqualTo(1);
}
@Test
void finished_HiddenAndOtherUsersGamesDoNotCount() {
UserGame hidden = game(HIDDEN, 0, GameStatus.BACKLOG, Instant.now());
hidden.setHiddenAt(Instant.now());
UserGame other = new UserGame(OTHER_USER, 220L, 0, Instant.now(), Instant.now());
userGameRepository.saveAllAndFlush(List.of(hidden, other));
jdbc.update("update user_game set status = 'FINISHED' where appid in (?, 220)", HIDDEN);
assertThat(repository.finishedPerYear(USER_ID, BERLIN)).isEmpty();
assertThat(repository.finishedBeforeTracking(USER_ID)).isZero();
}
@Test
void pileMoves_SyncInsertsAreNoAddBeforeTheBaseline_MovesAre() {
Instant monthStart = Instant.now().minusSeconds(3600);
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
setStatus(220L, "DROPPED");
setStatus(620L, "PLAYING");
setStatus(620L, "BACKLOG");
setStatus(620L, "FINISHED");
// No Steam baseline: the two inserts are the import; 620 back onto the pile is an add.
assertThat(repository.pileMoves(USER_ID, monthStart)).isEqualTo(new UserStats.MonthMoves(1, 2));
}
@Test
void pileMoves_GamesAddedByHandAreAdded() {
UserGame manual = UserGame.manual(USER_ID, 700L, "Switch", GameStatus.BACKLOG, 0, Instant.now());
userGameRepository.saveAndFlush(manual);
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600)))
.isEqualTo(new UserStats.MonthMoves(1, 0));
}
@Test
void pileMoves_RemovedOrHiddenGamesAndOlderChangesDoNotCount() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
setStatus(220L, "DROPPED");
setStatus(620L, "DROPPED");
jdbc.update("update user_game set removed_at = now() where appid = 220");
jdbc.update("update user_game set hidden_at = now() where appid = 620");
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user, changed_at) " +
"values (?, 700, 'BACKLOG', 'PLAYING', true, now() - interval '40 days')", USER_ID);
jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " +
"values (?, '76561198000000001', '76561198000000001', now() - interval '1 day')", USER_ID);
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(86400)))
.isEqualTo(new UserStats.MonthMoves(1, 0));
}
private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER_ID, appid);
}
}
@@ -0,0 +1,122 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
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.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Against the real tables: the candidates query, the guarded update and the stats that read the estimate.
*/
@Import({FinishedEstimateService.class, UserStatsRepository.class})
class FinishedEstimateServiceTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long ENDING_GAME = 220L;
private static final long NO_ENDING_GAME = 620L;
private static final Instant ENDING_UNLOCKED = Instant.parse("2021-05-04T20:00:00Z");
@Autowired
private FinishedEstimateService service;
@Autowired
private UserStatsRepository userStatsRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameAchievementSchemaRepository schemaRepository;
@Autowired
private UserGameAchievementRepository userAchievementRepository;
@Autowired
private EntityManager entityManager;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(ENDING_GAME, "Half-Life 2", null, Instant.now()),
new SteamGame(NO_ENDING_GAME, "Portal 2", null, Instant.now())));
GameAchievementSchema ending = new GameAchievementSchema(ENDING_GAME, "ACH_END", "The End", "Complete the game.",
null, null, false);
ending.setGlobalPercent(40.0);
GameAchievementSchema other = new GameAchievementSchema(NO_ENDING_GAME, "ACH_JUMP", "Jumper", "Jump 100 times",
null, null, false);
other.setGlobalPercent(50.0);
schemaRepository.saveAllAndFlush(List.of(ending, other));
// Came into the library finished: no finish in the status history.
UserGame finished = new UserGame(USER_ID, ENDING_GAME, 600, Instant.now(), Instant.now());
finished.setStatus(GameStatus.FINISHED);
UserGame completed = new UserGame(USER_ID, NO_ENDING_GAME, 900, Instant.now(), Instant.now());
completed.setStatus(GameStatus.COMPLETED);
userGameRepository.saveAllAndFlush(List.of(finished, completed));
userAchievementRepository.saveAllAndFlush(List.of(
new UserGameAchievement(USER_ID, ENDING_GAME, "ACH_END", true, ENDING_UNLOCKED),
new UserGameAchievement(USER_ID, NO_ENDING_GAME, "ACH_JUMP", true, Instant.now())));
}
private Instant estimate(long appid) {
entityManager.clear();
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().getFinishedAtEstimate();
}
@Test
void fillMissing_DatesTheGameByTheUnlockOfItsEnding() {
assertThat(service.fillMissing()).isEqualTo(1);
assertThat(estimate(ENDING_GAME)).isEqualTo(ENDING_UNLOCKED);
assertThat(estimate(NO_ENDING_GAME)).isNull();
assertThat(userStatsRepository.finishedPerYear(USER_ID, ZoneId.of("Europe/Berlin")))
.containsExactly(new UserStats.YearCount(2021, 1));
assertThat(userStatsRepository.finishedBeforeTracking(USER_ID)).isEqualTo(1);
}
// E.g. Core's own FINISHED on the first sync of an old library, or the user marking old games finished.
@Test
void fillMissing_TheEndingWinsOverALaterFinishInTheStatusHistory() {
jdbc.update("update user_game set status = 'PLAYING' where appid = ?", ENDING_GAME);
jdbc.update("update user_game set status = 'FINISHED' where appid = ?", ENDING_GAME);
service.fillMissing();
assertThat(estimate(ENDING_GAME)).isEqualTo(ENDING_UNLOCKED);
assertThat(userStatsRepository.finishedPerYear(USER_ID, ZoneId.of("Europe/Berlin")))
.containsExactly(new UserStats.YearCount(2021, 1));
}
@Test
void fillMissing_SecondRun_ChangesNothing() {
service.fillMissing();
assertThat(service.fillMissing()).isZero();
}
}
@@ -1,23 +1,30 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -34,6 +41,12 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@Mock
private LibrarySyncWorker worker;
@Mock
private BacklogSnapshotRepository backlogSnapshotRepository;
@Mock
private FinishedEstimateService finishedEstimateService;
@InjectMocks
private LibrarySyncService syncService;
@@ -43,6 +56,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L);
ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L);
ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L);
ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin"));
}
private void givenSync(String steamId, UserSteamSync.Status status, Duration age) {
@@ -106,6 +120,41 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(worker, never()).runSync(anyLong(), anyString());
}
@Test
void syncAllNightly_WritesTheBacklogSnapshotOfTheStartDayAfterTheSyncs() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001")));
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
syncService.syncAllNightly();
InOrder order = inOrder(worker, backlogSnapshotRepository, finishedEstimateService);
order.verify(worker).runSyncNow(1L, "76561198000000001");
order.verify(backlogSnapshotRepository).writeAll(today);
order.verify(finishedEstimateService).fillMissing();
}
@Test
void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of());
when(backlogSnapshotRepository.writeAll(any())).thenThrow(new IllegalStateException("db down"));
syncService.syncAllNightly();
verify(finishedEstimateService).fillMissing();
}
@Test
void syncAllNightly_ASyncThrows_TheSnapshotIsStillWritten() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001")));
doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString());
assertThrows(IllegalStateException.class, () -> syncService.syncAllNightly());
verify(backlogSnapshotRepository).writeAll(any());
}
@Test
void syncAllNightly_SyncsRecentlyUsedAccountsOneAfterTheOther() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
@@ -239,6 +239,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@Test
void runSync_PlayedGames_GoFromThePileWithOneGuardedUpdate() {
UserGame existing = owned(0);
steamReturns(game(30, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
// Never through the entity: a status the user saves during the sync would be overwritten.
assertEquals(GameStatus.BACKLOG, existing.getStatus());
}
@Test
void runSync_NoPlaytimeGrowth_LeavesThePileAlone() {
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@Test
void runSync_FirstSyncWithOldPlaytime_LeavesThePileAlone() {
owned(0);
steamReturns(game(5000, 0), null);
worker.runSync(USER_ID, STEAM_ID);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@Test
void runSync_PlaytimeUnchangedOrLower_RecordsNothing() {
owned(100);
@@ -55,9 +55,9 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(220L, 620L, 700L));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), anyBoolean(), any());
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), aryEq(new String[]{"Puzzle"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any(), anyBoolean(), any());
}
@Test
@@ -69,7 +69,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(427520L));
verify(gameMoodsRepository).upsert(eq(427520L), any(), eq(true), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(427520L), any(), any(), eq(true), any(Instant.class));
}
@Test
@@ -81,7 +81,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refreshBacklogInBackground(7L);
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), aryEq(new String[0]), isNull(), any(Instant.class));
}
@Test
@@ -93,7 +93,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(-119388L));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), aryEq(new String[]{"Adventure"}), eq(false), any(Instant.class));
}
@Test
@@ -104,7 +104,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L));
verify(gameMoodsRepository, never()).upsert(any(), any(), anyBoolean(), any());
verify(gameMoodsRepository, never()).upsert(any(), any(), any(), anyBoolean(), any());
}
@Test
@@ -0,0 +1,97 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.UserStatsDto;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class UserStatsServiceTest extends AbstractUnitTest {
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Mock
private UserGameRepository userGameRepository;
@Mock
private UserStatsRepository userStatsRepository;
@Mock
private BacklogSnapshotRepository backlogSnapshotRepository;
@InjectMocks
private UserStatsService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "zone", BERLIN);
}
@Test
void getStats_OnlyTheAskedGroups_TheOthersAreNullAndNotQueried() {
when(userStatsRepository.mostPlayed(7L, 5)).thenReturn(List.of());
when(userStatsRepository.mostLoyal(7L)).thenReturn(Optional.empty());
UserStatsDto stats = service.getStats(7L, Set.of(StatGroup.TOP_GAMES));
assertThat(stats.topGames()).isNotNull();
assertThat(stats.overview()).isNull();
assertThat(stats.pile()).isNull();
assertThat(stats.taste()).isNull();
assertThat(stats.habits()).isNull();
assertThat(stats.finished()).isNull();
assertThat(stats.trend()).isNull();
verifyNoInteractions(userGameRepository, backlogSnapshotRepository);
}
@Test
void trend_NoSnapshotToday_AddsTheLivePile() {
LocalDate today = LocalDate.now(BERLIN);
UserStats.PileDay yesterday = new UserStats.PileDay(today.minusDays(1), 10, 5, 600);
UserStats.PileDay live = new UserStats.PileDay(today, 9, 5, 500);
when(backlogSnapshotRepository.findSince(7L, today.minusDays(89))).thenReturn(List.of(yesterday));
when(backlogSnapshotRepository.live(7L, today)).thenReturn(live);
when(userStatsRepository.pileMoves(7L, today.withDayOfMonth(1).atStartOfDay(BERLIN).toInstant()))
.thenReturn(new UserStats.MonthMoves(0, 1));
UserStatsDto.Trend trend = service.getStats(7L, Set.of(StatGroup.TREND)).trend();
assertThat(trend.days()).containsExactly(yesterday, live);
assertThat(trend.thisMonth()).isEqualTo(new UserStats.MonthMoves(0, 1));
}
@Test
void trend_NoSnapshotYet_IsOnlyTheLivePile() {
LocalDate today = LocalDate.now(BERLIN);
when(backlogSnapshotRepository.live(7L, today)).thenReturn(new UserStats.PileDay(today, 9, 5, 500));
when(userStatsRepository.pileMoves(7L, today.withDayOfMonth(1).atStartOfDay(BERLIN).toInstant()))
.thenReturn(new UserStats.MonthMoves(0, 0));
assertThat(service.getStats(7L, Set.of(StatGroup.TREND)).trend().days()).hasSize(1);
}
@Test
void getStats_NoGroups_RunsNoQuery() {
UserStatsDto stats = service.getStats(7L, Set.of());
assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null));
verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository);
}
}
@@ -0,0 +1,88 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The trend group against the real status log and snapshots.
*/
@Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class})
class UserStatsServiceTrendTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
@Autowired
private UserStatsService service;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from backlog_snapshot");
jdbc.update("delete from user_steam_sync");
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()),
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(700L, "100% Orange Juice", null, Instant.now())));
}
@Test
void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() {
// Last month the user had only a game added by hand, so the nightly job wrote a snapshot then.
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942");
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " +
"listed_games) values (?, ?, 1, 1, 0, 1)", USER_ID, today.minusDays(40));
// Today the user links Steam: the first sync imports the Steam library and then sets history_since.
importSteamGames();
jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " +
"values (?, '76561198000000001', '76561198000000001', now())", USER_ID);
assertThat(service.getStats(USER_ID, Set.of(StatGroup.TREND)).trend().thisMonth().added()).isZero();
}
@Test
void thisMonth_NewSteamGamesAfterTheFirstSync_AreAdded() {
jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " +
"values (?, '76561198000000001', '76561198000000001', now() - interval '1 day')", USER_ID);
importSteamGames();
assertThat(service.getStats(USER_ID, Set.of(StatGroup.TREND)).trend().thisMonth().added()).isEqualTo(3);
}
private void importSteamGames() {
userGameRepository.saveAllAndFlush(List.of(
new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, 620L, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now())));
}
}