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,9 @@
package core_service.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@@ -1,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
@@ -39,6 +40,8 @@ public class LibraryController {
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80); private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
private static final int DEFAULT_SIZE = 20; private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7;
private static final int MAX_ACTIVITY_DAYS = 90;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
@@ -134,6 +137,13 @@ public class LibraryController {
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); .body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
} }
@GetMapping("/activity")
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "30") int days) {
int safeDays = Math.clamp(days, MIN_ACTIVITY_DAYS, MAX_ACTIVITY_DAYS);
return ResponseEntity.ok(queryService.getActivity(principal.getIdAsLong(), safeDays));
}
@GetMapping("/summary") @GetMapping("/summary")
public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Long exclude) { @RequestParam(required = false) Long exclude) {
@@ -0,0 +1,15 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.DayMinutes;
import java.util.List;
/**
* @param days one entry per day, oldest first, also days without playtime
* @param recentGames up to 5 games by Steam's playtime of the last two weeks
*/
public record ActivityDto(
List<DayMinutes> days,
long totalMinutes,
List<RecentGameDto> recentGames
) {}
@@ -11,6 +11,7 @@ public record GameDetailDto(
String name, String name,
String imgIconUrl, String imgIconUrl,
int playtimeMinutes, int playtimeMinutes,
int playtime2Weeks,
Instant firstSeenAt, Instant firstSeenAt,
Instant lastSeenAt, Instant lastSeenAt,
GameStatus status, GameStatus status,
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
*/
public record RecentGameDto(
Long appid,
String name,
int playtimeMinutes,
int playtime2Weeks,
GameStatus status
) {}
@@ -0,0 +1,5 @@
package core_service.modules.library.domain;
import java.time.LocalDate;
public record DayMinutes(LocalDate day, long minutes) {}
@@ -0,0 +1,38 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* Written only through {@code PlaytimeDayRepository.addMinutes}.
*/
@Entity
@Table(name = "user_game_playtime_day")
@IdClass(PlaytimeDayId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PlaytimeDay {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "day")
private LocalDate day;
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "minutes", nullable = false)
private int minutes;
}
@@ -0,0 +1,20 @@
package core_service.modules.library.domain;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDate;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EqualsAndHashCode
public class PlaytimeDayId implements Serializable {
private Long userId;
private LocalDate day;
private Long appid;
}
@@ -36,6 +36,9 @@ public class UserGame {
@Column(name = "playtime_minutes", nullable = false) @Column(name = "playtime_minutes", nullable = false)
private int playtimeMinutes; private int playtimeMinutes;
@Column(name = "playtime_2weeks", nullable = false)
private int playtime2Weeks;
@Column(name = "first_seen_at", nullable = false) @Column(name = "first_seen_at", nullable = false)
private Instant firstSeenAt; private Instant firstSeenAt;
@@ -36,6 +36,12 @@ public class UserSteamSync {
@Column(name = "last_sync_status", length = 16) @Column(name = "last_sync_status", length = 16)
private Status lastSyncStatus; private Status lastSyncStatus;
@Column(name = "history_steam_id", length = 32)
private String historySteamId;
@Column(name = "user_seen_at")
private Instant userSeenAt;
public UserSteamSync(Long userId, String steamId) { public UserSteamSync(Long userId, String steamId) {
this.userId = userId; this.userId = userId;
this.steamId = steamId; this.steamId = steamId;
@@ -0,0 +1,31 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.PlaytimeDay;
import core_service.modules.library.domain.PlaytimeDayId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
@Modifying
@Transactional
@Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) values (:userId, :day, :appid, :minutes) " +
"on conflict (user_id, day, appid) do update set minutes = user_game_playtime_day.minutes + excluded.minutes",
nativeQuery = true)
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("day") LocalDate day, @Param("minutes") int minutes);
/**
* Only days with playtime; the caller fills the gaps.
*/
@Query("select new core_service.modules.library.domain.DayMinutes(p.day, sum(p.minutes)) from PlaytimeDay p " +
"where p.userId = :userId and p.day >= :from group by p.day order by p.day")
List<DayMinutes> minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from);
}
@@ -24,6 +24,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
long countByUserIdAndRemovedAtIsNull(Long userId); long countByUserIdAndRemovedAtIsNull(Long userId);
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
Long userId, int playtime2Weeks);
long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes); long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes);
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " + @Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
@@ -5,8 +5,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.List;
public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> { public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> {
@@ -40,5 +42,16 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
void markFinished(@Param("userId") Long userId, void markFinished(@Param("userId") Long userId,
@Param("status") String status, @Param("status") String status,
@Param("now") Instant now); @Param("now") Instant now);
@Modifying
@Query(value = "UPDATE user_steam_sync SET history_steam_id = :steamId WHERE user_id = :userId", nativeQuery = true)
void markHistoryBaseline(@Param("userId") Long userId, @Param("steamId") String steamId);
@Modifying
@Transactional
@Query(value = "UPDATE user_steam_sync SET user_seen_at = :now WHERE user_id = :userId", nativeQuery = true)
void touchUserSeen(@Param("userId") Long userId, @Param("now") Instant now);
List<UserSteamSync> findByUserSeenAtAfter(Instant since);
} }
@@ -108,6 +108,7 @@ public class GameDetailService {
meta != null ? meta.getName() : null, meta != null ? meta.getName() : null,
meta != null ? meta.getImgIconUrl() : null, meta != null ? meta.getImgIconUrl() : null,
userGame.getPlaytimeMinutes(), userGame.getPlaytimeMinutes(),
userGame.getPlaytime2Weeks(),
userGame.getFirstSeenAt(), userGame.getFirstSeenAt(),
userGame.getLastSeenAt(), userGame.getLastSeenAt(),
userGame.getStatus(), userGame.getStatus(),
@@ -1,21 +1,28 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync; 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.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -30,6 +37,10 @@ public class LibraryQueryService {
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final PlaytimeDayRepository playtimeDayRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
public long countOwnedGames(Long userId) { public long countOwnedGames(Long userId) {
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
@@ -63,6 +74,34 @@ public class LibraryQueryService {
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion);
} }
/**
* @param days the number of days up to and including today
*/
public ActivityDto getActivity(Long userId, int days) {
LocalDate today = LocalDate.now(zone);
LocalDate from = today.minusDays(days - 1L);
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L)))
.toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
List<UserGame> recent = userGameRepository
.findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(userId, 0);
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<RecentGameDto> recentGames = recent.stream()
.map(ug -> new RecentGameDto(
ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(),
ug.getPlaytime2Weeks(),
ug.getStatus()))
.toList();
return new ActivityDto(perDay, total, recentGames);
}
private static GameDto toDto(UserGame ug, SteamGame meta) { private static GameDto toDto(UserGame ug, SteamGame meta) {
return new GameDto( return new GameDto(
ug.getAppid(), ug.getAppid(),
@@ -5,10 +5,12 @@ import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -30,12 +32,16 @@ public class LibrarySyncService {
@Value("${app.library.failed-retry-minutes:15}") @Value("${app.library.failed-retry-minutes:15}")
private long failedRetryMinutes; private long failedRetryMinutes;
@Value("${app.library.nightly-active-days:14}")
private long nightlyActiveDays;
/** /**
* Trigger sync only if data is older than TTL or was synced for another Steam ID. * Trigger sync only if data is older than TTL or was synced for another Steam ID.
* Cheap gate check on caller thread, actual sync runs async via worker. * Cheap gate check on caller thread, actual sync runs async via worker.
*/ */
public void syncIfStale(Long userId, String steamId) { public void syncIfStale(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
if (sync != null && isAlreadyRunning(sync)) { if (sync != null && isAlreadyRunning(sync)) {
log.debug("Sync already running for user {}", userId); log.debug("Sync already running for user {}", userId);
return; return;
@@ -46,11 +52,25 @@ public class LibrarySyncService {
worker.runSync(userId, steamId); worker.runSync(userId, steamId);
} }
/**
* Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each
* day even when the user does not open the app. Accounts are synced one after the other on the scheduler
* thread; accounts without a user request in the last {@code nightly-active-days} are skipped.
*/
@Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void syncAllNightly() {
Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays));
List<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince);
log.info("Nightly sync for {} Steam accounts", accounts.size());
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId()));
}
/** /**
* User-triggered sync. Throws SyncCooldownException if recently synced or running. * User-triggered sync. Throws SyncCooldownException if recently synced or running.
*/ */
public void forceSync(Long userId, String steamId) { public void forceSync(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
if (sync != null && sync.getLastSyncedAt() != null) { if (sync != null && sync.getLastSyncedAt() != null) {
long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds(); long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds();
if (sinceLast < forceSyncCooldownSeconds) { if (sinceLast < forceSyncCooldownSeconds) {
@@ -63,6 +83,14 @@ public class LibrarySyncService {
worker.runSync(userId, steamId); worker.runSync(userId, steamId);
} }
// At most one write per user and hour; the nightly sync only needs to know "used recently".
private void touchUserSeen(UserSteamSync sync) {
Instant now = Instant.now();
if (sync != null && (sync.getUserSeenAt() == null || sync.getUserSeenAt().isBefore(now.minus(Duration.ofHours(1))))) {
userSteamSyncRepository.touchUserSeen(sync.getUserId(), now);
}
}
private boolean isStale(UserSteamSync sync) { private boolean isStale(UserSteamSync sync) {
if (sync.getLastSyncedAt() == null) return true; if (sync.getLastSyncedAt() == null) return true;
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED
@@ -4,6 +4,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync; 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.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -18,6 +19,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import core_service.modules.library.domain.UserSteamSync.Status; import core_service.modules.library.domain.UserSteamSync.Status;
@@ -36,17 +39,34 @@ public class LibrarySyncWorker {
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final PlaytimeDayRepository playtimeDayRepository;
@Value("${app.library.running-stale-minutes:5}") @Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes; private long runningStaleMinutes;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@Async @Async
@Transactional @Transactional
public void runSync(Long userId, String steamId) { public void runSync(Long userId, String steamId) {
sync(userId, steamId);
}
/**
* Same as {@link #runSync}, but on the caller's thread. The nightly job uses it to sync one account after
* the other instead of starting all at once against the Steam rate limit and the connection pool.
*/
@Transactional
public void runSyncNow(Long userId, String steamId) {
sync(userId, steamId);
}
private void sync(Long userId, String steamId) {
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)); Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
String previousSteamId = userSteamSyncRepository.findById(userId) UserSteamSync before = userSteamSyncRepository.findById(userId).orElse(null);
.map(UserSteamSync::getSteamId) String previousSteamId = before != null ? before.getSteamId() : null;
.orElse(null); String historySteamId = before != null ? before.getHistorySteamId() : null;
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore); int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
if (claimed == 0) { if (claimed == 0) {
log.info("Sync skipped for user {} — another run already in flight", userId); log.info("Sync skipped for user {} — another run already in flight", userId);
@@ -56,7 +76,11 @@ public class LibrarySyncWorker {
Instant syncStartedAt = Instant.now(); Instant syncStartedAt = Instant.now();
Status finalStatus = Status.FAILED; Status finalStatus = Status.FAILED;
try { try {
if (previousSteamId != null && !previousSteamId.equals(steamId)) { boolean steamIdChanged = previousSteamId != null && !previousSteamId.equals(steamId);
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
boolean recordPlaytime = steamId.equals(historySteamId);
LocalDate today = LocalDate.ofInstant(syncStartedAt, zone);
if (steamIdChanged) {
// The old games belong to the previous Steam account. The upserts below restore the games this account also owns. // The old games belong to the previous Steam account. The upserts below restore the games this account also owns.
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
} }
@@ -71,13 +95,14 @@ public class LibrarySyncWorker {
for (SteamOwnedGamesResponse.Game g : games) { for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue; if (g.appid() == null) continue;
upsertGame(g, syncStartedAt); upsertGame(g, syncStartedAt);
upsertUserGame(userId, g, syncStartedAt); upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null);
} }
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
if (removed > 0) { if (removed > 0) {
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed); log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
} }
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
finalStatus = Status.OK; finalStatus = Status.OK;
} catch (Exception e) { } catch (Exception e) {
log.error("Sync failed for user {}", userId, e); log.error("Sync failed for user {}", userId, e);
@@ -98,16 +123,28 @@ public class LibrarySyncWorker {
} }
} }
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now) { /**
* @param playedOn the day that gets the playtime growth, null to record none
*/
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null); UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0; int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
if (existing == null) { if (existing == null) {
userGameRepository.save(new UserGame(userId, appid, playtime, now, now)); UserGame created = new UserGame(userId, appid, playtime, now, now);
created.setPlaytime2Weeks(playtime2Weeks);
userGameRepository.save(created);
} else { } else {
existing.setPlaytimeMinutes(playtime); existing.setPlaytimeMinutes(playtime);
existing.setPlaytime2Weeks(playtime2Weeks);
existing.setLastSeenAt(now); existing.setLastSeenAt(now);
existing.setRemovedAt(null); existing.setRemovedAt(null);
} }
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
if (playedOn != null && playtime > previousPlaytime) {
playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime);
}
} }
} }
@@ -21,6 +21,7 @@ public record SteamOwnedGamesResponse(
@JsonProperty("name") String name, @JsonProperty("name") String name,
@JsonProperty("playtime_forever") Integer playtimeForever, @JsonProperty("playtime_forever") Integer playtimeForever,
@JsonProperty("img_icon_url") String imgIconUrl, @JsonProperty("img_icon_url") String imgIconUrl,
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats @JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
) {} ) {}
} }
@@ -0,0 +1,21 @@
-- Minutes played per user, game and day (Europe/Berlin), as the growth of playtime_forever between two syncs.
-- The first sync of a Steam account is only the baseline and writes no rows.
CREATE TABLE user_game_playtime_day (
user_id BIGINT NOT NULL,
day DATE NOT NULL,
appid BIGINT NOT NULL,
minutes INTEGER NOT NULL CHECK (minutes > 0),
PRIMARY KEY (user_id, day, appid)
);
-- The Steam account whose playtime is the baseline for the history: set after a successful sync only.
-- Growth is recorded only while it equals steam_id, so a failed first sync of a new account cannot turn
-- that account's whole playtime into one day.
ALTER TABLE user_steam_sync ADD COLUMN history_steam_id VARCHAR(32);
-- Last request of the user. The nightly sync skips accounts nobody used for a while, e.g. after the user
-- removed the Steam ID (Core does not learn about that).
ALTER TABLE user_steam_sync ADD COLUMN user_seen_at TIMESTAMPTZ;
-- Steam's own "last two weeks" playtime, updated on every sync.
ALTER TABLE user_game ADD COLUMN playtime_2weeks INTEGER NOT NULL DEFAULT 0;
@@ -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.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -101,4 +105,35 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(worker, never()).runSync(anyLong(), anyString()); 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.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync; 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.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -33,6 +39,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001"; 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 @Mock
private SteamApiService steamApiService; private SteamApiService steamApiService;
@@ -46,9 +84,17 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private UserSteamSyncRepository userSteamSyncRepository; private UserSteamSyncRepository userSteamSyncRepository;
@Mock
private PlaytimeDayRepository playtimeDayRepository;
@InjectMocks @InjectMocks
private LibrarySyncWorker worker; private LibrarySyncWorker worker;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
}
@Test @Test
void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() { void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
@@ -90,7 +136,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_WithGames_MarksOk() { void runSync_WithGames_MarksOk() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of( 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); worker.runSync(USER_ID, STEAM_ID);
@@ -103,7 +149,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) { void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of( 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); 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(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of( 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); worker.runSync(USER_ID, STEAM_ID);
assertEquals(GameStatus.DROPPED, existing.getStatus()); assertEquals(GameStatus.DROPPED, existing.getStatus());
assertEquals(90, existing.getPlaytimeMinutes()); 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. * Full Spring context with Testcontainers.
*/ */
@Tag("IntegrationTest") @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") @ActiveProfiles("dev")
public abstract class AbstractIntegrationTest { public abstract class AbstractIntegrationTest {