feat(library): finish games from the ending achievement or suggest it
Build, Test & Push Core Service / quality-check (push) Successful in 58s
Build, Test & Push Core Service / docker (push) Successful in 1m23s

This commit is contained in:
lobo committed 2026-09-26 19:00:57 +02:00
1 parent c2d17d0bc7
commit b959542e23
31 files changed
+1072 -34

No files matched your search

@@ -0,0 +1,205 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.FinishedHintType;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
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.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
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. Fresh schemas, so the detail page asks Steam nothing.
*/
@AutoConfigureMockMvc
class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final long ENDING_GAME = 322110L;
private static final long MANUAL_GAME = -1942L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaRepository schemaRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementRepository userAchievementRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
schemaRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
steamGameRepository.save(new SteamGame(ENDING_GAME, "20XX", null, Instant.now()));
steamGameRepository.save(new SteamGame(MANUAL_GAME, "Zelda", null, Instant.now()));
GameAchievementSchema ending = new GameAchievementSchema(ENDING_GAME, "ACH_SURVIVOR", "Survivor", "Complete the game.", null, null, false);
ending.setGlobalPercent(19.2);
GameAchievementSchema other = new GameAchievementSchema(ENDING_GAME, "ACH_KILL", "First Blood", "Kill an enemy", null, null, false);
other.setGlobalPercent(90.0);
schemaRepository.saveAll(List.of(ending, other));
schemaMetaRepository.save(new GameAchievementSchemaMeta(ENDING_GAME, 2, true, Instant.now()));
userAchievementRepository.save(new UserGameAchievement(USER_ID, ENDING_GAME, "ACH_SURVIVOR", true, Instant.now()));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, ENDING_GAME, Instant.now(), true, 1, 600));
gameTimeToBeatRepository.save(new GameTimeToBeat(MANUAL_GAME, null, 600, null, Instant.now()));
gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, false, Instant.now());
}
private UserGame steamGame(boolean statusSetByUser) {
UserGame game = new UserGame(USER_ID, ENDING_GAME, 600, Instant.now(), Instant.now());
game.setStatusSetByUser(statusSetByUser);
return userGameRepository.save(game);
}
private UserGame stored(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
private ResultActions dismiss(long appid, String body) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/finished-hint").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
@Test
void detail_UntouchedGameWithTheEndingUnlocked_IsFinishedByCore() throws Exception {
steamGame(false);
mockMvc.perform(get("/api/library/games/" + ENDING_GAME))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED"))
.andExpect(jsonPath("$.finishedHint").doesNotExist());
assertEquals(GameStatus.FINISHED, stored(ENDING_GAME).getStatus());
}
@Test
void detail_UntouchedGameAfterNotYet_IsNotFinishedByCore() throws Exception {
UserGame game = steamGame(false);
game.setFinishedHintDismissed(FinishedHintType.PLAYTIME);
userGameRepository.save(game);
mockMvc.perform(get("/api/library/games/" + ENDING_GAME))
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.finishedHint.type").value("ENDING"));
}
@Test
void detail_StatusChosenByTheUser_KeepsItAndShowsTheEndingHint() throws Exception {
steamGame(true);
mockMvc.perform(get("/api/library/games/" + ENDING_GAME))
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.finishedHint.type").value("ENDING"))
.andExpect(jsonPath("$.finishedHint.achievement").value("Survivor"));
assertEquals(GameStatus.PLAYING, stored(ENDING_GAME).getStatus());
}
@Test
void detail_GameAddedByHand_GetsThePlaytimeHint() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, "Switch", GameStatus.PLAYING, 600, Instant.now()));
mockMvc.perform(get("/api/library/games/" + MANUAL_GAME))
.andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME"))
.andExpect(jsonPath("$.finishedHint.achievement").doesNotExist());
}
@Test
void notYet_HidesTheHintAndPlaytimeNeverWeakensEnding() throws Exception {
steamGame(true);
dismiss(ENDING_GAME, "{\"dismissed\":\"ENDING\"}").andExpect(status().isNoContent());
dismiss(ENDING_GAME, "{\"dismissed\":\"PLAYTIME\"}").andExpect(status().isNoContent());
assertEquals(FinishedHintType.ENDING, stored(ENDING_GAME).getFinishedHintDismissed());
mockMvc.perform(get("/api/library/games/" + ENDING_GAME)).andExpect(jsonPath("$.finishedHint").doesNotExist());
}
@Test
void settingTheStatusByHand_MarksItAsChosen() throws Exception {
steamGame(false);
mockMvc.perform(put("/api/library/games/" + ENDING_GAME + "/status").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"DROPPED\"}"))
.andExpect(status().isOk());
assertTrue(stored(ENDING_GAME).isStatusSetByUser());
assertEquals(0, userGameRepository.finishIfUntouched(USER_ID, ENDING_GAME));
assertEquals(GameStatus.DROPPED, stored(ENDING_GAME).getStatus());
}
@Test
void notYet_UnknownGameIs404() throws Exception {
dismiss(620L, "{\"dismissed\":\"ENDING\"}").andExpect(status().isNotFound());
}
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"dismissed\":\"MAYBE\"}"})
void notYet_BadBodyIs400(String body) throws Exception {
steamGame(true);
dismiss(ENDING_GAME, body).andExpect(status().isBadRequest());
assertNull(stored(ENDING_GAME).getFinishedHintDismissed());
}
@Test
void notYet_WithoutClientHeaderIs403() throws Exception {
steamGame(true);
mockMvc.perform(put("/api/library/games/" + ENDING_GAME + "/finished-hint")
.contentType(MediaType.APPLICATION_JSON).content("{\"dismissed\":\"ENDING\"}"))
.andExpect(status().isForbidden());
assertFalse(stored(ENDING_GAME).getFinishedHintDismissed() != null);
}
}
@@ -93,9 +93,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
game(40L, GameStatus.FINISHED, null, null);
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(status().isOk())
@@ -124,8 +124,8 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 3000);
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
@@ -0,0 +1,127 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The cases are rows measured on production (ROADMAP item 9), with their Steam global rates.
*/
class EndingAchievementTest {
private static GameAchievementSchema achievement(String apiName, String name, String description, Double percent) {
GameAchievementSchema row = new GameAchievementSchema(1L, apiName, name, description, null, null, false);
row.setGlobalPercent(percent);
return row;
}
@ParameterizedTest
@CsvSource(delimiter = '|', nullValues = "-", textBlock = """
Survivor | Complete the game. | 19.2
To Link the Fire | Reach "To Link the Fire" ending. | 31.2
The End of Fire | - | 21.4
Fate's Conclusion | - | 16.2
A Stroll In The Demonic Park | Complete the game on any difficulty setting | 21.7
A Cozy Completion | Complete the Campaign | 50.0
Passed the Trial | Finish the game on any difficulty. | 22.0
""")
void isCandidate_StoryEnds(String name, String description, double percent) {
assertThat(EndingAchievement.isCandidate(achievement("A", name, description, percent))).isTrue();
}
@ParameterizedTest
@CsvSource(delimiter = '|', nullValues = "-", textBlock = """
The Feast Begins! | Finished tutorial. | 98.6
True Fan I | Finish 5 sessions with a score of at least 1000 | 30.1
Last Breath | Complete the game with 1 HP remaining (and no armor). | 5.9
Honor and Glory | Complete the game after entering and achieving glory. | 14.6
Super Fighting Robot | Complete the game on NORMAL difficulty. | 10.2
Geminiphobia | Beat the game… twice. | 11.6
Do You Want to Super Size | Beat the game on a Large Map. | 13.4
The Alexman | Beat the game on the Warlord difficulty level. | 18.1
In Storybook Endings | Lock up Mad Hatter in GCPD. | 20.0
Neverending | Ascend the world 3 times | 40.0
Victory | Complete the game | 0.5
Victory | Complete the game | 95.0
First Steps | - | 89.1
Prologue Done | Finish the prologue of the game | 70.0
Act One | Complete Act 1 of the campaign | 60.0
First Level | Clear the first level of the game | 75.0
Match Played | Complete a game | 75.0
Chapter 1 | Complete chapter 1 of the story | 70.0
The End of the Beginning | - | 65.0
Dead End | - | 40.0
Beginning of the End | - | 30.0
""")
void isCandidate_NoStoryEnd(String name, String description, double percent) {
assertThat(EndingAchievement.isCandidate(achievement("A", name, description, percent))).isFalse();
}
@Test
void isCandidate_UnknownRateIsNoCandidate() {
assertThat(EndingAchievement.isCandidate(achievement("A", "Survivor", "Complete the game.", null))).isFalse();
}
@Test
void of_TheEasiestVariantWins() {
List<GameAchievementSchema> schema = List.of(
achievement("Game_CompleteApocolyptic", "Apocalyptic", "Complete the Game on Apocalyptic Difficulty", 6.6),
achievement("Game_Complete", "Protector of Humanity", "Complete the Game on Any Difficulty Setting", 32.9),
achievement("GAME_STORY", "Story", "Complete the story", 20.0),
achievement("Other", "First Blood", "Kill an enemy", 90.0));
EndingAchievement ending = EndingAchievement.of(schema).orElseThrow();
assertThat(ending.achievement().getApiName()).isEqualTo("Game_Complete");
// Two candidates: a hint, never FINISHED by Core itself.
assertThat(ending.certain()).isFalse();
}
@Test
void of_MoreThanThreeCandidatesIsAnOpenGame() {
List<GameAchievementSchema> schema = List.of(
achievement("A", "Egypt", "Beat the game on any difficulty setting as Ramesses II.", 10.0),
achievement("B", "Rome", "Beat the game on any difficulty setting as Augustus.", 11.0),
achievement("C", "China", "Beat the game on any difficulty setting as Wu Zetian.", 9.0),
achievement("D", "Greece", "Beat the game on any difficulty setting as Alexander.", 12.0));
assertThat(EndingAchievement.of(schema)).isEmpty();
}
// Three of the four leaders are under the 1 % floor; the family still shows an open game.
@Test
void of_SiblingsUnderTheRateFloorStillMakeAnOpenGame() {
List<GameAchievementSchema> schema = List.of(
achievement("A", "Egypt", "Beat the game on any difficulty setting as Ramesses II.", 5.0),
achievement("B", "Rome", "Beat the game on any difficulty setting as Augustus.", 0.8),
achievement("C", "China", "Beat the game on any difficulty setting as Wu Zetian.", 0.7),
achievement("D", "Greece", "Beat the game on any difficulty setting as Alexander.", 0.5));
assertThat(EndingAchievement.of(schema).filter(EndingAchievement::certain)).isEmpty();
}
@Test
void of_NoCandidateIsEmpty() {
assertThat(EndingAchievement.of(List.of(achievement("A", "First Blood", "Kill an enemy", 90.0)))).isEmpty();
assertThat(EndingAchievement.of(List.of())).isEmpty();
}
@Test
void of_OneTextMatchIsCertain() {
EndingAchievement ending = EndingAchievement.of(List.of(
achievement("END", "Survivor", "Complete the game.", 19.2),
achievement("KILL", "First Blood", "Kill an enemy", 90.0))).orElseThrow();
assertThat(ending.certain()).isTrue();
}
@Test
void of_ANameOnlyMatchIsNeverCertain() {
EndingAchievement ending = EndingAchievement.of(List.of(achievement("ACH02", "The End of Fire", null, 21.4))).orElseThrow();
assertThat(ending.certain()).isFalse();
}
}
@@ -0,0 +1,121 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class FinishedHintTest {
private static final int NORMAL = 1000;
private static GameAchievementSchema achievement(String apiName, String name, String description, double percent) {
GameAchievementSchema row = new GameAchievementSchema(1L, apiName, name, description, null, null, false);
row.setGlobalPercent(percent);
return row;
}
private static final List<GameAchievementSchema> WITH_ENDING = List.of(
achievement("END", "Survivor", "Complete the game.", 19.2),
achievement("KILL", "First Blood", "Kill an enemy", 90.0));
private static final List<GameAchievementSchema> WITHOUT_ENDING = List.of(
achievement("KILL", "First Blood", "Kill an enemy", 90.0),
achievement("BOSS", "Big One", "Defeat a boss", 50.0));
/**
* A PLAYING Steam game with visible stats, known time to beat and not endless.
*/
private static FinishedHint.Facts facts(List<GameAchievementSchema> schema, Set<String> unlocked, int playtime) {
return new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, schema, unlocked, true, playtime, NORMAL,
false, null);
}
private static Optional<FinishedHintType> type(FinishedHint.Facts facts) {
return FinishedHint.of(facts).map(FinishedHint::type);
}
@Test
void endingUnlocked_IsTheEndingHintWithItsName() {
assertThat(FinishedHint.of(facts(WITH_ENDING, Set.of("END"), 0)))
.contains(new FinishedHint(FinishedHintType.ENDING, "Survivor"));
}
@Test
void endingLocked_IsNoHintEvenAfterALongPlaytime() {
assertThat(type(facts(WITH_ENDING, Set.of("KILL"), 5 * NORMAL))).isEmpty();
}
@Test
void noEnding_PlaytimeFromNinetyPercentOfTheNormalTime() {
assertThat(type(facts(WITHOUT_ENDING, Set.of(), 900))).contains(FinishedHintType.PLAYTIME);
assertThat(type(facts(WITHOUT_ENDING, Set.of(), 899))).isEmpty();
}
@Test
void statsHiddenBySteam_UsesThePlaytime() {
FinishedHint.Facts hidden = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITH_ENDING, Set.of(),
false, NORMAL, NORMAL, false, null);
assertThat(type(hidden)).contains(FinishedHintType.PLAYTIME);
}
@Test
void endlessOrUnknownGame_GetsNoPlaytimeHint() {
FinishedHint.Facts endless = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, List.of(), Set.of(),
true, 5 * NORMAL, NORMAL, true, null);
FinishedHint.Facts unknown = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, List.of(), Set.of(),
true, 5 * NORMAL, NORMAL, null, null);
FinishedHint.Facts noTimeToBeat = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, List.of(), Set.of(),
true, 5 * NORMAL, null, false, null);
assertThat(type(endless)).isEmpty();
assertThat(type(unknown)).isEmpty();
assertThat(type(noTimeToBeat)).isEmpty();
}
@Test
void everyAchievementUnlocked_LeavesItToTheCompletedHint() {
assertThat(type(facts(WITH_ENDING, Set.of("END", "KILL"), 5 * NORMAL))).isEmpty();
}
@ParameterizedTest
@EnumSource(value = GameStatus.class, names = {"FINISHED", "COMPLETED", "DROPPED"})
void onlyBacklogAndPlayingGamesAreAsked(GameStatus status) {
FinishedHint.Facts done = new FinishedHint.Facts(status, GameSource.STEAM, WITH_ENDING, Set.of("END"), true,
5 * NORMAL, NORMAL, false, null);
assertThat(type(done)).isEmpty();
}
@Test
void dismissedPlaytime_StillShowsTheEndingLater() {
FinishedHint.Facts playtimeOnly = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITHOUT_ENDING,
Set.of(), true, 5 * NORMAL, NORMAL, false, FinishedHintType.PLAYTIME);
FinishedHint.Facts endingNow = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITH_ENDING,
Set.of("END"), true, 5 * NORMAL, NORMAL, false, FinishedHintType.PLAYTIME);
assertThat(type(playtimeOnly)).isEmpty();
assertThat(type(endingNow)).contains(FinishedHintType.ENDING);
}
@Test
void dismissedEnding_ShowsNothing() {
FinishedHint.Facts facts = new FinishedHint.Facts(GameStatus.PLAYING, GameSource.STEAM, WITH_ENDING,
Set.of("END"), true, 5 * NORMAL, NORMAL, false, FinishedHintType.ENDING);
assertThat(type(facts)).isEmpty();
}
@Test
void gameAddedByHand_UsesOnlyTheTypedHours() {
FinishedHint.Facts manual = new FinishedHint.Facts(GameStatus.BACKLOG, GameSource.MANUAL, List.of(), Set.of(),
true, NORMAL, NORMAL, false, null);
assertThat(type(manual)).contains(FinishedHintType.PLAYTIME);
}
}
@@ -2,6 +2,8 @@ package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.List;
@@ -45,4 +47,25 @@ class MoodTest extends AbstractUnitTest {
void of_NothingThatFits_HasNoMood() {
assertThat(Mood.of(List.of("Racing", "Sport"), List.of("Science fiction"), List.of("Multiplayer"))).isEmpty();
}
@ParameterizedTest
@CsvSource(delimiter = '|', textBlock = """
Simulator | - | -
Strategy | Sandbox | -
Adventure | Survival | -
Strategy | 4X (explore, expand, exploit, and exterminate) | -
RPG | - | Massively Multiplayer Online (MMO)
""", nullValues = "-")
void endless_GamesWithoutAnEnd(String genre, String theme, String mode) {
assertThat(Mood.endless(listOf(genre), listOf(theme), listOf(mode))).isTrue();
}
@Test
void endless_StrategyWithACampaignIsNotEndless() {
assertThat(Mood.endless(List.of("Real Time Strategy (RTS)", "Strategy"), List.of("Warfare"), List.of("Single player"))).isFalse();
}
private static List<String> listOf(String value) {
return value == null ? List.of() : List.of(value);
}
}
@@ -26,12 +26,23 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
gameMoodsRepository.deleteAll();
}
@Test
void upsert_StoresTheEndlessFlag() {
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, true, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isTrue();
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isFalse();
}
@Test
void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, Instant.now());
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, false, Instant.now());
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP");
gameMoodsRepository.upsert(620L, new String[0], Instant.now());
gameMoodsRepository.upsert(620L, new String[0], false, Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty();
@@ -39,8 +50,8 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, Instant.now().minus(Duration.ofDays(40)));
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, false, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
@@ -1,7 +1,9 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
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.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -9,6 +11,7 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
@@ -23,6 +26,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -184,6 +188,101 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong());
}
private static Optional<SteamSchemaForGameResponse.Game> steamSchema(String... apiNames) {
List<SteamSchemaForGameResponse.Achievement> achievements = java.util.Arrays.stream(apiNames)
.map(name -> new SteamSchemaForGameResponse.Achievement(name, name, "Complete the game.", 0, "icon", "gray"))
.toList();
return Optional.of(new SteamSchemaForGameResponse.Game("Game", "1",
new SteamSchemaForGameResponse.AvailableGameStats(achievements)));
}
@SuppressWarnings("unchecked")
private List<GameAchievementSchema> savedSchema() {
ArgumentCaptor<List<GameAchievementSchema>> saved = ArgumentCaptor.forClass(List.class);
verify(schemaRepository).saveAll(saved.capture());
return saved.getValue();
}
@Test
void refreshSchemaNow_StoresTheGlobalRatesPerAchievement() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(steamSchema("A", "B"));
when(steamApiService.getGlobalAchievementPercentages(620L)).thenReturn(Map.of("A", 12.5));
service.refreshSchemaNow(620L);
List<GameAchievementSchema> rows = savedSchema();
assertEquals(12.5, rows.get(0).getGlobalPercent());
assertEquals(null, rows.get(1).getGlobalPercent());
}
@Test
void refreshSchemaNow_FailedRatesCall_KeepsTheStoredRates() throws Exception {
GameAchievementSchema old = new GameAchievementSchema(620L, "A", "A", null, null, null, false);
old.setGlobalPercent(30.0);
when(steamApiService.getSchemaForGame(620L)).thenReturn(steamSchema("A"));
when(steamApiService.getGlobalAchievementPercentages(620L)).thenReturn(Map.of());
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(old));
service.refreshSchemaNow(620L);
assertEquals(30.0, savedSchema().getFirst().getGlobalPercent());
}
@Test
void refreshSchemaNow_RateLimiterFull_StillStoresTheSchema() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(steamSchema("A"));
when(steamApiService.getGlobalAchievementPercentages(620L)).thenThrow(new IllegalStateException("limiter full"));
service.refreshSchemaNow(620L);
assertEquals("A", savedSchema().getFirst().getApiName());
}
private void endingOf620(boolean unlocked) {
GameAchievementSchema ending = new GameAchievementSchema(620L, "END", "Survivor", "Complete the game.", null, null, false);
ending.setGlobalPercent(19.2);
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(ending));
when(userAchievementRepository.findByUserIdAndAppid(USER_ID, 620L))
.thenReturn(List.of(new UserGameAchievement(USER_ID, 620L, "END", unlocked, null)));
}
@Test
void finishIfEndingUnlocked_UnlockedEnding_FinishesAnUntouchedGame() {
endingOf620(true);
when(userGameRepository.finishIfUntouched(USER_ID, 620L)).thenReturn(1);
assertTrue(service.finishIfEndingUnlocked(USER_ID, 620L));
}
@Test
void finishIfEndingUnlocked_LockedEnding_ChangesNothing() {
endingOf620(false);
assertFalse(service.finishIfEndingUnlocked(USER_ID, 620L));
verify(userGameRepository, never()).finishIfUntouched(anyLong(), anyLong());
}
@Test
void syncLibrary_UntouchedGameWithUnchangedPlaytime_IsCheckedForTheEnding() {
library(List.of(game(620L, 120)), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
endingOf620(true);
service.syncLibrary(USER_ID, STEAM_ID);
verify(userGameRepository).finishIfUntouched(USER_ID, 620L);
}
@Test
void syncLibrary_StatusChosenByTheUser_IsNeverFinishedByCore() {
UserGame chosen = game(620L, 120);
chosen.setStatusSetByUser(true);
library(List.of(chosen), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
service.syncLibrary(USER_ID, STEAM_ID);
verify(userGameRepository, never()).finishIfUntouched(anyLong(), anyLong());
}
@Test
void refreshSchemaNow_EmptyAnswer_KeepsTheStoredSchema() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
@@ -18,8 +18,10 @@ import java.util.Set;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyCollection;
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.verifyNoInteractions;
@@ -53,9 +55,21 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(220L, 620L, 700L));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any());
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), anyBoolean(), any());
}
@Test
void refresh_StoresWhetherTheGameHasNoEnd() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTags(Set.of(427520L))).thenReturn(Optional.of(Map.of(
427520L, new IgdbApiService.GameTags(List.of("Simulator", "Strategy"), List.of("Sandbox"), List.of()))));
service.refresh(List.of(427520L));
verify(gameMoodsRepository).upsert(eq(427520L), any(), eq(true), any(Instant.class));
}
@Test
@@ -67,7 +81,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refreshBacklogInBackground(7L);
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), isNull(), any(Instant.class));
}
@Test
@@ -79,7 +93,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(-119388L));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), eq(false), any(Instant.class));
}
@Test
@@ -90,7 +104,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L));
verify(gameMoodsRepository, never()).upsert(any(), any(), any());
verify(gameMoodsRepository, never()).upsert(any(), any(), anyBoolean(), any());
}
@Test
@@ -383,4 +383,29 @@ class SteamApiServiceTest {
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
.isInstanceOf(RestClientException.class);
}
@Test
void getGlobalAchievementPercentages_ReadsStringsAndNumbers() {
server.expect(requestTo(containsString("/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/")))
.andExpect(queryParam("gameid", "620"))
.andRespond(withSuccess("""
{"achievementpercentages":{"achievements":[
{"name":"ACH.WAKE_UP","percent":"64.8"},
{"name":"ACH.LASER","percent":62.7},
{"name":"ACH.BROKEN"}
]}}
""", MediaType.APPLICATION_JSON));
assertThat(steamApiService.getGlobalAchievementPercentages(620))
.containsOnly(Map.entry("ACH.WAKE_UP", 64.8), Map.entry("ACH.LASER", 62.7));
}
@Test
void getGlobalAchievementPercentages_AnyFailureIsEmpty() {
server.expect(requestTo(containsString("/GetGlobalAchievementPercentagesForApp/"))).andRespond(withStatus(HttpStatus.FORBIDDEN));
server.expect(requestTo(containsString("/GetGlobalAchievementPercentagesForApp/"))).andRespond(withServerError());
assertThat(steamApiService.getGlobalAchievementPercentages(620)).isEmpty();
assertThat(steamApiService.getGlobalAchievementPercentages(620)).isEmpty();
}
}