Public Access
feat(library): add stats page data and pile trend
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:
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
|
||||
|
||||
+1
-1
@@ -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())));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user