Public Access
feat(library): let auditors correct game data
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:
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");
|
||||
}
|
||||
}
|
||||
+105
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user