feat(library): exclude unreleased games from picker and pile
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m34s

This commit is contained in:
lobo committed 2026-10-05 20:33:25 +02:00
1 parent 33b230ee91
commit 7b538b7f30
48 files changed
+1226 -57

No files matched your search

+7 -2
View File
@@ -28,6 +28,11 @@ To Be Played is split into three repositories:
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
- **Coming games**: a game with a release date after today (Berlin date) or Steam "coming soon", and no playtime or
achievement of any user, is not picked and not on the pile (live count, new snapshots, pile stats, time to beat
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
(`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a
year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily).
```mermaid
flowchart LR
@@ -239,7 +244,7 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
```
@@ -255,7 +260,7 @@ flowchart LR
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
@@ -251,8 +252,8 @@ public class IgdbApiService {
Map<Long, Set<Integer>> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
yearsOf(steamByIgdb.keySet()).forEach((igdbId, year) -> steamByIgdb.get(igdbId)
.forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(year)));
datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId)
.forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(date.getYear())));
}
return Optional.of(result);
} catch (Exception e) {
@@ -272,8 +273,8 @@ public class IgdbApiService {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, Set<Integer>> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
yearsOf(ids.subList(from, Math.min(from + BATCH, ids.size())))
.forEach((igdbId, year) -> result.put(igdbId, Set.of(year)));
datesOf(ids.subList(from, Math.min(from + BATCH, ids.size())))
.forEach((igdbId, date) -> result.put(igdbId, Set.of(date.getYear())));
}
return Optional.of(result);
} catch (Exception e) {
@@ -282,6 +283,49 @@ public class IgdbApiService {
}
}
/**
* The first release date per Steam appid (UTC); the earliest when an appid belongs to several IGDB games. An app
* IGDB has no date for is missing. Empty when the adapter is off or IGDB failed.
*/
public Optional<Map<Long, LocalDate>> getReleaseDates(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, LocalDate> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId)
.forEach(appid -> result.merge(appid, date, (a, b) -> a.isBefore(b) ? a : b)));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for release dates", e);
return Optional.empty();
}
}
/**
* Like {@link #getReleaseDates}, but for IGDB game ids.
*/
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, LocalDate> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for release dates", e);
return Optional.empty();
}
}
/**
* Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed.
*/
@@ -443,7 +487,7 @@ public class IgdbApiService {
return result;
}
private Map<Long, Integer> yearsOf(Collection<Long> igdbIds) {
private Map<Long, LocalDate> datesOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
}
@@ -451,17 +495,21 @@ public class IgdbApiService {
IgdbResponses.Game[] games = query("/games",
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
Map<Long, Integer> result = new HashMap<>();
Map<Long, LocalDate> result = new HashMap<>();
for (IgdbResponses.Game game : games) {
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
result.put(game.id(), year(game.firstReleaseDate()));
result.put(game.id(), date(game.firstReleaseDate()));
}
}
return result;
}
private static int year(long unixSeconds) {
return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).getYear();
return date(unixSeconds).getYear();
}
private static LocalDate date(long unixSeconds) {
return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).toLocalDate();
}
private static List<String> names(List<IgdbResponses.Named> named) {
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
public record GameDetailDto(
@@ -47,7 +48,11 @@ public record GameDetailDto(
// an auditor linked the game with others; for every user
boolean linked,
// the user's other games of that set, empty when none
List<EditionDto> editions
List<EditionDto> editions,
// see GameDto#coming
boolean coming,
// see GameDto#comingOn
LocalDate comingOn
) {
/**
@@ -4,6 +4,8 @@ import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import java.time.LocalDate;
/**
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
* @param achievementsTotal null when the game has no achievements or they were not fetched yet
@@ -12,6 +14,8 @@ import core_service.modules.library.domain.GameStatus;
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
* @param hidden the user hid the game; it shows only under the library's hidden filter
* @param editions how many games of the user are in this game's linked set, null below 2
* @param coming the game is not released yet and nobody played it; it does not count on the pile
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
*/
public record GameDto(
Long appid,
@@ -28,5 +32,7 @@ public record GameDto(
String platform,
String imageUrl,
boolean hidden,
Integer editions
Integer editions,
boolean coming,
LocalDate comingOn
) {}
@@ -7,6 +7,7 @@ import java.util.Map;
/**
* @param byStatus has every status, also those with 0 games
* @param suggestion a random backlog game, null when the backlog is empty
* @param pileGames the BACKLOG games without the ones that are not released yet
* @param backlogMinutes normal time to beat of the backlog games IGDB knows; 0 when none or IGDB is off
*/
public record LibrarySummaryDto(
@@ -14,5 +15,6 @@ public record LibrarySummaryDto(
Map<GameStatus, Long> byStatus,
long neverPlayed,
GameDto suggestion,
long backlogMinutes
long backlogMinutes,
long pileGames
) {}
@@ -2,6 +2,7 @@ package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@@ -31,7 +32,9 @@ public record PublicLibraryDto(
/**
* @param platform see {@link GameDto#platform()}
* @param imageUrl see {@link GameDto#imageUrl()}
* @param coming see {@link GameDto#coming()}
* @param comingOn see {@link GameDto#comingOn()}
*/
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating,
String platform, String imageUrl) {}
String platform, String imageUrl, boolean coming, LocalDate comingOn) {}
}
@@ -10,7 +10,7 @@ import java.util.Map;
/**
* One field per {@link core_service.modules.library.domain.StatGroup}; a group that was not asked for is null.
* Every number counts only listed games (not removed, not hidden). The playtime day sums (pace, longest day) also
* count removed games: those days were played.
* count removed games: those days were played. The pile does not count games that are not released yet.
*/
public record UserStatsDto(
Overview overview,
@@ -0,0 +1,30 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Immutable;
import java.time.LocalDate;
/**
* A game that is not released yet and nobody played: read-only view, see V33. The date is null for "coming soon".
*/
@Entity
@Immutable
@Table(name = "coming_game")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ComingGame {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "release_date")
private LocalDate releaseDate;
}
@@ -0,0 +1,37 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.time.LocalDate;
/**
* The release date of one library key; the date is null when no source knows it, see V33.
*/
@Entity
@Table(name = "game_release")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class GameRelease {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "release_date")
private LocalDate releaseDate;
@Column(name = "coming_soon", nullable = false)
private boolean comingSoon;
@Column(name = "fetched_at", nullable = false)
private Instant fetchedAt;
}
@@ -46,7 +46,10 @@ public final class UserStats {
* @param at the date the stat is about (finished, moved); null when it has none
* @param fromStatus only for the pile moves; null for a game that came in new
* @param toStatus only for the pile moves
* @param coming the game is not released yet and nobody played it
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
*/
public record StatGame(long appid, String name, String imageUrl, GameStatus status, long playtimeMinutes,
Long minutes, Instant at, GameStatus fromStatus, GameStatus toStatus) {}
Long minutes, Instant at, GameStatus fromStatus, GameStatus toStatus,
boolean coming, LocalDate comingOn) {}
}
@@ -18,11 +18,12 @@ import java.util.List;
public class BacklogSnapshotRepository {
private static final String PILE_COLUMNS = """
count(*) filter (where ug.status = 'BACKLOG') as backlog_games,
count(*) filter (where ug.status = 'BACKLOG' and cg.appid is null) as backlog_games,
count(*) filter (where ug.playtime_minutes = 0) as never_played,
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG' and cg.appid is null), 0) as backlog_minutes,
count(*) as listed_games
from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid
left join coming_game cg on cg.appid = ug.appid
""";
private final NamedParameterJdbcTemplate jdbc;
@@ -0,0 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.ComingGame;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ComingGameRepository extends JpaRepository<ComingGame, Long> {
}
@@ -28,7 +28,7 @@ public class GameMergeRepository {
// The shared rows of a game; steam_game last, since user_game refers to it.
private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat",
"game_correction", "broken_image", "game_edition", "steam_game");
"game_correction", "broken_image", "game_edition", "game_release", "steam_game");
// The paid game kinds; a merge moves their ref, an undo moves it back.
private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')";
@@ -0,0 +1,36 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameRelease;
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.Instant;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Set;
public interface GameReleaseRepository extends JpaRepository<GameRelease, Long> {
/**
* A released or unknown game is fresh for {@code since}; a coming one (future date or coming soon) only until
* {@code comingSince}, so a real date replaces a placeholder within a day.
*/
@Query("select r.appid from GameRelease r where r.appid in :appids and r.fetchedAt > :since " +
"and ((r.releaseDate is null and r.comingSoon = false) or r.releaseDate < :today or r.fetchedAt > :comingSince)")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since,
@Param("today") LocalDate today, @Param("comingSince") Instant comingSince);
// An upsert, not save(): two users' syncs can store the same new game at the same time.
@Modifying
@Transactional
@Query(value = "insert into game_release (appid, release_date, coming_soon, fetched_at) " +
"values (:appid, :releaseDate, :comingSoon, :fetchedAt) " +
"on conflict (appid) do update set release_date = excluded.release_date, " +
"coming_soon = excluded.coming_soon, fetched_at = excluded.fetched_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("releaseDate") LocalDate releaseDate,
@Param("comingSoon") boolean comingSoon, @Param("fetchedAt") Instant fetchedAt);
}
@@ -104,10 +104,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
*/
@Query(value = "select * from counted_user_game where user_id = :userId and status = 'BACKLOG' " +
"and appid <> :excludeAppid " +
@Query(value = "select * from counted_user_game ug where ug.user_id = :userId and ug.status = 'BACKLOG' " +
"and ug.appid <> :excludeAppid and " + NOT_COMING +
// Weighted random pick: the smallest -ln(u) / weight wins.
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
"order by -ln(1 - random()) / case ug.priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
"limit 1", nativeQuery = true)
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
@@ -120,6 +120,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
/**
* Leaves out the games that are not released yet, see V33. Needs a native query and the alias {@code ug} on a
* table with an {@code appid} column.
*/
String NOT_COMING = "not exists (select 1 from coming_game cg where cg.appid = ug.appid) ";
/**
* The WHERE tail every picker query shares. Needs the alias {@code ug} and the parameters {@code :userId},
* {@code :excludeAppids}, {@code :maxMinutes}, {@code :mood} and {@code :speedrun}.
@@ -131,7 +137,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " +
" where m.appid = ug.appid and cast(:mood as varchar) = any(m.moods))) " +
"and (:speedrun = false or exists (select 1 from game_speedrun s " +
" where s.appid = ug.appid and s.weblink is not null)) ";
" where s.appid = ug.appid and s.weblink is not null)) " +
"and " + NOT_COMING;
String PRIORITY_ORDER = "case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end";
@@ -249,9 +256,18 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
* Normal time to beat of the user's backlog, only games IGDB has data for.
*/
@Query("select coalesce(sum(t.normallyMinutes), 0) from CountedUserGame c join GameTimeToBeat t on t.appid = c.appid " +
"where c.userId = :userId and c.status = core_service.modules.library.domain.GameStatus.BACKLOG")
"where c.userId = :userId and c.status = core_service.modules.library.domain.GameStatus.BACKLOG " +
"and not exists (select 1 from ComingGame cg where cg.appid = c.appid)")
long sumBacklogMinutes(@Param("userId") Long userId);
/**
* The backlog games of the pile: the BACKLOG status without the games that are not released yet.
*/
@Query("select count(c) from CountedUserGame c where c.userId = :userId " +
"and c.status = core_service.modules.library.domain.GameStatus.BACKLOG " +
"and not exists (select 1 from ComingGame cg where cg.appid = c.appid)")
long countPile(@Param("userId") Long userId);
/**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
@@ -53,8 +53,8 @@ public class UserStatsRepository {
private static final String PILE_TTB_FROM = """
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid
where %s and ug.status = 'BACKLOG'
""".formatted(OF_USER);
where %s and ug.status = 'BACKLOG' and %s
""".formatted(OF_USER, UserGameRepository.NOT_COMING);
private static final String MOST_PLAYED_FROM = """
from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0
@@ -108,7 +108,7 @@ public class UserStatsRepository {
Map.entry(StatKey.STATUS, counted("ug.status = :arg")),
Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")),
Map.entry(StatKey.NEVER_STARTED, counted("ug.playtime_minutes = 0")),
Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG'")),
Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG' and " + UserGameRepository.NOT_COMING)),
Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)),
Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)),
Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)),
@@ -157,9 +157,9 @@ public class UserStatsRepository {
return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
from counted_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 and %s
order by ug.first_seen_at, ug.appid limit 1
""".formatted(OF_USER), user(userId),
""".formatted(OF_USER, UserGameRepository.NOT_COMING), user(userId),
(rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant()))
.stream().findFirst();
@@ -286,8 +286,10 @@ public class UserStatsRepository {
public List<UserStats.StatGame> games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) {
return jdbc.query("""
select s.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime,
s.minutes, s.event_at, s.from_status, s.to_status
s.minutes, s.event_at, s.from_status, s.to_status,
cg.appid is not null as coming, cg.release_date as coming_on
from (%s) s left join steam_game sg on sg.appid = s.appid
left join coming_game cg on cg.appid = s.appid
left join user_game ug on ug.user_id = :userId and ug.appid = s.appid
order by %s, s.appid limit :limit offset :offset
""".formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)),
@@ -295,7 +297,8 @@ public class UserStatsRepository {
(rs, i) -> new UserStats.StatGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"),
status(rs.getString("status")), rs.getLong("playtime"), minutes(rs.getObject("minutes")),
instant(rs.getTimestamp("event_at")), status(rs.getString("from_status")),
status(rs.getString("to_status"))));
status(rs.getString("to_status")), rs.getBoolean("coming"),
rs.getObject("coming_on", LocalDate.class)));
}
public long countGames(long userId, StatKey key, Object arg, ZoneId zone) {
@@ -6,6 +6,7 @@ import core_service.modules.library.api.dto.EditionDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.FinishedHint;
@@ -18,6 +19,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.ComingGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameLinkRepository;
@@ -59,8 +61,10 @@ public class GameDetailService {
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final AchievementSyncService achievementSyncService;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final ComingGameRepository comingGameRepository;
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final GameEditionService gameEditionService;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final SpeedrunService speedrunService;
@@ -117,6 +121,7 @@ public class GameDetailService {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
timeToBeatService.refreshInBackground(List.of(appid));
moodService.refreshInBackground(List.of(appid));
releaseDateService.refreshInBackground(List.of(appid));
return new Refreshed(userGame, false);
}
@@ -198,6 +203,7 @@ public class GameDetailService {
userGame.getFinishedHintDismissed()))
.map(hint -> new FinishedHintDto(hint.type(), hint.achievement()))
.orElse(null);
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
boolean hasAchievements = total > 0;
int remaining = Math.max(0, total - unlocked);
double pct = total == 0 ? 0.0 : (unlocked * 100.0 / total);
@@ -237,7 +243,9 @@ public class GameDetailService {
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream()
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
.toList()
.toList(),
coming.isPresent(),
coming.map(ComingGame::getReleaseDate).orElse(null)
);
}
}
@@ -52,6 +52,7 @@ public class GameImportWorker {
private final ManualGameService manualGameService;
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final Duration stallTime;
// Only a try that started before this app is one a restart stopped.
private final Instant startedAt = Instant.now();
@@ -60,11 +61,13 @@ public class GameImportWorker {
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
TimeToBeatService timeToBeatService, MoodService moodService,
ReleaseDateService releaseDateService,
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
this.importRepository = importRepository;
this.manualGameService = manualGameService;
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.releaseDateService = releaseDateService;
this.stallTime = Duration.ofMinutes(stallMinutes);
this.matching = new Lane("game-import-match", MATCHING_QUEUE, this::match);
this.saving = new Lane("game-import-save", SAVING_QUEUE, this::save);
@@ -159,9 +162,10 @@ public class GameImportWorker {
try {
timeToBeatService.refresh(appids);
moodService.refresh(appids);
releaseDateService.refresh(appids);
} catch (RuntimeException e) {
// The next sync or page view asks again.
log.warn("Time to beat or moods of import {} not loaded: {}", importId, e.getMessage());
log.warn("Time to beat, moods or release dates of import {} not loaded: {}", importId, e.getMessage());
}
if (Thread.currentThread().isInterrupted()) {
return;
@@ -68,15 +68,18 @@ public class GameMergeService {
private final AdminAuditRepository adminAuditRepository;
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final TransactionTemplate transaction;
public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository,
TimeToBeatService timeToBeatService,
MoodService moodService, PlatformTransactionManager transactionManager) {
MoodService moodService, ReleaseDateService releaseDateService,
PlatformTransactionManager transactionManager) {
this.mergeRepository = mergeRepository;
this.adminAuditRepository = adminAuditRepository;
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.releaseDateService = releaseDateService;
this.transaction = new TransactionTemplate(transactionManager);
}
@@ -110,6 +113,7 @@ public class GameMergeService {
// The target may have had no moods or time to beat yet; both skip what is still fresh.
timeToBeatService.refreshInBackground(List.of(to));
moodService.refreshInBackground(List.of(to));
releaseDateService.refreshInBackground(List.of(to));
}
return result;
}
@@ -11,6 +11,7 @@ import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
@@ -25,6 +26,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.ComingGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
@@ -79,10 +81,12 @@ public class LibraryQueryService {
private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final ComingGameRepository comingGameRepository;
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository;
@@ -109,6 +113,7 @@ public class LibraryQueryService {
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*/
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
releaseDateService.refreshBacklogInBackground(userId);
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
@@ -168,12 +173,18 @@ public class LibraryQueryService {
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> coming = comingOf(appids);
return games.stream()
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid())))
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
coming.get(ug.getAppid())))
.toList();
}
private Map<Long, ComingGame> comingOf(Collection<Long> appids) {
return comingGameRepository.findAllById(appids).stream().collect(Collectors.toMap(ComingGame::getAppid, c -> c));
}
public AchievementSummaryDto getAchievementSummary(Long userId) {
List<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
List<AchievementProgress> started = AchievementProgress.started(games);
@@ -218,7 +229,8 @@ public class LibraryQueryService {
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME)
.map(ug -> toDtos(List.of(ug)).getFirst())
.orElse(null);
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId),
userGameRepository.countPile(userId));
}
/**
@@ -267,9 +279,11 @@ public class LibraryQueryService {
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))))
: null;
Map<Long, SteamGame> meta = steamGameRepository.findAllById(Stream.of(playing, finished)
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList())
List<Long> shownAppids = Stream.of(playing, finished)
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, ComingGame> coming = comingOf(shownAppids);
List<AchievementProgress> started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null;
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
return new PublicLibraryDto(
@@ -277,7 +291,7 @@ public class LibraryQueryService {
summary ? userGameRepository.sumPlaytime(userId) : null,
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
sections.contains(ProfileSection.STATUS) ? byStatus : null,
toPublicGames(playing, meta), toPublicGames(finished, meta),
toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming),
summary ? AchievementProgress.perfectAppids(started).size() : null,
summary ? averageCompletion(started) : null,
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
@@ -286,7 +300,8 @@ public class LibraryQueryService {
/**
* @return null for a hidden section
*/
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta,
Map<Long, ComingGame> coming) {
if (games == null) {
return null;
}
@@ -294,7 +309,9 @@ public class LibraryQueryService {
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
coming.containsKey(ug.getAppid()),
coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null))
.toList();
}
@@ -307,7 +324,7 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
Integer editions) {
Integer editions, ComingGame coming) {
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
@@ -327,7 +344,9 @@ public class LibraryQueryService {
ug.getPlatform(),
meta != null ? meta.getImageUrl() : null,
ug.getHiddenAt() != null,
editions);
editions,
coming != null,
coming != null ? coming.getReleaseDate() : null);
}
public Optional<UserSteamSync> getSyncStatus(Long userId) {
@@ -65,6 +65,7 @@ public class LibrarySyncWorker {
private final WishlistGameRepository wishlistGameRepository;
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final GameEditionService gameEditionService;
private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transaction;
@@ -151,6 +152,7 @@ public class LibrarySyncWorker {
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
timeToBeatService.refresh(igdbAppids);
releaseDateService.refresh(igdbAppids);
moodService.refresh(igdbAppids);
gameEditionService.refresh(igdbAppids);
finalStatus = Status.OK;
@@ -67,12 +67,13 @@ public class ManualGameService {
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final GameEditionService gameEditionService;
private final ReleaseDateService releaseDateService;
private final TransactionTemplate transaction;
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository,
TimeToBeatService timeToBeatService, MoodService moodService,
GameEditionService gameEditionService,
GameEditionService gameEditionService, ReleaseDateService releaseDateService,
PlatformTransactionManager transactionManager) {
this.igdbApiService = igdbApiService;
this.userGameRepository = userGameRepository;
@@ -81,6 +82,7 @@ public class ManualGameService {
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.gameEditionService = gameEditionService;
this.releaseDateService = releaseDateService;
this.transaction = new TransactionTemplate(transactionManager);
}
@@ -173,6 +175,7 @@ public class ManualGameService {
timeToBeatService.refreshInBackground(List.of(added.appid()));
moodService.refreshInBackground(List.of(added.appid()));
gameEditionService.refreshInBackground(List.of(added.appid()));
releaseDateService.refreshInBackground(List.of(added.appid()));
}
return result;
}
@@ -0,0 +1,108 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
/**
* Stores the release date of games, the same way {@link TimeToBeatService} stores the time to beat. IGDB's first
* release date wins; Steam's store is asked only for Steam games IGDB has no date for.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ReleaseDateService {
private static final SteamApiService.StoreRelease NO_RELEASE = new SteamApiService.StoreRelease(null, false);
private final IgdbApiService igdbApiService;
private final SteamApiService steamApiService;
private final GameReleaseRepository gameReleaseRepository;
private final UserGameRepository userGameRepository;
private final TaskExecutor applicationTaskExecutor;
@Value("${app.library.release-ttl-days:30}")
private long ttlDays;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* Asks for the games without a fresh row and stores the answer, also "no date". Does nothing when IGDB is off.
* Stores nothing when IGDB fails; when Steam fails, the Steam games without an IGDB date are skipped.
*/
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<LocalDate> igdb = IgdbLookup.byAppid(stale, igdbApiService::getReleaseDates,
igdbApiService::getReleaseDatesByGameIds);
Set<Long> answered = igdb.answered();
if (answered.size() < stale.size()) {
log.warn("Release dates of {} games not refreshed, IGDB did not answer", stale.size() - answered.size());
}
Set<Long> steamAsk = new TreeSet<>();
for (Long appid : answered) {
if (GameKeys.isSteam(appid) && !igdb.found().containsKey(appid)) steamAsk.add(appid);
}
Optional<Map<Long, SteamApiService.StoreRelease>> steam = steamAsk.isEmpty() ? Optional.of(Map.of())
: steamApiService.getReleases(List.copyOf(steamAsk));
if (steam.isEmpty()) {
log.warn("Release dates of {} Steam games not refreshed, Steam did not answer", steamAsk.size());
}
Instant now = Instant.now();
for (Long appid : answered) {
LocalDate igdbDate = igdb.found().get(appid);
if (igdbDate != null) {
gameReleaseRepository.upsert(appid, igdbDate, false, now);
} else if (!steamAsk.contains(appid)) {
gameReleaseRepository.upsert(appid, null, false, now);
} else if (steam.isPresent()) {
SteamApiService.StoreRelease release = steam.get().getOrDefault(appid, NO_RELEASE);
gameReleaseRepository.upsert(appid, release.date(), release.date() == null && release.comingSoon(), now);
}
}
}
/**
* Same as {@link #refresh}, but the request does not wait for IGDB or Steam, and an error does not fail it.
*/
public void refreshInBackground(Collection<Long> appids) {
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Release dates of " + appids, () -> appids, this::stale,
this::refresh);
}
/**
* For the picker: a user without a Steam sync gets dates only here, and a Steam user before the next sync.
*/
public void refreshBacklogInBackground(Long userId) {
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Backlog release dates of user " + userId,
() -> userGameRepository.findBacklogAppids(userId), this::stale, this::refresh);
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, (ids, since) -> gameReleaseRepository.findFreshAppids(ids, since,
LocalDate.now(zone), Instant.now().minus(Duration.ofDays(1))), ttlDays);
}
}
@@ -56,13 +56,13 @@ public class UserStatsService {
* Runs only the queries of the asked groups; the others stay null.
*/
public UserStatsDto getStats(long userId, Set<StatGroup> groups) {
Map<GameStatus, Long> byStatus = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.PILE)
Map<GameStatus, Long> byStatus = groups.contains(StatGroup.OVERVIEW)
? userGameRepository.countEveryStatus(userId) : Map.of();
long playtime = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.HABITS)
? userGameRepository.sumPlaytime(userId) : 0;
return new UserStatsDto(
groups.contains(StatGroup.OVERVIEW) ? overview(userId, byStatus, playtime) : null,
groups.contains(StatGroup.PILE) ? pile(userId, byStatus.get(GameStatus.BACKLOG)) : null,
groups.contains(StatGroup.PILE) ? pile(userId) : null,
groups.contains(StatGroup.TOP_GAMES) ? topGames(userId) : null,
groups.contains(StatGroup.TASTE) ? taste(userId) : null,
groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null,
@@ -77,11 +77,11 @@ public class UserStatsService {
return new UserStatsDto.Overview(total, playtime, total - neverStarted, neverStarted, byStatus);
}
private UserStatsDto.Pile pile(long userId, long backlogGames) {
private UserStatsDto.Pile pile(long userId) {
UserStats.PileTimes times = userStatsRepository.pileTimes(userId);
LocalDate from = LocalDate.now(zone).minusDays(StatFormulas.PACE_DAYS - 1L);
long recentMinutes = userStatsRepository.minutesPlayedSince(userId, from);
return new UserStatsDto.Pile(backlogGames, times.normalMinutes(), times.completionistMinutes(),
return new UserStatsDto.Pile(userGameRepository.countPile(userId), times.normalMinutes(), times.completionistMinutes(),
times.gamesWithTimeToBeat(), StatFormulas.minutesPerWeek(recentMinutes),
StatFormulas.paceWeeks(times.normalMinutes(), recentMinutes),
StatFormulas.workYears(times.normalMinutes()), userStatsRepository.oldestUnplayed(userId).orElse(null));
@@ -110,7 +110,8 @@ public class StatsRepository {
Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM counted_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
WHERE %s AND playtime_minutes = 0
AND appid NOT IN (SELECT appid FROM coming_game) GROUP BY user_id
) t
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L;
@@ -19,6 +19,7 @@ public record SteamStoreItemsResponse(
* @param success 1 for a known app
* @param bestPurchaseOption null for an app without a price, e.g. not released yet
* @param assets only sent when the request asks for them
* @param release only sent when the request asks for it
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreItem(
@@ -26,7 +27,18 @@ public record SteamStoreItemsResponse(
@JsonProperty("success") Integer success,
@JsonProperty("name") String name,
@JsonProperty("best_purchase_option") PurchaseOption bestPurchaseOption,
@JsonProperty("assets") Assets assets
@JsonProperty("assets") Assets assets,
@JsonProperty("release") Release release
) {}
/**
* @param steamReleaseDate unix seconds; null when Steam has no date
* @param comingSoon true while the app is announced and not out
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Release(
@JsonProperty("steam_release_date") Long steamReleaseDate,
@JsonProperty("is_coming_soon") Boolean comingSoon
) {}
/**
@@ -20,6 +20,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -110,6 +112,12 @@ public class SteamApiService {
*/
public record StoreItem(String name, String price, String originalPrice, int discountPct) {}
/**
* @param date null when Steam gives none
* @param comingSoon true while Steam says the app is not out
*/
public record StoreRelease(LocalDate date, boolean comingSoon) {}
/**
* Needs a public wishlist; a private one comes back as an empty list, like an empty one.
*
@@ -175,6 +183,31 @@ public class SteamApiService {
return result;
}
/**
* Steam's release date per app, UTC. Apps Steam sends no release for are missing. Empty when any batch failed, so
* the caller can tell "failed" from "no data".
*/
@RateLimiter(name = "steamApi")
public Optional<Map<Long, StoreRelease>> getReleases(List<Long> appids) {
Map<Long, StoreRelease> result = new HashMap<>();
try {
for (int from = 0; from < appids.size(); from += STORE_ITEMS_BATCH) {
List<Long> batch = appids.subList(from, Math.min(from + STORE_ITEMS_BATCH, appids.size()));
for (SteamStoreItemsResponse.StoreItem item : requestStoreItems(batch, "{\"include_release\":true}")) {
SteamStoreItemsResponse.Release release = item.release();
if (release == null) continue;
LocalDate date = release.steamReleaseDate() == null ? null
: Instant.ofEpochSecond(release.steamReleaseDate()).atZone(ZoneOffset.UTC).toLocalDate();
result.put(item.appid(), new StoreRelease(date, Boolean.TRUE.equals(release.comingSoon())));
}
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with Steam API for release dates of {} apps", appids.size(), e);
return Optional.empty();
}
}
private Map<Long, StoreItem> getStoreItemsBatch(List<Long> appids) {
Map<Long, StoreItem> items = new HashMap<>();
for (SteamStoreItemsResponse.StoreItem item : fetchStoreItems(appids, "{}")) {
@@ -192,10 +225,21 @@ public class SteamApiService {
* @return the known apps (success 1); empty when the request failed
*/
private List<SteamStoreItemsResponse.StoreItem> fetchStoreItems(List<Long> appids, String dataRequest) {
try {
return requestStoreItems(appids, dataRequest);
} catch (Exception e) {
log.error("Error communicating with Steam API for store items {}", appids, e);
return List.of();
}
}
/**
* @return the known apps (success 1); throws when the request failed
*/
private List<SteamStoreItemsResponse.StoreItem> requestStoreItems(List<Long> appids, String dataRequest) {
String ids = appids.stream().map(id -> "{\"appid\":" + id + "}").collect(Collectors.joining(","));
String inputJson = "{\"ids\":[" + ids + "],\"context\":{\"language\":\"" + storeLanguage
+ "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":" + dataRequest + "}";
try {
SteamStoreItemsResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IStoreBrowseService/GetItems/v1/")
@@ -210,10 +254,6 @@ public class SteamApiService {
return response.response().storeItems().stream()
.filter(item -> item.appid() != null && Integer.valueOf(1).equals(item.success()))
.toList();
} catch (Exception e) {
log.error("Error communicating with Steam API for store items {}", appids, e);
return List.of();
}
}
/**
+1
View File
@@ -65,6 +65,7 @@ app:
schema-ttl-days: 30
speedrun-ttl-days: 30
edition-ttl-days: 30
release-ttl-days: 30
# Reported broken pictures, repaired after the nightly sync.
image-repair-cron: "0 30 1 * * *"
merge-undo-purge-cron: "0 45 3 * * *"
@@ -0,0 +1,20 @@
-- Release date per library key, shared by all users and refreshed like game_edition. IGDB first_release_date wins,
-- then Steam's release date. release_date null: no source knows it. coming_soon: Steam says coming soon, no date.
CREATE TABLE game_release (
appid BIGINT PRIMARY KEY,
release_date DATE,
coming_soon BOOLEAN NOT NULL DEFAULT FALSE,
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX user_game_played_appid_idx ON user_game (appid) WHERE playtime_minutes > 0;
CREATE INDEX uga_meta_unlocked_appid_idx ON user_game_achievement_meta (appid) WHERE achievements_unlocked > 0;
-- A game that is not out yet: the date is in the future, or Steam says coming soon without a date, and nobody
-- has played it. A user_game row of any user counts as play, also a hidden or removed one. An unknown date is never
-- coming. "Today" is the Berlin date, the zone of app.library.zone (snapshot day); it is computed on every read, so a
-- game counts again on its release day. A new zone in app.library.zone needs a new migration for this view.
CREATE VIEW coming_game AS
SELECT r.appid, r.release_date FROM game_release r
WHERE (r.release_date > (now() AT TIME ZONE 'Europe/Berlin')::date
OR (r.release_date IS NULL AND r.coming_soon))
AND NOT EXISTS (SELECT 1 FROM user_game ug WHERE ug.appid = r.appid AND ug.playtime_minutes > 0)
AND NOT EXISTS (SELECT 1 FROM user_game_achievement_meta m WHERE m.appid = r.appid AND m.achievements_unlocked > 0);
@@ -9,6 +9,7 @@ import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -189,6 +190,46 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(service.getReleaseYears(List.of(620L))).isEmpty();
}
@Test
void getReleaseDates_SteamAppLinkedToTwoIgdbGames_GetsTheEarliestUtcDate() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}, {\"game\": 233, \"uid\": \"620\"}, {\"game\": 9, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
// 2006-06-01 and 2017-05-05 UTC; game 9 has no date
.andRespond(withSuccess("[{\"id\": 233, \"first_release_date\": 1493942400}, {\"id\": 72, \"first_release_date\": 1149120000}, {\"id\": 9}]",
MediaType.APPLICATION_JSON));
assertThat(service.getReleaseDates(List.of(620L, 220L))).contains(Map.of(620L, LocalDate.of(2006, 6, 1)));
}
@Test
void getReleaseDatesByGameIds_MapsTheIgdbIdToItsDate() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (1025,1026)")))
.andRespond(withSuccess("[{\"id\": 1025, \"first_release_date\": 1493942400}, {\"id\": 1026}]", MediaType.APPLICATION_JSON));
assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5)));
}
@Test
void getReleaseDates_IgdbError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getReleaseDates(List.of(620L))).isEmpty();
}
@Test
void getReleaseDatesByGameIds_IgdbError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError());
assertThat(service.getReleaseDatesByGameIds(List.of(1025L))).isEmpty();
}
@Test
void getGameTags_NoSteamAppKnown_AsksNoGames() {
expectToken();
@@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource;
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.repo.GameReleaseRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.GameImportWorker;
import core_service.shared.test.AbstractMockedAdaptersTest;
@@ -22,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -71,6 +73,9 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
@Autowired
private JdbcTemplate jdbc;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void seed() {
jdbc.update("delete from game_alias");
@@ -269,6 +274,18 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
assertEquals(id, importIdOf(-119388L));
}
@Test
void save_StoresTheReleaseDateOfTheAddedGames() throws Exception {
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Zelda Tears\"}]}");
gameReleaseRepository.deleteAll();
LocalDate releaseDate = LocalDate.now().plusDays(60);
when(igdbApiService.getReleaseDatesByGameIds(any())).thenReturn(Optional.of(Map.of(119388L, releaseDate)));
saveAndWait(id, "{\"lineNo\": 1, \"igdbId\": 119388, \"platform\": \"Switch\", \"status\": \"BACKLOG\"}");
assertEquals(releaseDate, gameReleaseRepository.findById(-119388L).orElseThrow().getReleaseDate());
}
@Test
void undo_RemovesTheGamesStillAddedByHand_AndCountsTheChangedOnes() throws Exception {
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}, {\"title\": \"Zelda Tears\"}]}");
@@ -171,6 +171,8 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
jdbc.update("delete from game_link_pair");
jdbc.update("delete from game_link_request");
jdbc.update("delete from game_edition");
jdbc.update("delete from game_release");
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, current_date, now())", MANUAL);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, MANUAL_2);
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, STEAM_2);
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, MANUAL);
@@ -185,6 +187,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select count(*) from game_link_pair", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from game_edition", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_release", Integer.class)).isZero();
assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE");
}
@@ -186,9 +186,12 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest {
manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING);
gameMoodsRepository.upsert(MANUAL, new String[]{"ACTION"}, new String[]{"Roguelike"}, false, Instant.now());
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 600, now())", MANUAL);
jdbc.update("delete from game_release");
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, current_date, now())", MANUAL);
jdbc.update("insert into game_correction (appid, field, value, corrected_by, corrected_at) "
+ "values (?, 'NAME', 'Hades Switch', 1, now())", MANUAL);
merge(MANUAL, STEAM).andExpect(status().isOk());
assertThat(count("select count(*) from game_release")).isZero();
jdbc.update("update user_game set status = 'FINISHED', rating = 4 where user_id = ? and appid = ?", AUDITOR_ID, STEAM);
int history = logRows(AUDITOR_ID, STEAM);
@@ -204,6 +207,7 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest {
assertThat(steamGameRepository.findById(MANUAL).orElseThrow().getName()).isEqualTo("Hades (Switch)");
assertThat(gameMoodsRepository.findById(MANUAL)).isPresent();
assertThat(gameTimeToBeatRepository.findById(MANUAL)).isPresent();
assertThat(count("select count(*) from game_release where appid = ?", MANUAL)).isEqualTo(1);
assertThat(count("select count(*) from game_correction where appid = ?", MANUAL)).isEqualTo(1);
assertThat(aliasOf(MANUAL)).isNull();
assertThat(count("select count(*) from game_merge_undo")).isZero();
@@ -0,0 +1,102 @@
package core_service.modules.library.api;
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.repo.GameReleaseRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractMockedAdaptersTest;
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 static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.nullValue;
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. Games added by hand, so the detail page asks Steam nothing.
*/
@AutoConfigureMockMvc
class LibraryControllerComingTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L;
private static final long COMING = -4001L;
private static final long COMING_SOON = -4002L;
private static final long RELEASED = -4003L;
private static final LocalDate TOMORROW = LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1);
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
gameReleaseRepository.deleteAll();
for (long appid : new long[]{COMING, COMING_SOON, RELEASED}) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, appid, "PC", GameStatus.BACKLOG, 0, Instant.now()));
}
gameReleaseRepository.upsert(COMING, TOMORROW, false, Instant.now());
gameReleaseRepository.upsert(COMING_SOON, null, true, Instant.now());
gameReleaseRepository.upsert(RELEASED, TOMORROW.minusDays(10), false, Instant.now());
}
@Test
void list_MarksComingGamesWithTheirDate() throws Exception {
mockMvc.perform(get("/api/library/games"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[?(@.appid == " + COMING + ")].coming").value(true))
.andExpect(jsonPath("$.content[?(@.appid == " + COMING + ")].comingOn").value(TOMORROW.toString()))
.andExpect(jsonPath("$.content[?(@.appid == " + COMING_SOON + ")].coming").value(true))
.andExpect(jsonPath("$.content[?(@.appid == " + COMING_SOON + ")].comingOn").value(contains(nullValue())))
.andExpect(jsonPath("$.content[?(@.appid == " + RELEASED + ")].coming").value(false))
.andExpect(jsonPath("$.content[?(@.appid == " + RELEASED + ")].comingOn").value(contains(nullValue())));
}
@Test
void detail_ComingGame_HasTheDate() throws Exception {
mockMvc.perform(get("/api/library/games/" + COMING))
.andExpect(status().isOk())
.andExpect(jsonPath("$.coming").value(true))
.andExpect(jsonPath("$.comingOn").value(TOMORROW.toString()));
}
@Test
void detail_ReleasedGame_IsNotComing() throws Exception {
mockMvc.perform(get("/api/library/games/" + RELEASED))
.andExpect(status().isOk())
.andExpect(jsonPath("$.coming").value(false))
.andExpect(jsonPath("$.comingOn").value(nullValue()));
}
@Test
void detail_ComingGameSomeonePlayed_IsNotComing() throws Exception {
jdbc.update("update user_game set playtime_minutes = 30 where appid = ?", COMING);
mockMvc.perform(get("/api/library/games/" + COMING))
.andExpect(jsonPath("$.coming").value(false));
}
}
@@ -7,6 +7,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.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
@@ -19,6 +20,8 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
@@ -57,8 +60,12 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void clean() {
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
@@ -283,4 +290,20 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
verify(syncService).syncIfStale(USER_ID, "76561198000000001");
}
@Test
void picker_ComingGame_IsNeverPickedUntilItsReleaseDay() throws Exception {
LocalDate berlinToday = LocalDate.now(ZoneId.of("Europe/Berlin"));
game(10L, GameStatus.BACKLOG, null, null);
game(20L, GameStatus.BACKLOG, null, null);
gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now());
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
gameReleaseRepository.upsert(20L, berlinToday, false, Instant.now());
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
}
}
@@ -4,6 +4,7 @@ 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.GameReleaseRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -15,6 +16,8 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
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.nullValue;
@@ -42,9 +45,13 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
gameReleaseRepository.deleteAll();
}
@Test
@@ -108,4 +115,24 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
mockMvc.perform(get("/api/library/summary").param("exclude", "220"))
.andExpect(jsonPath("$.suggestion").value(nullValue()));
}
@Test
void summary_ComingGame_StaysInTheStatusCountButLeavesThePile() throws Exception {
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Dawn of War 4", null, Instant.now())));
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, 620L, 0, Instant.now(), Instant.now())));
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(220L, 500, 700, 900, Instant.now()),
new GameTimeToBeat(620L, 1, 9999, 1, Instant.now())));
gameReleaseRepository.upsert(620L, LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1), false, Instant.now());
mockMvc.perform(get("/api/library/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.byStatus.BACKLOG").value(2))
.andExpect(jsonPath("$.pileGames").value(1))
.andExpect(jsonPath("$.backlogMinutes").value(700))
.andExpect(jsonPath("$.suggestion.appid").value(220));
}
}
@@ -7,6 +7,7 @@ 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.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -21,6 +22,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -66,6 +68,9 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private JdbcTemplate jdbc;
@@ -113,6 +118,17 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
assertEquals(3600, gameTimeToBeatRepository.findById(-119388L).orElseThrow().getNormallyMinutes());
}
@Test
void add_StoresTheReleaseDate() throws Exception {
gameReleaseRepository.deleteAll();
LocalDate releaseDate = LocalDate.now().plusDays(60);
when(igdbApiService.getReleaseDatesByGameIds(any())).thenReturn(Optional.of(Map.of(119388L, releaseDate)));
add(119388, "Switch").andExpect(status().isCreated());
assertEquals(releaseDate, gameReleaseRepository.findById(-119388L).orElseThrow().getReleaseDate());
}
@Test
void add_GameAddedBeforeIgdbKnewItOnSteam_Is409() throws Exception {
add(119388, "Switch").andExpect(status().isCreated());
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -21,8 +22,11 @@ import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -70,8 +74,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
return game;
}
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void seed() {
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
@@ -109,6 +117,22 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$..priority").isEmpty());
}
@Test
void publicLibrary_ComingGame_ShowsItsDateAndAReleasedOneDoesNot() throws Exception {
LocalDate tomorrow = LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1);
steamGameRepository.save(new SteamGame(900L, "Dawn of War 4", null, Instant.now()));
userGameRepository.save(game(900L, 0, GameStatus.PLAYING, null));
gameReleaseRepository.upsert(900L, tomorrow, false, Instant.now());
gameReleaseRepository.upsert(220L, tomorrow.minusDays(100), false, Instant.now());
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.playing[?(@.appid == 900)].coming").value(true))
.andExpect(jsonPath("$.playing[?(@.appid == 900)].comingOn").value(tomorrow.toString()))
.andExpect(jsonPath("$.playing[?(@.appid == 220)].coming").value(false))
.andExpect(jsonPath("$.playing[?(@.appid == 220)].comingOn").value(contains(nullValue())));
}
@Test
void publicLibrary_ShowsPerfectGamesAndAverageCompletion() throws Exception {
schemaMetaRepository.saveAll(List.of(
@@ -12,8 +12,10 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Date;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -124,4 +126,36 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.findSince(2L, DAY)).isEmpty();
}
private void comingTomorrow(long appid) {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
Date.valueOf(LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1)));
}
@Test
void comingGame_LeavesTheBacklogNumbersOfNewSnapshotsAndLive() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG),
game(1L, 1000L, 0, GameStatus.BACKLOG)));
comingTomorrow(1000L);
repository.writeAll(DAY);
UserStats.PileDay expected = new UserStats.PileDay(DAY, 1, 2, 600);
assertThat(repository.findSince(1L, DAY)).containsExactly(expected);
assertThat(repository.live(1L, DAY)).isEqualTo(expected);
}
@Test
void comingGame_AnOldSnapshotKeepsItsValue() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG),
game(1L, 1000L, 0, GameStatus.BACKLOG)));
repository.writeAll(DAY.minusDays(1));
comingTomorrow(1000L);
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY.minusDays(1))).containsExactly(
new UserStats.PileDay(DAY.minusDays(1), 2, 2, 10599),
new UserStats.PileDay(DAY, 1, 2, 600));
}
}
@@ -0,0 +1,157 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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;
class ComingGameRepositoryTest extends AbstractPersistenceTest {
private static final long GAME = 4000L;
private static final LocalDate TODAY = LocalDate.now(ZoneId.of("Europe/Berlin"));
@Autowired
private ComingGameRepository comingGameRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private JdbcTemplate jdbc;
private void release(LocalDate date, boolean comingSoon) {
gameReleaseRepository.upsert(GAME, date, comingSoon, Instant.now());
}
private void owned(long userId, int playtime) {
steamGameRepository.saveAndFlush(new SteamGame(GAME, "Dawn of War 4", null, Instant.now()));
userGameRepository.saveAndFlush(new UserGame(userId, GAME, playtime, Instant.now(), Instant.now()));
}
private boolean coming() {
return comingGameRepository.findById(GAME).isPresent();
}
@Test
void view_FutureDateAndNoPlay_IsComingWithItsDate() {
release(TODAY.plusDays(1), false);
owned(1L, 0);
assertThat(comingGameRepository.findById(GAME)).get().extracting(ComingGame::getReleaseDate)
.isEqualTo(TODAY.plusDays(1));
}
@Test
void view_OnTheReleaseDay_IsNotComing() {
release(TODAY, false);
assertThat(coming()).isFalse();
}
@Test
void view_PastDate_IsNotComing() {
release(TODAY.minusDays(1), false);
assertThat(coming()).isFalse();
}
@Test
void view_UnknownDate_IsNotComing() {
release(null, false);
assertThat(coming()).isFalse();
}
@Test
void view_ComingSoonWithoutDate_IsComingWithoutDate() {
release(null, true);
assertThat(comingGameRepository.findById(GAME)).get().extracting(ComingGame::getReleaseDate).isNull();
}
@Test
void view_ComingSoonWithoutDateButPlaytime_IsNotComing() {
release(null, true);
owned(1L, 5);
assertThat(coming()).isFalse();
}
@Test
void view_FutureDateButAnotherUsersPlaytime_IsNotComing() {
release(TODAY.plusDays(30), false);
owned(1L, 0);
owned(2L, 10);
assertThat(coming()).isFalse();
}
@Test
void view_FutureDateButUnlockedAchievement_IsNotComing() {
release(TODAY.plusDays(30), false);
owned(1L, 0);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (1, ?, 1)", GAME);
assertThat(coming()).isFalse();
}
@Test
void view_FutureDateAndAchievementMetaWithoutUnlocks_IsComing() {
release(TODAY.plusDays(30), false);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (1, ?, 0)", GAME);
assertThat(coming()).isTrue();
}
@Test
void findFreshAppids_ComingRowsFetchedTwoDaysAgoAreStale_ReleasedRowIsFresh() {
Instant twoDaysAgo = Instant.now().minusSeconds(2 * 86_400L);
gameReleaseRepository.upsert(1L, TODAY.plusDays(5), false, twoDaysAgo);
gameReleaseRepository.upsert(2L, null, true, twoDaysAgo);
gameReleaseRepository.upsert(3L, TODAY.minusDays(5), false, twoDaysAgo);
gameReleaseRepository.upsert(4L, null, false, twoDaysAgo);
gameReleaseRepository.upsert(5L, TODAY.plusDays(5), false, Instant.now());
Set<Long> fresh = gameReleaseRepository.findFreshAppids(List.of(1L, 2L, 3L, 4L, 5L, 6L),
Instant.now().minusSeconds(30 * 86_400L), TODAY, Instant.now().minusSeconds(86_400L));
assertThat(fresh).containsExactlyInAnyOrder(3L, 4L, 5L);
}
@Test
void findFreshAppids_OldRowBeyondTheTtl_IsStale() {
gameReleaseRepository.upsert(3L, TODAY.minusDays(50), false, Instant.now().minusSeconds(40 * 86_400L));
assertThat(gameReleaseRepository.findFreshAppids(List.of(3L), Instant.now().minusSeconds(30 * 86_400L), TODAY,
Instant.now().minusSeconds(86_400L))).isEmpty();
}
@Test
void upsert_SecondCall_ReplacesTheRow() {
release(TODAY.plusDays(5), false);
release(null, true);
assertThat(gameReleaseRepository.findById(GAME)).get()
.satisfies(r -> {
assertThat(r.getReleaseDate()).isNull();
assertThat(r.isComingSoon()).isTrue();
});
}
}
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
@@ -34,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class UserGameRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
private static final PageRequest PAGE = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "playtimeMinutes"));
@Autowired
@@ -51,6 +54,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@@ -547,4 +553,57 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(1003L), ids(userGameRepository.findSimilarGames(USER_ID, 1001L, null, Mood.COOP, List.of(1002L), 10)));
assertEquals(List.of(1003L), ids(userGameRepository.findLongestWaitingGames(USER_ID, null, Mood.COOP, List.of(1002L), 10)));
}
private void backlogGameReleasing(long appid, LocalDate releaseDate, String... moods) {
addGame(appid, GameStatus.BACKLOG, null, Instant.now(), 0, moods);
gameReleaseRepository.upsert(appid, releaseDate, false, Instant.now());
}
@Test
void comingGame_IsNeverPickedFromAnySource() {
hide(380L, 700L);
LocalDate tomorrow = LocalDate.now(BERLIN).plusDays(1);
addGame(1001L, GameStatus.BACKLOG, null, Instant.now(), 0, "STORY");
backlogGameReleasing(1002L, tomorrow, "STORY");
assertEquals(List.of(1001L), ids(userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)));
assertEquals(List.of(1001L), ids(userGameRepository.findLongestWaitingGames(USER_ID, null, null, List.of(), 10)));
assertTrue(userGameRepository.findSimilarGames(USER_ID, 1001L, null, null, List.of(), 10).isEmpty());
for (int i = 0; i < 20; i++) {
assertEquals(1001L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
}
}
@Test
void releasedGame_IsPickedAndCounted() {
hide(380L, 700L);
backlogGameReleasing(1002L, LocalDate.now(BERLIN), "STORY");
backlogGameReleasing(1003L, LocalDate.now(BERLIN).minusDays(1), "STORY");
assertEquals(List.of(1002L, 1003L), ids(userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10))
.stream().sorted().toList());
assertEquals(2, userGameRepository.countPile(USER_ID));
}
@Test
void comingGame_LeavesThePileCountAndTheBacklogMinutesButKeepsItsStatus() {
LocalDate tomorrow = LocalDate.now(BERLIN).plusDays(1);
backlogGameReleasing(1002L, tomorrow);
gameTimeToBeatRepository.save(new GameTimeToBeat(1002L, 1, 5000, 1, Instant.now()));
gameTimeToBeatRepository.save(new GameTimeToBeat(380L, 100, 200, 300, Instant.now()));
assertEquals(2, userGameRepository.countPile(USER_ID));
assertEquals(3, userGameRepository.countEveryStatus(USER_ID).get(GameStatus.BACKLOG));
assertEquals(200, userGameRepository.sumBacklogMinutes(USER_ID));
}
@Test
void comingGame_WithAnyUsersPlaytime_IsPickedAndCounted() {
hide(380L, 700L);
backlogGameReleasing(1002L, LocalDate.now(BERLIN).plusDays(30));
userGameRepository.saveAndFlush(new UserGame(2L, 1002L, 10, Instant.now(), Instant.now()));
assertEquals(List.of(1002L), ids(userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)));
assertEquals(1, userGameRepository.countPile(USER_ID));
}
}
@@ -14,12 +14,14 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Date;
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 static org.assertj.core.api.Assertions.tuple;
@Import(UserStatsRepository.class)
class UserStatsRepositoryTest extends AbstractPersistenceTest {
@@ -370,4 +372,40 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.countGames(USER_ID, StatKey.TOTAL, "", BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1);
}
private void comingTomorrow(long appid) {
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
Date.valueOf(LocalDate.now(BERLIN).plusDays(1)));
}
@Test
void comingGame_LeavesThePileNumbersAndDrillSets() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z")),
game(620L, 0, GameStatus.BACKLOG, Instant.EPOCH)));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()),
new GameTimeToBeat(620L, 300, 5000, 8000, Instant.now())));
comingTomorrow(620L);
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(220L);
assertThat(repository.countGames(USER_ID, StatKey.PILE, "", BERLIN)).isEqualTo(1);
assertThat(repository.countGames(USER_ID, StatKey.PILE_TIME_TO_BEAT, "", BERLIN)).isEqualTo(1);
assertThat(repository.countGames(USER_ID, StatKey.TOTAL, "", BERLIN)).isEqualTo(2);
}
@Test
void games_MarkComingGamesWithTheirDate() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
comingTomorrow(620L);
List<UserStats.StatGame> games = repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10);
assertThat(games).extracting(UserStats.StatGame::appid, UserStats.StatGame::coming, UserStats.StatGame::comingOn)
.containsExactlyInAnyOrder(
tuple(220L, false, null),
tuple(620L, true, LocalDate.now(BERLIN).plusDays(1)));
}
}
@@ -56,7 +56,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), 5);
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), 5);
try {
worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -126,6 +126,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private MoodService moodService;
@Mock
private ReleaseDateService releaseDateService;
@Mock
private GameEditionService gameEditionService;
@@ -544,6 +547,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(timeToBeatService).refresh(List.of(220L, -1025L));
verify(moodService).refresh(List.of(220L, -1025L));
verify(releaseDateService).refresh(List.of(220L, -1025L));
verify(gameEditionService).refresh(List.of(220L, -1025L));
}
@@ -53,6 +53,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock
private GameEditionService gameEditionService;
@Mock
private ReleaseDateService releaseDateService;
@Mock
private PlatformTransactionManager transactionManager;
@@ -61,7 +64,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
moodService, gameEditionService, transactionManager);
moodService, gameEditionService, releaseDateService, transactionManager);
}
@Test
@@ -0,0 +1,151 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.UserGameRepository;
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.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
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.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ReleaseDateServiceTest extends AbstractUnitTest {
private static final LocalDate IGDB_DATE = LocalDate.of(2026, 12, 4);
private static final LocalDate STEAM_DATE = LocalDate.of(2026, 11, 20);
@Mock
private IgdbApiService igdbApiService;
@Mock
private SteamApiService steamApiService;
@Mock
private GameReleaseRepository gameReleaseRepository;
@Mock
private UserGameRepository userGameRepository;
@Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@InjectMocks
private ReleaseDateService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "ttlDays", 30L);
ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin"));
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameReleaseRepository.findFreshAppids(anyCollection(), any(), any(), any())).thenReturn(Set.of());
}
@Test
void refresh_IgdbDate_WinsAndSteamIsNotAskedForIt() {
when(igdbApiService.getReleaseDates(Set.of(620L))).thenReturn(Optional.of(Map.of(620L, IGDB_DATE)));
service.refresh(List.of(620L));
verify(gameReleaseRepository).upsert(eq(620L), eq(IGDB_DATE), eq(false), any(Instant.class));
verify(steamApiService, never()).getReleases(any());
}
@Test
void refresh_SteamIsAskedOnlyForSteamKeysWithoutIgdbDate() {
when(igdbApiService.getReleaseDates(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, IGDB_DATE)));
when(igdbApiService.getReleaseDatesByGameIds(Set.of(7L))).thenReturn(Optional.of(Map.of()));
when(steamApiService.getReleases(List.of(700L))).thenReturn(Optional.of(Map.of(
700L, new SteamApiService.StoreRelease(STEAM_DATE, false))));
service.refresh(List.of(620L, 700L, -7L));
verify(steamApiService).getReleases(List.of(700L));
verify(gameReleaseRepository).upsert(eq(620L), eq(IGDB_DATE), eq(false), any(Instant.class));
verify(gameReleaseRepository).upsert(eq(700L), eq(STEAM_DATE), eq(false), any(Instant.class));
verify(gameReleaseRepository).upsert(eq(-7L), isNull(), eq(false), any(Instant.class));
}
@Test
void refresh_SteamComingSoonWithoutDate_StoresNullDateAndComingSoon() {
when(igdbApiService.getReleaseDates(Set.of(700L))).thenReturn(Optional.of(Map.of()));
when(steamApiService.getReleases(List.of(700L))).thenReturn(Optional.of(Map.of(
700L, new SteamApiService.StoreRelease(null, true))));
service.refresh(List.of(700L));
verify(gameReleaseRepository).upsert(eq(700L), isNull(), eq(true), any(Instant.class));
}
@Test
void refresh_IgdbDateBeatsSteamComingSoon() {
when(igdbApiService.getReleaseDates(Set.of(700L))).thenReturn(Optional.of(Map.of(700L, IGDB_DATE)));
service.refresh(List.of(700L));
verify(gameReleaseRepository).upsert(eq(700L), eq(IGDB_DATE), eq(false), any(Instant.class));
verify(steamApiService, never()).getReleases(any());
}
@Test
void refresh_SteamKnowsNothing_StoresUnknown() {
when(igdbApiService.getReleaseDates(Set.of(700L))).thenReturn(Optional.of(Map.of()));
when(steamApiService.getReleases(List.of(700L))).thenReturn(Optional.of(Map.of()));
service.refresh(List.of(700L));
verify(gameReleaseRepository).upsert(eq(700L), isNull(), eq(false), any(Instant.class));
}
@Test
void refresh_IgdbError_StoresNothing() {
when(igdbApiService.getReleaseDates(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameReleaseRepository, never()).upsert(any(), any(), anyBoolean(), any());
verify(steamApiService, never()).getReleases(any());
}
@Test
void refresh_SteamError_StoresOnlyTheIgdbDatedKeys() {
when(igdbApiService.getReleaseDates(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, IGDB_DATE)));
when(steamApiService.getReleases(anyList())).thenReturn(Optional.empty());
service.refresh(List.of(620L, 700L));
verify(gameReleaseRepository).upsert(eq(620L), eq(IGDB_DATE), eq(false), any(Instant.class));
verify(gameReleaseRepository, never()).upsert(eq(700L), any(), anyBoolean(), any());
}
@Test
void refresh_FreshKeys_AreSkipped() {
when(gameReleaseRepository.findFreshAppids(anyCollection(), any(), any(), any())).thenReturn(Set.of(620L));
service.refresh(List.of(620L));
verify(igdbApiService, never()).getReleaseDates(any());
}
}
@@ -17,6 +17,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.context.annotation.Import;
import org.springframework.test.util.ReflectionTestUtils;
import java.sql.Date;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
@@ -239,4 +240,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
hiddenGames_LeaveTheTopLists();
assertLandingListsIgnoreUser3();
}
@Test
void biggestBacklog_LeavesOutComingGames() {
steamGameRepository.saveAndFlush(new SteamGame(3000L, "Not out yet", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(5L, 220L, 0, Instant.now(), Instant.now()),
new UserGame(5L, 3000L, 0, Instant.now(), Instant.now())));
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (3000, ?, now())",
Date.valueOf(LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(1)));
assertEquals(1, statsRepository.biggestBacklog());
}
}
@@ -15,6 +15,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -236,6 +237,35 @@ class SteamApiServiceTest {
assertThat(steamApiService.getStoreHeaderUrls(List.of(620L))).isEmpty();
}
@Test
void getReleases_SendsIncludeReleaseAndParsesDateAndComingSoon() {
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
.andExpect(queryParam("input_json", containsString("%22data_request%22%3A%7B%22include_release%22%3Atrue%7D")))
.andRespond(withSuccess("""
{"response": {"store_items": [
{"appid": 620, "success": 1, "release": {"steam_release_date": 1796427000, "is_coming_soon": true}},
{"appid": 700, "success": 1, "release": {"is_coming_soon": true}},
{"appid": 800, "success": 1, "release": {"steam_release_date": 1493942400}},
{"appid": 900, "success": 1}
]}}
""", MediaType.APPLICATION_JSON));
Map<Long, SteamApiService.StoreRelease> releases = steamApiService.getReleases(List.of(620L, 700L, 800L, 900L)).orElseThrow();
assertThat(releases).containsOnly(
Map.entry(620L, new SteamApiService.StoreRelease(LocalDate.of(2026, 12, 4), true)),
Map.entry(700L, new SteamApiService.StoreRelease(null, true)),
Map.entry(800L, new SteamApiService.StoreRelease(LocalDate.of(2017, 5, 5), false)));
}
@Test
void getReleases_ServerError_IsEmpty() {
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
.andRespond(withServerError());
assertThat(steamApiService.getReleases(List.of(620L))).isEmpty();
}
@Test
void getPlayerAchievements_Forbidden_IsEmpty() {
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))