Public Access
feat(library): playtime history per day and a nightly sync
Each sync records the growth of playtime per game and day (Europe/Berlin). The first successful sync of a Steam account is only the baseline, tracked in user_steam_sync.history_steam_id, so a new or failed account never turns its whole playtime into one day. A nightly sync at 23:55 runs the recently used accounts one after the other. GET /api/library/activity returns minutes per day and the games of Steam's last two weeks.
This commit is contained in:
1 parent
afa2a7ea75
commit
ba1f1e6fe1
24 files changed
+607
-12
No files matched your search
@@ -0,0 +1,95 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
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 LibraryControllerActivityTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private PlaytimeDayRepository playtimeDayRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private final LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
jdbc.update("delete from user_game_playtime_day");
|
||||
steamGameRepository.saveAll(List.of(
|
||||
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||
new SteamGame(620L, "Portal 2", null, Instant.now()),
|
||||
new SteamGame(700L, "Dota 2", null, Instant.now())));
|
||||
UserGame hl2 = new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now());
|
||||
hl2.setPlaytime2Weeks(30);
|
||||
UserGame portal = new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now());
|
||||
portal.setPlaytime2Weeks(120);
|
||||
userGameRepository.saveAll(List.of(hl2, portal, new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activity_HasEveryDayWithZeroesAndTheRecentGames() throws Exception {
|
||||
playtimeDayRepository.addMinutes(USER_ID, 220L, today, 30);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 620L, today, 20);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 620L, today.minusDays(6), 40);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 620L, today.minusDays(7), 999);
|
||||
|
||||
mockMvc.perform(get("/api/library/activity").param("days", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.days", hasSize(7)))
|
||||
.andExpect(jsonPath("$.days[0].day").value(today.minusDays(6).toString()))
|
||||
.andExpect(jsonPath("$.days[0].minutes").value(40))
|
||||
.andExpect(jsonPath("$.days[1].minutes").value(0))
|
||||
.andExpect(jsonPath("$.days[6].day").value(today.toString()))
|
||||
.andExpect(jsonPath("$.days[6].minutes").value(50))
|
||||
.andExpect(jsonPath("$.totalMinutes").value(90))
|
||||
.andExpect(jsonPath("$.recentGames", hasSize(2)))
|
||||
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
|
||||
.andExpect(jsonPath("$.recentGames[0].name").value("Portal 2"))
|
||||
.andExpect(jsonPath("$.recentGames[0].playtime2Weeks").value(120))
|
||||
.andExpect(jsonPath("$.recentGames[1].appid").value(220));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
|
||||
mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30)));
|
||||
mockMvc.perform(get("/api/library/activity").param("days", "1000")).andExpect(jsonPath("$.days", hasSize(90)));
|
||||
mockMvc.perform(get("/api/library/activity").param("days", "0")).andExpect(jsonPath("$.days", hasSize(7)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.DayMinutes;
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final LocalDate DAY = LocalDate.of(2026, 9, 20);
|
||||
|
||||
@Autowired
|
||||
private PlaytimeDayRepository playtimeDayRepository;
|
||||
|
||||
@Test
|
||||
void addMinutes_SameGameAndDay_AddsUp() {
|
||||
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 45);
|
||||
|
||||
assertEquals(List.of(new DayMinutes(DAY, 75)), playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void minutesPerDay_SumsAllGamesOfTheUserFromTheGivenDay() {
|
||||
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 20);
|
||||
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY.plusDays(2), 10);
|
||||
playtimeDayRepository.addMinutes(2L, 220L, DAY, 500);
|
||||
|
||||
assertEquals(List.of(new DayMinutes(DAY, 50), new DayMinutes(DAY.plusDays(2), 10)),
|
||||
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,15 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -101,4 +105,35 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
|
||||
|
||||
verify(worker, never()).runSync(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncAllNightly_SyncsRecentlyUsedAccountsOneAfterTheOther() {
|
||||
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
|
||||
new UserSteamSync(1L, "76561198000000001"),
|
||||
new UserSteamSync(2L, "76561198005679790")));
|
||||
|
||||
syncService.syncAllNightly();
|
||||
|
||||
verify(worker).runSyncNow(1L, "76561198000000001");
|
||||
verify(worker).runSyncNow(2L, "76561198005679790");
|
||||
verify(worker, never()).runSync(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_MarksTheUserAsSeenAtMostHourly() {
|
||||
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID);
|
||||
seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK);
|
||||
seenLongAgo.setLastSyncedAt(Instant.now());
|
||||
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
|
||||
UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID);
|
||||
seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK);
|
||||
seenJustNow.setLastSyncedAt(Instant.now());
|
||||
seenJustNow.setUserSeenAt(Instant.now());
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo), Optional.of(seenJustNow));
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userSteamSyncRepository, times(1)).touchUserSeen(eq(USER_ID), any());
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,31 @@ import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -33,6 +39,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
private static final String OTHER_STEAM_ID = "76561198005679790";
|
||||
|
||||
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
|
||||
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks);
|
||||
}
|
||||
|
||||
private static LocalDate today() {
|
||||
return LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param previousSteamId the account of the last successful sync, null for the first sync of the user
|
||||
*/
|
||||
private void steamReturns(SteamOwnedGamesResponse.Game game, String previousSteamId) {
|
||||
if (previousSteamId != null) {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(syncRow(previousSteamId, previousSteamId)));
|
||||
}
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game));
|
||||
}
|
||||
|
||||
private static UserSteamSync syncRow(String steamId, String historySteamId) {
|
||||
UserSteamSync row = new UserSteamSync(USER_ID, steamId);
|
||||
row.setHistorySteamId(historySteamId);
|
||||
return row;
|
||||
}
|
||||
|
||||
private UserGame owned(int playtime) {
|
||||
UserGame existing = new UserGame(USER_ID, 220L, playtime, Instant.EPOCH, Instant.EPOCH);
|
||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
||||
return existing;
|
||||
}
|
||||
|
||||
@Mock
|
||||
private SteamApiService steamApiService;
|
||||
@@ -46,9 +84,17 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private UserSteamSyncRepository userSteamSyncRepository;
|
||||
|
||||
@Mock
|
||||
private PlaytimeDayRepository playtimeDayRepository;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
@@ -90,7 +136,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
void runSync_WithGames_MarksOk() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 0, "icon", true)));
|
||||
game(0, 0)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
@@ -103,7 +149,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true)));
|
||||
game(playtime, 0)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
@@ -119,11 +165,94 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 90, "icon", true)));
|
||||
game(90, 0)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
assertEquals(GameStatus.DROPPED, existing.getStatus());
|
||||
assertEquals(90, existing.getPlaytimeMinutes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
|
||||
owned(100);
|
||||
steamReturns(game(160, 60), STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_PlaytimeUnchangedOrLower_RecordsNothing() {
|
||||
owned(100);
|
||||
steamReturns(game(90, 0), STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_NewGameAfterEarlierSyncs_RecordsItsWholePlaytime() {
|
||||
steamReturns(game(45, 45), STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_FirstSync_IsOnlyTheBaseline() {
|
||||
steamReturns(game(5000, 60), null);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_NewSteamId_IsOnlyTheBaseline() {
|
||||
owned(100);
|
||||
steamReturns(game(5000, 60), OTHER_STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_FailedFirstSyncOfNewSteamId_NextSyncIsStillOnlyTheBaseline() {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(
|
||||
Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)),
|
||||
Optional.of(syncRow(STEAM_ID, OTHER_STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(), List.of(game(5000, 60)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||
verify(userSteamSyncRepository).markHistoryBaseline(USER_ID, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_EmptyResult_KeepsTheOldBaseline() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userSteamSyncRepository, never()).markHistoryBaseline(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_StoresRecentPlaytime() {
|
||||
UserGame existing = owned(100);
|
||||
steamReturns(game(160, 75), STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
assertEquals(75, existing.getPlaytime2Weeks());
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import org.testcontainers.containers.PostgreSQLContainer;
|
||||
* Full Spring context with Testcontainers.
|
||||
*/
|
||||
@Tag("IntegrationTest")
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
// The nightly sync would call Steam with the dev key if a test ran at 23:55.
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.library.nightly-sync-cron=-")
|
||||
@ActiveProfiles("dev")
|
||||
public abstract class AbstractIntegrationTest {
|
||||
|
||||
|
||||
Reference in new issue
Block a user