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
+1238 -69

No files matched your search

@@ -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/")))