feat(library): let auditors correct game data
Build, Test & Push Core Service / quality-check (push) Successful in 1m0s
Build, Test & Push Core Service / docker (push) Successful in 1m25s

An AUDITOR corrects name, header image, speedrun link, moods, endless and genres of a game for every user (V20 game_correction, PUT/DELETE /api/library/games/{appid}/corrections/{field}). The value is also written into the cached row, so reads stay unchanged; the Steam sync, the image repair, the IGDB add and the mood and speedrun upserts skip corrected fields. SteamGame gets @DynamicUpdate so a sync no longer writes back columns it did not change.
This commit is contained in:
lobo committed 2026-09-28 09:31:39 +02:00
1 parent 2596df609e
commit 3c2f40036d
30 files changed
+1066 -20

No files matched your search

@@ -0,0 +1,191 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
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.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.empty;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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;
/**
* Corrections through the real (non-dev) filter chain, so the role in the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GameCorrectionFlowTest extends AbstractIntegrationTest {
private static final long AUDITOR_ID = 4711L;
private static final long GAME = 620L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@BeforeEach
void seed() {
gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll();
gameMoodsRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
UserGame owned = new UserGame(AUDITOR_ID, GAME, 60, Instant.now(), Instant.now());
// Hidden: the details make no calls to Steam, IGDB or speedrun.com.
owned.setHiddenAt(Instant.now());
userGameRepository.save(owned);
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, false, Instant.now());
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
}
private static String token(Role role) {
return Jwts.builder()
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/corrections/" + field)
.header("Authorization", "Bearer " + token(role)).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"value\":" + valueJson + "}"));
}
private ResultActions reset(String field) throws Exception {
return mockMvc.perform(delete("/api/library/games/" + GAME + "/corrections/" + field)
.header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private ResultActions details() throws Exception {
return mockMvc.perform(get("/api/library/games/" + GAME).header("Authorization", "Bearer " + token(Role.USER)));
}
@Test
void name_IsCorrectedForEveryoneAndGoesBackToTheSource() throws Exception {
correct(Role.AUDITOR, GAME, "name", "\" Portal Two \"").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.name").value("Portal Two"))
.andExpect(jsonPath("$.corrections[0].field").value("NAME"));
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME).getFirst().getCorrectedBy()).isEqualTo(AUDITOR_ID);
reset("name").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.name").value("Portal 2"))
.andExpect(jsonPath("$.corrections", empty()));
}
@Test
void moodsAndSpeedrun_SurviveTheNextRefresh() throws Exception {
correct(Role.AUDITOR, GAME, "moods", "[\"COOP\", \"CHILL\"]").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "speedrun", "null").andExpect(status().isNoContent());
gameMoodsRepository.upsert(GAME, new String[]{"ACTION"}, new String[]{"Shooter"}, true, Instant.now());
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
details().andExpect(jsonPath("$.moods", contains("CHILL", "COOP")))
.andExpect(jsonPath("$.genres", contains("Shooter")))
.andExpect(jsonPath("$.speedrunUrl").doesNotExist());
}
@Test
void reset_WritesTheSourceBackAndAsksTheSourceAgain() throws Exception {
correct(Role.AUDITOR, GAME, "moods", "[\"COOP\"]").andExpect(status().isNoContent());
reset("moods").andExpect(status().isNoContent());
GameMoods stored = gameMoodsRepository.findById(GAME).orElseThrow();
assertThat(stored.getMoods()).containsExactly("STORY");
assertThat(stored.getCheckedAt()).isEqualTo(Instant.EPOCH);
}
@Test
void genresAndEndless_AreStored() throws Exception {
correct(Role.AUDITOR, GAME, "genres", "[\"Puzzle\", \"Platform\", \"Puzzle\"]").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "endless", "true").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.genres", contains("Platform", "Puzzle")))
.andExpect(jsonPath("$.endless").value(true));
}
@Test
void roles_OnlyAuditorsAndAdminsCorrect() throws Exception {
correct(Role.USER, GAME, "name", "\"Hacked\"").andExpect(status().isForbidden());
correct(Role.USER, GAME, "rating", "5").andExpect(status().isForbidden());
mockMvc.perform(delete("/api/library/games/" + GAME + "/corrections/name")
.header("Authorization", "Bearer " + token(Role.USER)).header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isForbidden());
mockMvc.perform(put("/api/library/games/" + GAME + "/corrections/name").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"value\":\"x\"}"))
.andExpect(status().isUnauthorized());
correct(Role.PLATFORM_ADMIN, GAME, "name", "\"Portal Two\"").andExpect(status().isNoContent());
}
@Test
void reset_RestoresNullSourcesAndAsksSpeedrunComAgain() throws Exception {
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, null, Instant.now());
correct(Role.AUDITOR, GAME, "image", "\"https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg\"")
.andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "endless", "true").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "speedrun", "\"https://www.speedrun.com/portal\"").andExpect(status().isNoContent());
reset("image").andExpect(status().isNoContent());
reset("endless").andExpect(status().isNoContent());
reset("speedrun").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.imageUrl").doesNotExist())
.andExpect(jsonPath("$.endless").doesNotExist())
.andExpect(jsonPath("$.speedrunUrl").value("https://www.speedrun.com/portal_2"))
.andExpect(jsonPath("$.corrections", empty()));
assertThat(gameSpeedrunRepository.findById(GAME).orElseThrow().getCheckedAt()).isEqualTo(Instant.EPOCH);
}
@Test
void badInput_IsRefused() throws Exception {
correct(Role.AUDITOR, 999_999L, "name", "\"x\"").andExpect(status().isNotFound());
correct(Role.AUDITOR, GAME, "rating", "5").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_CORRECTION"));
correct(Role.AUDITOR, GAME, "image", "\"https://evil.example.com/a.jpg\"").andExpect(status().isBadRequest());
correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").andExpect(status().isBadRequest());
correct(Role.AUDITOR, GAME, "name", "\" \"").andExpect(status().isBadRequest());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameMoods;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
@@ -67,4 +68,20 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
@Test
void upsert_KeepsACorrectedColumnAndRefreshesTheOthers() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL"}, new String[]{"Puzzle"}, false, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'MOODS', '[\"COOP\"]', '[\"CHILL\"]', 7, now())").executeUpdate();
gameMoodsRepository.upsert(620L, new String[]{"ACTION"}, new String[]{"Shooter"}, true, Instant.now());
entityManager.clear();
GameMoods stored = gameMoodsRepository.findById(620L).orElseThrow();
assertThat(stored.getMoods()).containsExactly("CHILL");
assertThat(stored.getGenres()).containsExactly("Shooter");
assertThat(stored.getEndless()).isTrue();
assertThat(stored.getCheckedAt()).isAfter(Instant.EPOCH);
}
}
@@ -48,4 +48,20 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
assertThat(gameSpeedrunRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
@Test
void upsert_KeepsACorrectedLink() {
gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'SPEEDRUN', 'null', '\"https://www.speedrun.com/portal\"', 7, now())")
.executeUpdate();
gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
entityManager.clear();
GameSpeedrun row = gameSpeedrunRepository.findById(620L).orElseThrow();
assertThat(row.getWeblink()).isNull();
assertThat(row.getSpeedrunGameId()).isNull();
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
}
}
@@ -0,0 +1,58 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.SteamGame;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class SteamGameRepositoryTest extends AbstractPersistenceTest {
private static final String REPAIRED = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/abc/header.jpg";
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private EntityManager entityManager;
private String storedImage(long appid) {
entityManager.clear();
return steamGameRepository.findById(appid).orElseThrow().getImageUrl();
}
@Test
void setImageUrl_KeepsAnAuditorsImage() {
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(400L, "Portal", null, Instant.now()));
entityManager.flush();
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'IMAGE', 'null', 'null', 7, now())").executeUpdate();
steamGameRepository.setImageUrl(620L, REPAIRED);
steamGameRepository.setImageUrl(400L, REPAIRED);
assertThat(storedImage(620L)).isNull();
assertThat(storedImage(400L)).isEqualTo(REPAIRED);
}
@Test
void setSourceName_KeepsAnAuditorsName() {
steamGameRepository.save(new SteamGame(620L, "Portal Two", null, Instant.now()));
steamGameRepository.save(new SteamGame(400L, "Portal", null, Instant.now()));
entityManager.flush();
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'NAME', '\"Portal Two\"', '\"Portal 2\"', 7, now())").executeUpdate();
steamGameRepository.setSourceName(620L, "Portal 2");
steamGameRepository.setSourceName(400L, "Portal: Still Alive");
entityManager.clear();
assertThat(steamGameRepository.findById(620L).orElseThrow().getName()).isEqualTo("Portal Two");
assertThat(steamGameRepository.findById(400L).orElseThrow().getName()).isEqualTo("Portal: Still Alive");
}
}
@@ -0,0 +1,105 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest {
private static final long GAME = 620L;
private static final String IMAGE = "https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg";
@Autowired
private GameCorrectionService gameCorrectionService;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
@AfterEach
void clean() {
jdbcTemplate.update("delete from game_correction where appid = ?", GAME);
if (steamGameRepository.findById(GAME).isEmpty()) {
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
}
jdbcTemplate.update("update steam_game set name = 'Portal 2', image_url = null where appid = ?", GAME);
}
@Test
void twoAuditorsCorrectTheSameFieldForTheFirstTime_BothSucceed() {
CountDownLatch firstCorrected = new CountDownLatch(1);
// The first commits a moment after the second has started, so both see no row before either commits.
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
gameCorrectionService.correct(GAME, "name", "Portal Two", 1L);
firstCorrected.countDown();
pause();
}));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstCorrected);
gameCorrectionService.correct(GAME, "name", "Portal II", 2L);
});
assertDoesNotThrow(() -> CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS));
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME)).hasSize(1);
}
@Test
void aCorrectionCommittedDuringASync_SurvivesTheSync() {
new TransactionTemplate(transactionManager).executeWithoutResult(tx -> {
// What LibrarySyncWorker.upsertGame does for every owned game.
SteamGame existing = steamGameRepository.findById(GAME).orElseThrow();
existing.setImgIconUrl("icon");
existing.setLastSeenAt(Instant.now());
assertDoesNotThrow(() -> CompletableFuture.runAsync(() ->
gameCorrectionService.correct(GAME, "image", IMAGE, 1L)).get(10, TimeUnit.SECONDS));
});
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME)).extracting("field").containsExactly(CorrectionField.IMAGE);
assertThat(steamGameRepository.findById(GAME).orElseThrow().getImageUrl()).isEqualTo(IMAGE);
}
private static void pause() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,63 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameCorrectionServiceTest extends AbstractUnitTest {
private static void refused(CorrectionField field, Object value) {
assertThatThrownBy(() -> GameCorrectionService.validate(field, value))
.as(field + " " + value).isInstanceOf(InvalidCorrectionException.class);
}
@Test
void image_OnlyHttpsOnTheAllowedHosts() {
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/header.jpg")).isNotNull();
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
"https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg")).isNotNull();
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE, null)).isNull();
refused(CorrectionField.IMAGE, "http://images.igdb.com/a.jpg");
refused(CorrectionField.IMAGE, "https://steamstatic.com.evil.example/a.jpg");
refused(CorrectionField.IMAGE, "not a url");
}
@Test
void speedrun_OnlyAPageOnSpeedrunCom() {
assertThat(GameCorrectionService.validate(CorrectionField.SPEEDRUN, "https://www.speedrun.com/portal_2"))
.isEqualTo("https://www.speedrun.com/portal_2");
assertThat(GameCorrectionService.validate(CorrectionField.SPEEDRUN, null)).isNull();
refused(CorrectionField.SPEEDRUN, "https://www.speedrun.com/");
refused(CorrectionField.SPEEDRUN, "https://speedrun.com.evil.example/portal");
refused(CorrectionField.SPEEDRUN, 42);
}
@Test
void lists_AreSortedWithoutDuplicatesAndLimited() {
assertThat(GameCorrectionService.validate(CorrectionField.MOODS, List.of("COOP", "CHILL", "COOP")))
.isEqualTo(List.of("CHILL", "COOP"));
assertThat(GameCorrectionService.validate(CorrectionField.MOODS, List.of())).isEqualTo(List.of());
refused(CorrectionField.MOODS, List.of("SPEEDRUN"));
refused(CorrectionField.MOODS, List.of("chill"));
refused(CorrectionField.MOODS, "CHILL");
refused(CorrectionField.GENRES, Collections.nCopies(11, "x").stream().map(g -> g + Math.random()).toList());
refused(CorrectionField.GENRES, List.of(" "));
refused(CorrectionField.GENRES, List.of(1));
}
@Test
void nameAndEndless() {
assertThat(GameCorrectionService.validate(CorrectionField.NAME, " Portal 2 ")).isEqualTo("Portal 2");
refused(CorrectionField.NAME, null);
refused(CorrectionField.NAME, "x".repeat(513));
assertThat(GameCorrectionService.validate(CorrectionField.ENDLESS, null)).isNull();
refused(CorrectionField.ENDLESS, "true");
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.ImageKind;
@@ -8,6 +9,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService;
@@ -22,6 +24,7 @@ import java.time.Instant;
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.anyInt;
@@ -48,6 +51,8 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private GameCorrectionRepository gameCorrectionRepository;
@Mock
private GameAchievementSchemaRepository schemaRepository;
@Mock
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@@ -62,7 +67,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, schemaRepository,
service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, gameCorrectionRepository, schemaRepository,
schemaMetaRepository, achievementSyncService, steamApiService, steamCdnService, BASE, 200, 3, 30);
}
@@ -281,4 +286,17 @@ class ImageRepairServiceTest extends AbstractUnitTest {
// Clear the flag for the next test on this thread.
org.assertj.core.api.Assertions.assertThat(Thread.interrupted()).isTrue();
}
@Test
void repairNightly_ABrokenCorrectedCoverIsKeptAndCountsAnAttempt() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.IMAGE), any())).thenReturn(Set.of(620L));
when(steamCdnService.isAvailable(FIXED_620)).thenReturn(false);
service.repairNightly();
verify(brokenImageRepository, never()).deleteReportedUpTo(anyLong(), anyString(), any());
verifyNoInteractions(steamApiService);
verify(steamGameRepository, never()).setImageUrl(anyLong(), anyString());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
@@ -7,6 +8,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -32,6 +34,7 @@ import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -40,6 +43,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
@@ -91,6 +95,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private GameCorrectionRepository gameCorrectionRepository;
@Mock
private UserGameRepository userGameRepository;
@@ -541,4 +548,29 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertThat(stored.getName()).isEqualTo("El Mucho");
}
@Test
void runSync_NewName_GoesThroughTheGuardedUpdate() {
SteamGame stored = new SteamGame(220L, "Half-Life 2: Corrected", null, Instant.EPOCH);
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(stored));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
// The update keeps an auditor's name (SteamGameRepositoryTest); the entity itself is not renamed.
verify(steamGameRepository).setSourceName(220L, "Half-Life 2");
assertThat(stored.getName()).isEqualTo("Half-Life 2: Corrected");
assertThat(stored.getImgIconUrl()).isEqualTo("icon");
}
@Test
void runSync_CorrectedName_SendsNoUpdate() {
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(new SteamGame(220L, "HL2 (corrected)", null, Instant.EPOCH)));
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.NAME), any())).thenReturn(Set.of(220L));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
verify(steamGameRepository, never()).setSourceName(anyLong(), anyString());
}
}
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -12,6 +13,7 @@ import org.mockito.Mock;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -84,6 +86,22 @@ class ManualGameServiceTest extends AbstractUnitTest {
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
}
@Test
void add_KnownGameOffSteam_UpdatesNameAndImageThroughTheGuardedUpdates() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
SteamGame stored = new SteamGame(-119388L, "Zelda (corrected)", null, Instant.EPOCH);
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.of(stored));
service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
// Both updates keep an auditor's value (SteamGameRepositoryTest); the entity is not changed.
verify(steamGameRepository).setSourceName(-119388L, ZELDA.name());
verify(steamGameRepository).setImageUrl(-119388L, ZELDA.imageUrl());
assertThat(stored.getName()).isEqualTo("Zelda (corrected)");
}
private static IgdbApiService.Game game(long igdbId, String name) {
return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null);
}