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
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||
import core_service.modules.library.api.dto.ActivityDto;
|
||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
||||
import core_service.modules.library.api.dto.CorrectionDto;
|
||||
import core_service.modules.library.api.dto.FinishedHintDismissal;
|
||||
import core_service.modules.library.api.dto.GameDto;
|
||||
import core_service.modules.library.api.dto.HiddenUpdateDto;
|
||||
@@ -19,8 +20,10 @@ import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.Mood;
|
||||
import core_service.modules.library.domain.StatGroup;
|
||||
import core_service.modules.library.service.BacklogService;
|
||||
import core_service.modules.library.service.GameCorrectionService;
|
||||
import core_service.modules.library.service.GameDetailService;
|
||||
import core_service.modules.library.service.ImageRepairService;
|
||||
import core_service.modules.library.service.InvalidCorrectionException;
|
||||
import core_service.modules.library.service.LibraryQueryService;
|
||||
import core_service.modules.library.service.LibrarySyncService;
|
||||
import core_service.modules.library.service.SteamProfileService;
|
||||
@@ -35,6 +38,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -70,6 +74,7 @@ public class LibraryController {
|
||||
private final SteamProfileService steamProfileService;
|
||||
private final ImageRepairService imageRepairService;
|
||||
private final UserStatsService userStatsService;
|
||||
private final GameCorrectionService gameCorrectionService;
|
||||
|
||||
@GetMapping("/games-count")
|
||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@@ -156,6 +161,41 @@ public class LibraryController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* An auditor's correction, for every user. 204; the game details show the new value.
|
||||
*/
|
||||
@PutMapping("/games/{appid}/corrections/{field}")
|
||||
public ResponseEntity<?> correctGame(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("appid") Long appid, @PathVariable("field") String field,
|
||||
@RequestBody CorrectionDto correction) {
|
||||
try {
|
||||
return correctionAnswer(gameCorrectionService.correct(appid, field, correction.value(), principal.getIdAsLong()));
|
||||
} catch (InvalidCorrectionException e) {
|
||||
return invalidCorrection(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to the source's value. 204 also when the field had no correction.
|
||||
*/
|
||||
@DeleteMapping("/games/{appid}/corrections/{field}")
|
||||
public ResponseEntity<?> resetCorrection(@PathVariable("appid") Long appid, @PathVariable("field") String field) {
|
||||
try {
|
||||
return correctionAnswer(gameCorrectionService.reset(appid, field));
|
||||
} catch (InvalidCorrectionException e) {
|
||||
return invalidCorrection(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static ResponseEntity<?> correctionAnswer(GameCorrectionService.Result result) {
|
||||
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game"));
|
||||
}
|
||||
|
||||
private static ResponseEntity<?> invalidCorrection(String message) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", "INVALID_CORRECTION", "message", message));
|
||||
}
|
||||
|
||||
@PutMapping("/games/{appid}/backlog")
|
||||
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("appid") Long appid,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
/**
|
||||
* @param value a string (name, image, speedrun), a list of names (moods, genres), a boolean (endless), or null
|
||||
*/
|
||||
public record CorrectionDto(Object value) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.CorrectionField;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record CorrectionMarkDto(CorrectionField field, Instant correctedAt) {}
|
||||
@@ -35,7 +35,15 @@ public record GameDetailDto(
|
||||
// see GameDto#hidden
|
||||
boolean hidden,
|
||||
// null when there is nothing to ask
|
||||
FinishedHintDto finishedHint
|
||||
FinishedHintDto finishedHint,
|
||||
// Mood names without SPEEDRUN (that is speedrunUrl); empty until IGDB was asked
|
||||
List<String> moods,
|
||||
// null when not known
|
||||
Boolean endless,
|
||||
// IGDB genre names
|
||||
List<String> genres,
|
||||
// the fields an auditor corrected
|
||||
List<CorrectionMarkDto> corrections
|
||||
) {
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
public enum CorrectionField {
|
||||
NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES;
|
||||
|
||||
/**
|
||||
* @param name the lower-case name in the URL, e.g. "speedrun"
|
||||
*/
|
||||
public static Optional<CorrectionField> fromPath(String name) {
|
||||
return Arrays.stream(values()).filter(field -> field.name().equalsIgnoreCase(name)).findFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* An auditor's correction of one field of a game, for every user. See V20 for how it is applied.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "game_correction")
|
||||
@IdClass(GameCorrectionId.class)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class GameCorrection {
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Id
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "field")
|
||||
private CorrectionField field;
|
||||
|
||||
/** JSON; null (the JSON text, not SQL NULL) is a valid value, e.g. "no speedrun". */
|
||||
@Column(name = "value")
|
||||
private String value;
|
||||
|
||||
@Column(name = "source_value")
|
||||
private String sourceValue;
|
||||
|
||||
@Column(name = "corrected_by", nullable = false)
|
||||
private Long correctedBy;
|
||||
|
||||
@Column(name = "corrected_at", nullable = false)
|
||||
private Instant correctedAt;
|
||||
|
||||
public GameCorrection(Long appid, CorrectionField field, String sourceValue) {
|
||||
this.appid = appid;
|
||||
this.field = field;
|
||||
this.sourceValue = sourceValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class GameCorrectionId implements Serializable {
|
||||
private Long appid;
|
||||
private CorrectionField field;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AccessLevel;
|
||||
import org.hibernate.annotations.DynamicUpdate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
@@ -12,6 +13,8 @@ import lombok.Setter;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
// Only changed columns: a sync touching the icon must not write back a name or image an auditor changed meanwhile.
|
||||
@DynamicUpdate
|
||||
@Table(name = "steam_game")
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.CorrectionField;
|
||||
import core_service.modules.library.domain.GameCorrection;
|
||||
import core_service.modules.library.domain.GameCorrectionId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public interface GameCorrectionRepository extends JpaRepository<GameCorrection, GameCorrectionId> {
|
||||
|
||||
List<GameCorrection> findByAppidOrderByField(Long appid);
|
||||
|
||||
@Query("select c.appid from GameCorrection c where c.field = :field and c.appid in :appids")
|
||||
Set<Long> findCorrectedAppids(@Param("field") CorrectionField field, @Param("appids") Collection<Long> appids);
|
||||
|
||||
/**
|
||||
* source_value only on the first correction: it stays what the source had before any auditor.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(value = "insert into game_correction (appid, field, value, source_value, corrected_by, corrected_at) "
|
||||
+ "values (:appid, :field, :value, :sourceValue, :correctedBy, :correctedAt) "
|
||||
+ "on conflict (appid, field) do update set value = excluded.value, "
|
||||
+ "corrected_by = excluded.corrected_by, corrected_at = excluded.corrected_at",
|
||||
nativeQuery = true)
|
||||
void upsert(@Param("appid") Long appid, @Param("field") String field, @Param("value") String value,
|
||||
@Param("sourceValue") String sourceValue, @Param("correctedBy") Long correctedBy,
|
||||
@Param("correctedAt") Instant correctedAt);
|
||||
}
|
||||
@@ -20,14 +20,50 @@ public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
|
||||
* @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits
|
||||
* @param genres IGDB genre names; empty when IGDB has none
|
||||
*/
|
||||
// An upsert, not save(): two users' syncs can store the same new game at the same time.
|
||||
// An upsert, not save(): two users' syncs can store the same new game at the same time. A corrected column
|
||||
// keeps the auditor's value.
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
|
||||
"values (:appid, cast(:moods as varchar(16)[]), cast(:genres as varchar(64)[]), :endless, :checkedAt) " +
|
||||
"on conflict (appid) do update set moods = excluded.moods, genres = excluded.genres, " +
|
||||
"endless = excluded.endless, checked_at = excluded.checked_at",
|
||||
"on conflict (appid) do update set " +
|
||||
"moods = case when " + CORRECTED + "'MOODS') then game_mood.moods else excluded.moods end, " +
|
||||
"genres = case when " + CORRECTED + "'GENRES') then game_mood.genres else excluded.genres end, " +
|
||||
"endless = case when " + CORRECTED + "'ENDLESS') then game_mood.endless else excluded.endless end, " +
|
||||
"checked_at = excluded.checked_at",
|
||||
nativeQuery = true)
|
||||
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("genres") String[] genres,
|
||||
@Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
|
||||
|
||||
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_mood.appid and c.field = ";
|
||||
|
||||
// A game IGDB never answered for has no row yet; it gets one with nothing known but the corrected column, and
|
||||
// checkedAt only for that new row (stale, so the next refresh fills the other columns).
|
||||
@Modifying
|
||||
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
|
||||
"values (:appid, cast(:moods as varchar(16)[]), '{}', null, :checkedAt) " +
|
||||
"on conflict (appid) do update set moods = excluded.moods",
|
||||
nativeQuery = true)
|
||||
void writeMoods(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("checkedAt") Instant checkedAt);
|
||||
|
||||
@Modifying
|
||||
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
|
||||
"values (:appid, '{}', cast(:genres as varchar(64)[]), null, :checkedAt) " +
|
||||
"on conflict (appid) do update set genres = excluded.genres",
|
||||
nativeQuery = true)
|
||||
void writeGenres(@Param("appid") Long appid, @Param("genres") String[] genres, @Param("checkedAt") Instant checkedAt);
|
||||
|
||||
@Modifying
|
||||
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
|
||||
"values (:appid, '{}', '{}', :endless, :checkedAt) " +
|
||||
"on conflict (appid) do update set endless = excluded.endless",
|
||||
nativeQuery = true)
|
||||
void writeEndless(@Param("appid") Long appid, @Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
|
||||
|
||||
/**
|
||||
* The next mood refresh asks IGDB again.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update GameMoods m set m.checkedAt = :longAgo where m.appid = :appid")
|
||||
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
|
||||
}
|
||||
@@ -16,13 +16,36 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
|
||||
@Query("select s.appid from GameSpeedrun s where s.appid in :appids and s.checkedAt > :since")
|
||||
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
|
||||
|
||||
// A corrected link keeps the auditor's value.
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " +
|
||||
"values (:appid, :speedrunGameId, :weblink, :checkedAt) " +
|
||||
"on conflict (appid) do update set speedrun_game_id = excluded.speedrun_game_id, " +
|
||||
"weblink = excluded.weblink, checked_at = excluded.checked_at",
|
||||
"on conflict (appid) do update set " +
|
||||
"speedrun_game_id = case when " + CORRECTED + " then game_speedrun.speedrun_game_id " +
|
||||
"else excluded.speedrun_game_id end, " +
|
||||
"weblink = case when " + CORRECTED + " then game_speedrun.weblink else excluded.weblink end, " +
|
||||
"checked_at = excluded.checked_at",
|
||||
nativeQuery = true)
|
||||
void upsert(@Param("appid") Long appid, @Param("speedrunGameId") String speedrunGameId,
|
||||
@Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt);
|
||||
|
||||
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_speedrun.appid and c.field = 'SPEEDRUN')";
|
||||
|
||||
/**
|
||||
* An auditor's link has no speedrun.com game id.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " +
|
||||
"values (:appid, null, :weblink, :checkedAt) " +
|
||||
"on conflict (appid) do update set speedrun_game_id = null, weblink = excluded.weblink",
|
||||
nativeQuery = true)
|
||||
void writeWeblink(@Param("appid") Long appid, @Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt);
|
||||
|
||||
/**
|
||||
* The next speedrun check asks speedrun.com again.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
|
||||
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
|
||||
}
|
||||
@@ -9,8 +9,17 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
|
||||
|
||||
// Only for a name from Steam or IGDB: an auditor's name stays.
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid")
|
||||
@Query("update SteamGame g set g.name = :name where g.appid = :appid and not exists "
|
||||
+ "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.NAME)")
|
||||
void setSourceName(@Param("appid") Long appid, @Param("name") String name);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
// An auditor's image stays.
|
||||
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid and not exists "
|
||||
+ "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.IMAGE)")
|
||||
void setImageUrl(@Param("appid") Long appid, @Param("imageUrl") String imageUrl);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.CorrectionField;
|
||||
import core_service.modules.library.domain.GameCorrection;
|
||||
import core_service.modules.library.domain.GameCorrectionId;
|
||||
import core_service.modules.library.domain.GameMoods;
|
||||
import core_service.modules.library.domain.GameSpeedrun;
|
||||
import core_service.modules.library.domain.Mood;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
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.shared.security.RequiresRole;
|
||||
import core_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* An auditor's corrections of shared game data; V20 says how they are applied.
|
||||
*/
|
||||
@Service
|
||||
@RequiresRole(Role.AUDITOR)
|
||||
@RequiredArgsConstructor
|
||||
public class GameCorrectionService {
|
||||
|
||||
public enum Result { DONE, NOT_FOUND }
|
||||
|
||||
// The image hosts the frontend's Content Security Policy allows (nginx.conf img-src).
|
||||
private static final Set<String> IMAGE_HOSTS = Set.of("steamcdn-a.akamaihd.net", "media.steampowered.com",
|
||||
"images.igdb.com");
|
||||
private static final String IMAGE_HOST_SUFFIX = ".steamstatic.com";
|
||||
private static final int MAX_TEXT = 512;
|
||||
private static final int MAX_GENRES = 10;
|
||||
private static final int MAX_GENRE_LENGTH = 64;
|
||||
// Far enough back that the next refresh asks the source again.
|
||||
private static final Instant LONG_AGO = Instant.EPOCH;
|
||||
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final GameCorrectionRepository correctionRepository;
|
||||
private final GameMoodsRepository gameMoodsRepository;
|
||||
private final GameSpeedrunRepository gameSpeedrunRepository;
|
||||
private final JsonMapper jsonMapper;
|
||||
|
||||
/**
|
||||
* @param fieldName see {@link CorrectionField#fromPath}
|
||||
* @param value a string, a string list, a boolean or null, as the field needs
|
||||
* @throws InvalidCorrectionException for an unknown field or a value that does not fit it
|
||||
*/
|
||||
@Transactional
|
||||
public Result correct(long appid, String fieldName, Object value, long auditorId) {
|
||||
CorrectionField field = field(fieldName);
|
||||
Optional<SteamGame> game = steamGameRepository.findById(appid);
|
||||
if (game.isEmpty()) {
|
||||
return Result.NOT_FOUND;
|
||||
}
|
||||
Object clean = validate(field, value);
|
||||
// One statement, not find-then-save: two auditors' first correction of a field must not collide on the key.
|
||||
correctionRepository.upsert(appid, field.name(), jsonMapper.writeValueAsString(clean),
|
||||
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
|
||||
write(field, game.get(), clean);
|
||||
return Result.DONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to the source: the value the source had at the first correction. Moods and the speedrun link are also
|
||||
* asked again at the next refresh.
|
||||
*/
|
||||
@Transactional
|
||||
public Result reset(long appid, String fieldName) {
|
||||
CorrectionField field = field(fieldName);
|
||||
Optional<SteamGame> game = steamGameRepository.findById(appid);
|
||||
if (game.isEmpty()) {
|
||||
return Result.NOT_FOUND;
|
||||
}
|
||||
Optional<GameCorrection> correction = correctionRepository.findById(new GameCorrectionId(appid, field));
|
||||
if (correction.isEmpty()) {
|
||||
return Result.DONE;
|
||||
}
|
||||
correctionRepository.delete(correction.get());
|
||||
write(field, game.get(), jsonMapper.readValue(correction.get().getSourceValue(), Object.class));
|
||||
switch (field) {
|
||||
case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO);
|
||||
case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO);
|
||||
case NAME, IMAGE -> { }
|
||||
}
|
||||
return Result.DONE;
|
||||
}
|
||||
|
||||
// Here, not in the controller: a USER gets 403 before learning which fields exist.
|
||||
private static CorrectionField field(String name) {
|
||||
return CorrectionField.fromPath(name).orElseThrow(() -> new InvalidCorrectionException("unknown field " + name));
|
||||
}
|
||||
|
||||
private Object cachedValue(CorrectionField field, SteamGame game) {
|
||||
return switch (field) {
|
||||
case NAME -> game.getName();
|
||||
case IMAGE -> game.getImageUrl();
|
||||
case SPEEDRUN -> gameSpeedrunRepository.findById(game.getAppid()).map(GameSpeedrun::getWeblink).orElse(null);
|
||||
case MOODS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getMoods).orElse(List.of());
|
||||
case GENRES -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getGenres).orElse(List.of());
|
||||
case ENDLESS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getEndless).orElse(null);
|
||||
};
|
||||
}
|
||||
|
||||
private void write(CorrectionField field, SteamGame game, Object value) {
|
||||
switch (field) {
|
||||
case NAME -> game.setName((String) value);
|
||||
case IMAGE -> game.setImageUrl((String) value);
|
||||
// A new row has only the corrected link, so the next check would ask speedrun.com for nothing.
|
||||
case SPEEDRUN -> gameSpeedrunRepository.writeWeblink(game.getAppid(), (String) value, Instant.now());
|
||||
case MOODS -> gameMoodsRepository.writeMoods(game.getAppid(), strings(value), LONG_AGO);
|
||||
case GENRES -> gameMoodsRepository.writeGenres(game.getAppid(), strings(value), LONG_AGO);
|
||||
case ENDLESS -> gameMoodsRepository.writeEndless(game.getAppid(), (Boolean) value, LONG_AGO);
|
||||
}
|
||||
}
|
||||
|
||||
private static String[] strings(Object list) {
|
||||
return ((List<?>) list).stream().map(String.class::cast).toArray(String[]::new);
|
||||
}
|
||||
|
||||
static Object validate(CorrectionField field, Object value) {
|
||||
return switch (field) {
|
||||
case NAME -> {
|
||||
if (!(value instanceof String name) || name.isBlank() || name.strip().length() > MAX_TEXT) {
|
||||
throw new InvalidCorrectionException("name must be 1 to " + MAX_TEXT + " characters");
|
||||
}
|
||||
yield name.strip();
|
||||
}
|
||||
case IMAGE -> {
|
||||
if (value == null) {
|
||||
yield null;
|
||||
}
|
||||
if (!(value instanceof String url) || url.length() > MAX_TEXT || !isAllowedImage(url)) {
|
||||
throw new InvalidCorrectionException("image must be an https URL on " + IMAGE_HOSTS
|
||||
+ " or *" + IMAGE_HOST_SUFFIX);
|
||||
}
|
||||
yield url;
|
||||
}
|
||||
case SPEEDRUN -> {
|
||||
if (value == null) {
|
||||
yield null;
|
||||
}
|
||||
if (!(value instanceof String url) || !SpeedrunMatch.isWeblink(url)) {
|
||||
throw new InvalidCorrectionException("speedrun must be a page on " + SpeedrunMatch.WEBLINK_PREFIX);
|
||||
}
|
||||
yield url;
|
||||
}
|
||||
case MOODS -> textList(value, "moods").stream()
|
||||
.map(GameCorrectionService::storedMood).distinct().sorted().toList();
|
||||
case GENRES -> {
|
||||
List<String> genres = textList(value, "genres").stream().map(String::strip).distinct().sorted().toList();
|
||||
if (genres.size() > MAX_GENRES || genres.stream().anyMatch(g -> g.isEmpty() || g.length() > MAX_GENRE_LENGTH)) {
|
||||
throw new InvalidCorrectionException("genres: at most " + MAX_GENRES + " names of 1 to "
|
||||
+ MAX_GENRE_LENGTH + " characters");
|
||||
}
|
||||
yield genres;
|
||||
}
|
||||
case ENDLESS -> {
|
||||
if (value != null && !(value instanceof Boolean)) {
|
||||
throw new InvalidCorrectionException("endless must be true, false or null");
|
||||
}
|
||||
yield value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<String> textList(Object value, String name) {
|
||||
if (!(value instanceof List<?> list) || list.stream().anyMatch(item -> !(item instanceof String))) {
|
||||
throw new InvalidCorrectionException(name + " must be a list of names");
|
||||
}
|
||||
return list.stream().map(String.class::cast).toList();
|
||||
}
|
||||
|
||||
private static boolean isAllowedImage(String url) {
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
String host = uri.getHost();
|
||||
return "https".equals(uri.getScheme()) && host != null
|
||||
&& (IMAGE_HOSTS.contains(host) || host.endsWith(IMAGE_HOST_SUFFIX));
|
||||
} catch (URISyntaxException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SPEEDRUN comes from the speedrun link, never from the stored moods.
|
||||
private static String storedMood(String name) {
|
||||
boolean known = Arrays.stream(Mood.values()).anyMatch(mood -> mood != Mood.SPEEDRUN && mood.name().equals(name));
|
||||
if (!known) {
|
||||
throw new InvalidCorrectionException("moods: unknown mood " + name + " (SPEEDRUN is the speedrun link)");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.AchievementDto;
|
||||
import core_service.modules.library.api.dto.CorrectionMarkDto;
|
||||
import core_service.modules.library.api.dto.FinishedHintDto;
|
||||
import core_service.modules.library.api.dto.GameDetailDto;
|
||||
import core_service.modules.library.domain.AchievementProgress;
|
||||
@@ -17,6 +18,7 @@ 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.GameCorrectionRepository;
|
||||
import core_service.modules.library.repo.GameMoodsRepository;
|
||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||
@@ -59,6 +61,7 @@ public class GameDetailService {
|
||||
private final GameSpeedrunRepository gameSpeedrunRepository;
|
||||
private final SpeedrunService speedrunService;
|
||||
private final GameMoodsRepository gameMoodsRepository;
|
||||
private final GameCorrectionRepository gameCorrectionRepository;
|
||||
|
||||
@Value("${app.library.achievement-ttl-hours:1}")
|
||||
private long achievementTtlHours;
|
||||
@@ -156,10 +159,11 @@ public class GameDetailService {
|
||||
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
|
||||
Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName)
|
||||
.collect(Collectors.toSet());
|
||||
Optional<GameMoods> moods = gameMoodsRepository.findById(userGame.getAppid());
|
||||
FinishedHintDto finishedHint = FinishedHint.of(new FinishedHint.Facts(userGame.getStatus(), userGame.getSource(),
|
||||
schema, unlockedNames, statsVisible, userGame.getPlaytimeMinutes(),
|
||||
timeToBeat.map(GameTimeToBeat::getNormallyMinutes).orElse(null),
|
||||
gameMoodsRepository.findById(userGame.getAppid()).map(GameMoods::getEndless).orElse(null),
|
||||
moods.map(GameMoods::getEndless).orElse(null),
|
||||
userGame.getFinishedHintDismissed()))
|
||||
.map(hint -> new FinishedHintDto(hint.type(), hint.achievement()))
|
||||
.orElse(null);
|
||||
@@ -193,7 +197,12 @@ public class GameDetailService {
|
||||
meta != null ? meta.getImageUrl() : null,
|
||||
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null),
|
||||
userGame.getHiddenAt() != null,
|
||||
finishedHint
|
||||
finishedHint,
|
||||
moods.map(GameMoods::getMoods).orElse(List.of()),
|
||||
moods.map(GameMoods::getEndless).orElse(null),
|
||||
moods.map(GameMoods::getGenres).orElse(List.of()),
|
||||
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
|
||||
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.GameKeys;
|
||||
@@ -9,6 +10,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;
|
||||
@@ -44,6 +46,7 @@ public class ImageRepairService {
|
||||
private final BrokenImageRepository brokenImageRepository;
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final GameCorrectionRepository gameCorrectionRepository;
|
||||
private final GameAchievementSchemaRepository schemaRepository;
|
||||
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
private final AchievementSyncService achievementSyncService;
|
||||
@@ -59,6 +62,7 @@ public class ImageRepairService {
|
||||
public ImageRepairService(BrokenImageRepository brokenImageRepository,
|
||||
UserGameRepository userGameRepository,
|
||||
SteamGameRepository steamGameRepository,
|
||||
GameCorrectionRepository gameCorrectionRepository,
|
||||
GameAchievementSchemaRepository schemaRepository,
|
||||
GameAchievementSchemaMetaRepository schemaMetaRepository,
|
||||
AchievementSyncService achievementSyncService,
|
||||
@@ -71,6 +75,7 @@ public class ImageRepairService {
|
||||
this.brokenImageRepository = brokenImageRepository;
|
||||
this.userGameRepository = userGameRepository;
|
||||
this.steamGameRepository = steamGameRepository;
|
||||
this.gameCorrectionRepository = gameCorrectionRepository;
|
||||
this.schemaRepository = schemaRepository;
|
||||
this.schemaMetaRepository = schemaMetaRepository;
|
||||
this.achievementSyncService = achievementSyncService;
|
||||
@@ -121,8 +126,10 @@ public class ImageRepairService {
|
||||
|
||||
private void repairCovers(List<BrokenImage> rows, Instant startedAt) {
|
||||
if (rows.isEmpty()) return;
|
||||
Map<Long, SteamGame> games = steamGameRepository.findAllById(rows.stream().map(BrokenImage::getAppid).toList())
|
||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, Function.identity()));
|
||||
List<Long> appids = rows.stream().map(BrokenImage::getAppid).toList();
|
||||
Map<Long, SteamGame> games = steamGameRepository.findAllById(appids).stream()
|
||||
.collect(Collectors.toMap(SteamGame::getAppid, Function.identity()));
|
||||
Set<Long> corrected = gameCorrectionRepository.findCorrectedAppids(CorrectionField.IMAGE, appids);
|
||||
List<BrokenImage> broken = new ArrayList<>();
|
||||
for (BrokenImage row : rows) {
|
||||
SteamGame game = games.get(row.getAppid());
|
||||
@@ -130,6 +137,10 @@ public class ImageRepairService {
|
||||
? game.getImageUrl() : steamImageBaseUrl + row.getAppid() + "/header.jpg";
|
||||
if (steamCdnService.isAvailable(current)) {
|
||||
delete(row);
|
||||
} else if (corrected.contains(row.getAppid())) {
|
||||
// An auditor chose this image; setImageUrl keeps it, so only an auditor can fix it.
|
||||
log.warn("Corrected cover of app {} does not load: {}", row.getAppid(), current);
|
||||
markChecked(row, startedAt);
|
||||
} else {
|
||||
broken.add(row);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
public class InvalidCorrectionException extends RuntimeException {
|
||||
|
||||
public InvalidCorrectionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
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.SteamGame;
|
||||
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;
|
||||
@@ -29,6 +31,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -47,6 +50,7 @@ public class LibrarySyncWorker {
|
||||
|
||||
private final SteamApiService steamApiService;
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final GameCorrectionRepository gameCorrectionRepository;
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
@@ -109,10 +113,13 @@ public class LibrarySyncWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
// setSourceName keeps them anyway; skipping saves an update and a flush per corrected game and sync.
|
||||
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME,
|
||||
games.stream().map(SteamOwnedGamesResponse.Game::appid).filter(Objects::nonNull).map(Integer::longValue).toList());
|
||||
List<Long> played = new ArrayList<>();
|
||||
for (SteamOwnedGamesResponse.Game g : games) {
|
||||
if (g.appid() == null) continue;
|
||||
upsertGame(g, syncStartedAt);
|
||||
upsertGame(g, syncStartedAt, correctedNames);
|
||||
if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) {
|
||||
played.add(g.appid().longValue());
|
||||
}
|
||||
@@ -175,7 +182,7 @@ public class LibrarySyncWorker {
|
||||
}).toList());
|
||||
}
|
||||
|
||||
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) {
|
||||
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now, Set<Long> correctedNames) {
|
||||
Long appid = g.appid().longValue();
|
||||
SteamGame existing = steamGameRepository.findById(appid).orElse(null);
|
||||
// An unvetted app can come without a name, and the name column is NOT NULL.
|
||||
@@ -183,7 +190,9 @@ public class LibrarySyncWorker {
|
||||
if (existing == null) {
|
||||
steamGameRepository.save(new SteamGame(appid, hasName ? g.name() : SteamGame.placeholderName(appid), g.imgIconUrl(), now));
|
||||
} else {
|
||||
if (hasName) existing.setName(g.name());
|
||||
if (hasName && !g.name().equals(existing.getName()) && !correctedNames.contains(appid)) {
|
||||
steamGameRepository.setSourceName(appid, g.name());
|
||||
}
|
||||
existing.setImgIconUrl(g.imgIconUrl());
|
||||
existing.setLastSeenAt(now);
|
||||
}
|
||||
|
||||
@@ -222,8 +222,8 @@ public class ManualGameService {
|
||||
}
|
||||
steamGameRepository.save(created);
|
||||
} else if (!GameKeys.isSteam(appid)) {
|
||||
existing.get().setName(game.name());
|
||||
existing.get().setImageUrl(game.imageUrl());
|
||||
steamGameRepository.setSourceName(appid, game.name());
|
||||
steamGameRepository.setImageUrl(appid, game.imageUrl());
|
||||
existing.get().setLastSeenAt(now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ final class SpeedrunMatch {
|
||||
private SpeedrunMatch() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The weblink goes to the browser as a link; only speedrun.com's own pages may get there.
|
||||
*/
|
||||
static boolean isWeblink(String url) {
|
||||
return url != null && url.startsWith(WEBLINK_PREFIX) && url.length() > WEBLINK_PREFIX.length()
|
||||
&& url.length() <= MAX_WEBLINK_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param years our release years of the game; empty when unknown
|
||||
* @param hits in speedrun.com's order
|
||||
@@ -41,9 +49,7 @@ final class SpeedrunMatch {
|
||||
if (ours.year() != null) ourYears.add(ours.year());
|
||||
return hits.stream()
|
||||
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
|
||||
// The weblink goes to the browser as a link; only speedrun.com's own pages may get there.
|
||||
.filter(hit -> hit.weblink() != null && hit.weblink().startsWith(WEBLINK_PREFIX)
|
||||
&& hit.weblink().length() <= MAX_WEBLINK_LENGTH)
|
||||
.filter(hit -> isWeblink(hit.weblink()))
|
||||
.filter(hit -> {
|
||||
NameAndYear theirs = split(hit.name());
|
||||
Integer year = theirs.year() != null ? theirs.year() : hit.year();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- An auditor's correction of shared game data. The value is also written into the cached row (steam_game,
|
||||
-- game_speedrun, game_mood), so reads need no join; the refresh writers skip a corrected field. value and
|
||||
-- source_value are JSON (a string, a string list, a boolean or null); source_value is what the source had at the
|
||||
-- first correction, written back by "back to source".
|
||||
CREATE TABLE game_correction (
|
||||
appid BIGINT NOT NULL,
|
||||
field VARCHAR(16) NOT NULL,
|
||||
value TEXT,
|
||||
source_value TEXT,
|
||||
corrected_by BIGINT NOT NULL,
|
||||
corrected_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (appid, field)
|
||||
);
|
||||
@@ -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