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:
lobo committed 2026-09-24 13:41:37 +02:00
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 {