From 3c2f40036d2770e6cf4a8ee074fd391af51136d3 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 28 Sep 2026 09:31:39 +0200 Subject: [PATCH] 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. --- readme.md | 12 ++ .../library/api/LibraryController.java | 40 ++++ .../library/api/dto/CorrectionDto.java | 6 + .../library/api/dto/CorrectionMarkDto.java | 7 + .../library/api/dto/GameDetailDto.java | 10 +- .../library/domain/CorrectionField.java | 15 ++ .../library/domain/GameCorrection.java | 55 +++++ .../library/domain/GameCorrectionId.java | 19 ++ .../modules/library/domain/SteamGame.java | 3 + .../repo/GameCorrectionRepository.java | 35 +++ .../library/repo/GameMoodsRepository.java | 42 +++- .../library/repo/GameSpeedrunRepository.java | 27 ++- .../library/repo/SteamGameRepository.java | 11 +- .../service/GameCorrectionService.java | 204 ++++++++++++++++++ .../library/service/GameDetailService.java | 13 +- .../library/service/ImageRepairService.java | 15 +- .../service/InvalidCorrectionException.java | 8 + .../library/service/LibrarySyncWorker.java | 15 +- .../library/service/ManualGameService.java | 4 +- .../library/service/SpeedrunMatch.java | 12 +- .../db/migration/V20__game_correction.sql | 13 ++ .../library/api/GameCorrectionFlowTest.java | 191 ++++++++++++++++ .../library/repo/GameMoodsRepositoryTest.java | 17 ++ .../repo/GameSpeedrunRepositoryTest.java | 16 ++ .../library/repo/SteamGameRepositoryTest.java | 58 +++++ .../GameCorrectionServiceConcurrencyTest.java | 105 +++++++++ .../service/GameCorrectionServiceTest.java | 63 ++++++ .../service/ImageRepairServiceTest.java | 20 +- .../service/LibrarySyncWorkerTest.java | 32 +++ .../service/ManualGameServiceTest.java | 18 ++ 30 files changed, 1066 insertions(+), 20 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/CorrectionDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/CorrectionMarkDto.java create mode 100644 src/main/java/core_service/modules/library/domain/CorrectionField.java create mode 100644 src/main/java/core_service/modules/library/domain/GameCorrection.java create mode 100644 src/main/java/core_service/modules/library/domain/GameCorrectionId.java create mode 100644 src/main/java/core_service/modules/library/repo/GameCorrectionRepository.java create mode 100644 src/main/java/core_service/modules/library/service/GameCorrectionService.java create mode 100644 src/main/java/core_service/modules/library/service/InvalidCorrectionException.java create mode 100644 src/main/resources/db/migration/V20__game_correction.sql create mode 100644 src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java create mode 100644 src/test/java/core_service/modules/library/repo/SteamGameRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java create mode 100644 src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java diff --git a/readme.md b/readme.md index df28e92..9817bbc 100644 --- a/readme.md +++ b/readme.md @@ -95,6 +95,7 @@ method calling another service's method that needs more. |---|---|---| | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `PUT` | `/api/library/games/{appid}/backlog` | JWT | +| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | @@ -206,6 +207,17 @@ The frontend polls `GET /api/library/sync-status` until the run ends. At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table `broken_image`, one row per game and kind). Reports only count for Steam games the user owns. +### Corrections by an auditor + +An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag and genres for +every user (`game_correction`, V20). The value is also written into the cached row (`steam_game`, `game_speedrun`, +`game_mood`), so no read joins the corrections. The refresh writers skip a corrected field: the Steam sync (name), +the image repair (image), the IGDB add (name, image), the mood and speedrun upserts (SQL). **A new writer of these +fields must do the same.** "Back to source" (`DELETE`) writes back the source value from the first correction and +lets the next refresh ask IGDB or speedrun.com again. An image must be on a host of the frontend's CSP. +Known limit: a mood or speedrun refresh that waits on the row lock while an auditor saves can still write the +source value once (its guard sees the state from before the save); the auditor saves again. + ### Deleted accounts Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index e8ced5f..b4a7e88 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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, diff --git a/src/main/java/core_service/modules/library/api/dto/CorrectionDto.java b/src/main/java/core_service/modules/library/api/dto/CorrectionDto.java new file mode 100644 index 0000000..4e11f13 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/CorrectionDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/CorrectionMarkDto.java b/src/main/java/core_service/modules/library/api/dto/CorrectionMarkDto.java new file mode 100644 index 0000000..c4880ba --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/CorrectionMarkDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 1596aef..679b55c 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -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 moods, + // null when not known + Boolean endless, + // IGDB genre names + List genres, + // the fields an auditor corrected + List corrections ) { /** diff --git a/src/main/java/core_service/modules/library/domain/CorrectionField.java b/src/main/java/core_service/modules/library/domain/CorrectionField.java new file mode 100644 index 0000000..80522c4 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CorrectionField.java @@ -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 fromPath(String name) { + return Arrays.stream(values()).filter(field -> field.name().equalsIgnoreCase(name)).findFirst(); + } +} diff --git a/src/main/java/core_service/modules/library/domain/GameCorrection.java b/src/main/java/core_service/modules/library/domain/GameCorrection.java new file mode 100644 index 0000000..4d6f697 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameCorrection.java @@ -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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/GameCorrectionId.java b/src/main/java/core_service/modules/library/domain/GameCorrectionId.java new file mode 100644 index 0000000..4287c3a --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameCorrectionId.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/SteamGame.java b/src/main/java/core_service/modules/library/domain/SteamGame.java index 9b225f1..c5fe20f 100644 --- a/src/main/java/core_service/modules/library/domain/SteamGame.java +++ b/src/main/java/core_service/modules/library/domain/SteamGame.java @@ -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 diff --git a/src/main/java/core_service/modules/library/repo/GameCorrectionRepository.java b/src/main/java/core_service/modules/library/repo/GameCorrectionRepository.java new file mode 100644 index 0000000..a959223 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameCorrectionRepository.java @@ -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 { + + List findByAppidOrderByField(Long appid); + + @Query("select c.appid from GameCorrection c where c.field = :field and c.appid in :appids") + Set findCorrectedAppids(@Param("field") CorrectionField field, @Param("appids") Collection 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); +} diff --git a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java index b99ccc6..fbac355 100644 --- a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java @@ -20,14 +20,50 @@ public interface GameMoodsRepository extends JpaRepository { * @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); } diff --git a/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java b/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java index 0c92a8a..d4d4a73 100644 --- a/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java @@ -16,13 +16,36 @@ public interface GameSpeedrunRepository extends JpaRepository :since") Set findFreshAppids(@Param("appids") Collection 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); } diff --git a/src/main/java/core_service/modules/library/repo/SteamGameRepository.java b/src/main/java/core_service/modules/library/repo/SteamGameRepository.java index c3d6fe7..ae5c816 100644 --- a/src/main/java/core_service/modules/library/repo/SteamGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/SteamGameRepository.java @@ -9,8 +9,17 @@ import org.springframework.transaction.annotation.Transactional; public interface SteamGameRepository extends JpaRepository { + // 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); } diff --git a/src/main/java/core_service/modules/library/service/GameCorrectionService.java b/src/main/java/core_service/modules/library/service/GameCorrectionService.java new file mode 100644 index 0000000..5fe2051 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/GameCorrectionService.java @@ -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 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 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 game = steamGameRepository.findById(appid); + if (game.isEmpty()) { + return Result.NOT_FOUND; + } + Optional 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 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 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; + } +} diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index bbcc742..b9bfbd7 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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 timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid()); Set unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName) .collect(Collectors.toSet()); + Optional 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() ); } } diff --git a/src/main/java/core_service/modules/library/service/ImageRepairService.java b/src/main/java/core_service/modules/library/service/ImageRepairService.java index c9bc3e3..1063e34 100644 --- a/src/main/java/core_service/modules/library/service/ImageRepairService.java +++ b/src/main/java/core_service/modules/library/service/ImageRepairService.java @@ -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 rows, Instant startedAt) { if (rows.isEmpty()) return; - Map games = steamGameRepository.findAllById(rows.stream().map(BrokenImage::getAppid).toList()) - .stream().collect(Collectors.toMap(SteamGame::getAppid, Function.identity())); + List appids = rows.stream().map(BrokenImage::getAppid).toList(); + Map games = steamGameRepository.findAllById(appids).stream() + .collect(Collectors.toMap(SteamGame::getAppid, Function.identity())); + Set corrected = gameCorrectionRepository.findCorrectedAppids(CorrectionField.IMAGE, appids); List 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); } diff --git a/src/main/java/core_service/modules/library/service/InvalidCorrectionException.java b/src/main/java/core_service/modules/library/service/InvalidCorrectionException.java new file mode 100644 index 0000000..1baba51 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/InvalidCorrectionException.java @@ -0,0 +1,8 @@ +package core_service.modules.library.service; + +public class InvalidCorrectionException extends RuntimeException { + + public InvalidCorrectionException(String message) { + super(message); + } +} diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 1ca701b..e17dc95 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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 correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, + games.stream().map(SteamOwnedGamesResponse.Game::appid).filter(Objects::nonNull).map(Integer::longValue).toList()); List 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 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); } diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index df6d6e6..c2b367d 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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); } } diff --git a/src/main/java/core_service/modules/library/service/SpeedrunMatch.java b/src/main/java/core_service/modules/library/service/SpeedrunMatch.java index 41073c5..b9a28b2 100644 --- a/src/main/java/core_service/modules/library/service/SpeedrunMatch.java +++ b/src/main/java/core_service/modules/library/service/SpeedrunMatch.java @@ -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(); diff --git a/src/main/resources/db/migration/V20__game_correction.sql b/src/main/resources/db/migration/V20__game_correction.sql new file mode 100644 index 0000000..666ee09 --- /dev/null +++ b/src/main/resources/db/migration/V20__game_correction.sql @@ -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) +); diff --git a/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java b/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java new file mode 100644 index 0000000..3aba9e6 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java index 58aa9e2..5c6c229 100644 --- a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java index 18411d8..d0894e7 100644 --- a/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/repo/SteamGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/SteamGameRepositoryTest.java new file mode 100644 index 0000000..ddb86ba --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/SteamGameRepositoryTest.java @@ -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"); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java b/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java new file mode 100644 index 0000000..0b85937 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java @@ -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 first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) + .executeWithoutResult(tx -> { + gameCorrectionService.correct(GAME, "name", "Portal Two", 1L); + firstCorrected.countDown(); + pause(); + })); + CompletableFuture 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); + } + } +} diff --git a/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java b/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java new file mode 100644 index 0000000..43a235e --- /dev/null +++ b/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java @@ -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"); + } +} diff --git a/src/test/java/core_service/modules/library/service/ImageRepairServiceTest.java b/src/test/java/core_service/modules/library/service/ImageRepairServiceTest.java index e04cccb..273139b 100644 --- a/src/test/java/core_service/modules/library/service/ImageRepairServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ImageRepairServiceTest.java @@ -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()); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index f240b09..158f14c 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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()); + } } diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index a93c265..6cc0143 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -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); }