diff --git a/readme.md b/readme.md index a2d8f59..48cff89 100644 --- a/readme.md +++ b/readme.md @@ -136,10 +136,12 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | -| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | +| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | +| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | +| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR | -| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt}`; `action` is `GAME_MERGE`, `GAME_LINK`, `GAME_UNLINK` or `GAME_LINK_DISMISS`; size 20, 40 or 80) | JWT, AUDITOR | +| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt}`; `action` is `GAME_MERGE`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR | | `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT | @@ -311,13 +313,17 @@ a restart. ### Corrections by an auditor -An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag and genres for +An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag, genres and time to beat 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). `SteamGame` has no +the image repair (image), the IGDB add (name, image), the mood, speedrun and time to beat upserts (SQL). `SteamGame` has no setter for name and image: `applyCorrection` is the only Java writer, a refresh uses the guarded updates in `SteamGameRepository`. "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. +The time to beat is one field for both times (`normallyMinutes`, `completelyMinutes`, each empty or 1 to 60000, +completely not below normally) written into `game_time_to_beat`, so pile hours and stats use it; the IGDB `hastily` +time is not correctable. Every correction and every reset writes `admin_audit` (`GAME_CORRECT`, +`GAME_CORRECTION_RESET`). `GET /api/library/catalog` lets an auditor find and correct a game they do not own. 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. `save(new SteamGame(...))` on a stored key also overwrites a corrected name; the sync and the IGDB add do it only after `findById` found no row. 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 542d919..f73ad49 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -7,6 +7,10 @@ import core_service.modules.library.api.dto.BacklogPatch; import core_service.modules.library.api.dto.AuditEntryDto; import core_service.modules.library.api.dto.BrokenImageReport; import core_service.modules.library.api.dto.CatalogGameDto; +import core_service.modules.library.api.dto.CatalogGameDetailDto; +import core_service.modules.library.api.dto.CatalogHitDto; +import core_service.modules.library.api.dto.CorrectionMarkDto; +import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.api.dto.CorrectionDto; import core_service.modules.library.api.dto.FinishedHintDismissal; import core_service.modules.library.api.dto.GameDto; @@ -25,6 +29,8 @@ import core_service.modules.library.api.dto.UserStatsDto; import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.AuditEntry; import core_service.modules.library.domain.CatalogGame; +import core_service.modules.library.domain.CatalogGameDetail; +import core_service.modules.library.domain.CatalogHit; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.Mood; @@ -44,6 +50,7 @@ import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.UserStatsService; import core_service.shared.security.UserPrincipal; import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -194,14 +201,33 @@ public class LibraryController { * 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) { + public ResponseEntity resetCorrection(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid, @PathVariable("field") String field) { try { - return correctionAnswer(gameCorrectionService.reset(appid, field)); + return correctionAnswer(gameCorrectionService.reset(appid, field, principal.getIdAsLong())); } catch (InvalidCorrectionException e) { return invalidCorrection(e.getMessage()); } } + /** + * Every game some user has, by name part; blank for all. + */ + @GetMapping("/catalog") + public ResponseEntity> searchCatalog(@RequestParam(required = false) @Size(max = 100) String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; + return ResponseEntity.ok(PageDto.of(gameCorrectionService.search(q, PageRequest.of(Math.max(0, page), safeSize)) + .map(LibraryController::toDto))); + } + + @GetMapping("/catalog/{appid}") + public ResponseEntity catalogGame(@PathVariable("appid") Long appid) { + return gameCorrectionService.detail(appid).>map(d -> ResponseEntity.ok(toDto(d))) + .orElseGet(LibraryController::unknownGame); + } + /** * An auditor's merge of two keys of the same game, for every user. */ @@ -283,6 +309,18 @@ public class LibraryController { return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt()); } + private static CatalogGameDetailDto toDto(CatalogGameDetail d) { + var t = d.timeToBeat(); + return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(), + d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), + t.getNormallyMinutes(), t.getCompletelyMinutes()), + d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()); + } + + private static CatalogHitDto toDto(CatalogHit hit) { + return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms()); + } + private static CatalogGameDto toDto(CatalogGame game) { return new CatalogGameDto(game.appid(), game.name(), game.imageUrl()); } diff --git a/src/main/java/core_service/modules/library/api/dto/CatalogGameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/CatalogGameDetailDto.java new file mode 100644 index 0000000..a148ee8 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/CatalogGameDetailDto.java @@ -0,0 +1,18 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +/** + * What the correction dialog reads; the field names are those of {@link GameDetailDto}. + */ +public record CatalogGameDetailDto( + long appid, + String name, + String imageUrl, + String speedrunUrl, + List moods, + Boolean endless, + List genres, + GameDetailDto.TimeToBeatDto timeToBeat, + List corrections +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/CatalogHitDto.java b/src/main/java/core_service/modules/library/api/dto/CatalogHitDto.java new file mode 100644 index 0000000..5ecce37 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/CatalogHitDto.java @@ -0,0 +1,5 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +public record CatalogHitDto(long appid, String name, String imageUrl, List platforms) {} 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 index 4e11f13..631f8c2 100644 --- a/src/main/java/core_service/modules/library/api/dto/CorrectionDto.java +++ b/src/main/java/core_service/modules/library/api/dto/CorrectionDto.java @@ -1,6 +1,7 @@ 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 + * @param value a string (name, image, speedrun), a list of names (moods, genres), a boolean (endless), + * an object {normallyMinutes, completelyMinutes} (time_to_beat), or null */ public record CorrectionDto(Object value) {} diff --git a/src/main/java/core_service/modules/library/domain/CatalogGameDetail.java b/src/main/java/core_service/modules/library/domain/CatalogGameDetail.java new file mode 100644 index 0000000..2f1e29e --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CatalogGameDetail.java @@ -0,0 +1,10 @@ +package core_service.modules.library.domain; + +import java.util.List; + +/** + * What an auditor sees of a game any user has; timeToBeat is null without a row. + */ +public record CatalogGameDetail(long appid, String name, String imageUrl, String speedrunUrl, List moods, + Boolean endless, List genres, GameTimeToBeat timeToBeat, + List corrections) {} diff --git a/src/main/java/core_service/modules/library/domain/CatalogHit.java b/src/main/java/core_service/modules/library/domain/CatalogHit.java new file mode 100644 index 0000000..8ebce3c --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CatalogHit.java @@ -0,0 +1,8 @@ +package core_service.modules.library.domain; + +import java.util.List; + +/** + * A game some user has, as the auditor catalog lists it. + */ +public record CatalogHit(long appid, String name, String imageUrl, List platforms) {} diff --git a/src/main/java/core_service/modules/library/domain/CorrectionField.java b/src/main/java/core_service/modules/library/domain/CorrectionField.java index 80522c4..f085dd1 100644 --- a/src/main/java/core_service/modules/library/domain/CorrectionField.java +++ b/src/main/java/core_service/modules/library/domain/CorrectionField.java @@ -4,7 +4,7 @@ import java.util.Arrays; import java.util.Optional; public enum CorrectionField { - NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES; + NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES, TIME_TO_BEAT; /** * @param name the lower-case name in the URL, e.g. "speedrun" diff --git a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java index e3704eb..4c1e98f 100644 --- a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java +++ b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java @@ -15,7 +15,8 @@ import java.util.List; @RequiredArgsConstructor public class AdminAuditRepository { - public static final List ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS"); + public static final List ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS", + "GAME_CORRECT", "GAME_CORRECTION_RESET"); private final NamedParameterJdbcTemplate jdbc; diff --git a/src/main/java/core_service/modules/library/repo/CatalogRepository.java b/src/main/java/core_service/modules/library/repo/CatalogRepository.java new file mode 100644 index 0000000..9be4f8d --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/CatalogRepository.java @@ -0,0 +1,54 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.CatalogHit; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; + +/** + * The games any user has, for the auditor catalog. + */ +@Repository +@RequiredArgsConstructor +public class CatalogRepository { + + // ponytail: like '%q%' scans steam_game; add a pg_trgm index if the catalog grows past ~100k rows. + private static final String NAME_MATCH = "(cast(:pattern as varchar) is null or lower(g.name) like :pattern escape '\\')"; + + private final NamedParameterJdbcTemplate jdbc; + + /** + * @param query a name part; blank for every game + */ + public List search(String query, int offset, int limit) { + return jdbc.query(""" + select g.appid, g.name, g.image_url, + array_remove(array_agg(distinct case when ug.source = 'STEAM' then 'Steam' else ug.platform end), null) as platforms + from steam_game g join user_game ug on ug.appid = g.appid and ug.removed_at is null + where """ + NAME_MATCH + """ + + group by g.appid, g.name, g.image_url + order by lower(g.name), g.appid + limit :limit offset :offset + """, new MapSqlParameterSource("pattern", UserGameRepository.containsPattern(query)) + .addValue("limit", limit).addValue("offset", offset), + (rs, row) -> new CatalogHit(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), + platforms(rs.getArray("platforms")))); + } + + public long count(String query) { + Long count = jdbc.queryForObject("select count(*) from steam_game g where exists (select 1 from user_game ug " + + "where ug.appid = g.appid and ug.removed_at is null) and " + NAME_MATCH, + new MapSqlParameterSource("pattern", UserGameRepository.containsPattern(query)), Long.class); + return count == null ? 0 : count; + } + + private static List platforms(java.sql.Array array) throws SQLException { + return Arrays.stream((String[]) array.getArray()).sorted().toList(); + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java b/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java index 2e0df63..c5420dd 100644 --- a/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java @@ -13,6 +13,9 @@ import java.util.Set; public interface GameTimeToBeatRepository extends JpaRepository { + String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_time_to_beat.appid " + + "and c.field = 'TIME_TO_BEAT')"; + @Query("select t.appid from GameTimeToBeat t where t.appid in :appids and t.fetchedAt > :since") Set findFreshAppids(@Param("appids") Collection appids, @Param("since") Instant since); @@ -22,7 +25,10 @@ public interface GameTimeToBeatRepository extends JpaRepository game = steamGameRepository.findById(appid); if (game.isEmpty()) { @@ -88,15 +107,44 @@ public class GameCorrectionService { return Result.DONE; } correctionRepository.delete(correction.get()); - write(field, game.get(), jsonMapper.readValue(correction.get().getSourceValue(), Object.class)); + String sourceValue = correction.get().getSourceValue(); + write(field, game.get(), jsonMapper.readValue(sourceValue, Object.class)); switch (field) { case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO); case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO); + case TIME_TO_BEAT -> gameTimeToBeatRepository.markStale(appid, LONG_AGO); case NAME, IMAGE -> { } } + adminAuditRepository.write(auditorId, "GAME_CORRECTION_RESET", appid, null, field.name() + " " + sourceValue); return Result.DONE; } + /** + * @param query a name part; blank for every game + */ + @Transactional(readOnly = true) + public Page search(String query, Pageable pageable) { + return new PageImpl<>(catalogRepository.search(query, (int) pageable.getOffset(), pageable.getPageSize()), + pageable, catalogRepository.count(query)); + } + + /** + * What the correction dialog shows; empty for a key without a catalog row. + */ + @Transactional(readOnly = true) + public Optional detail(long appid) { + return steamGameRepository.findById(appid).map(game -> { + Optional moods = gameMoodsRepository.findById(appid); + return new CatalogGameDetail(appid, game.getName(), game.getImageUrl(), + gameSpeedrunRepository.findById(appid).map(GameSpeedrun::getWeblink).orElse(null), + moods.map(GameMoods::getMoods).orElse(List.of()), + moods.map(GameMoods::getEndless).orElse(null), + moods.map(GameMoods::getGenres).orElse(List.of()), + gameTimeToBeatRepository.findById(appid).orElse(null), + correctionRepository.findByAppidOrderByField(appid)); + }); + } + // 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)); @@ -110,6 +158,11 @@ public class GameCorrectionService { 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); + case TIME_TO_BEAT -> { + Optional t = gameTimeToBeatRepository.findById(game.getAppid()); + yield times(t.map(GameTimeToBeat::getNormallyMinutes).orElse(null), + t.map(GameTimeToBeat::getCompletelyMinutes).orElse(null)); + } }; } @@ -121,9 +174,32 @@ public class GameCorrectionService { 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); + case TIME_TO_BEAT -> { + Map times = (Map) value; + gameTimeToBeatRepository.writeCorrected(game.getAppid(), (Integer) times.get(NORMALLY), + (Integer) times.get(COMPLETELY), LONG_AGO); + } } } + private static Map times(Integer normally, Integer completely) { + Map times = new LinkedHashMap<>(); + times.put(NORMALLY, normally); + times.put(COMPLETELY, completely); + return times; + } + + private static Integer minutes(Object value, String name) { + if (value == null) { + return null; + } + if (!(value instanceof Integer m) || m < 1 || m > MAX_TIME_TO_BEAT_MINUTES) { + throw new InvalidCorrectionException(name + " must be null or a whole number of minutes from 1 to " + + MAX_TIME_TO_BEAT_MINUTES); + } + return m; + } + private static String[] strings(Object list) { return ((List) list).stream().map(String.class::cast).toArray(String[]::new); } @@ -171,6 +247,17 @@ public class GameCorrectionService { } yield value; } + case TIME_TO_BEAT -> { + if (!(value instanceof Map map) || !Set.of(NORMALLY, COMPLETELY).containsAll(map.keySet())) { + throw new InvalidCorrectionException("time_to_beat must be {" + NORMALLY + ", " + COMPLETELY + "}"); + } + Integer normally = minutes(map.get(NORMALLY), NORMALLY); + Integer completely = minutes(map.get(COMPLETELY), COMPLETELY); + if (normally != null && completely != null && completely < normally) { + throw new InvalidCorrectionException(COMPLETELY + " must not be less than " + NORMALLY); + } + yield times(normally, completely); + } }; } diff --git a/src/test/java/core_service/modules/library/api/CatalogControllerTest.java b/src/test/java/core_service/modules/library/api/CatalogControllerTest.java new file mode 100644 index 0000000..aa711c4 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/CatalogControllerTest.java @@ -0,0 +1,122 @@ +package core_service.modules.library.api; + +import core_service.configuration.security.ClientHeaderFilter; +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The auditor catalog through the real (non-dev) filter chain, so the role in the token decides. + */ +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class CatalogControllerTest extends AbstractIntegrationTest { + + private static final long AUDITOR_ID = 6101L; + private static final long OWNER = 6102L; + private static final long OTHER = 6103L; + private static final long HADES = -1_940_001L; + private static final long REMOVED = -1_940_002L; + private static final long NOBODY = -1_940_003L; + private static final long PERCENT = -1_940_004L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + jdbc.update("delete from user_game"); + jdbc.update("delete from game_correction"); + jdbc.update("delete from game_time_to_beat"); + jdbc.update("delete from steam_game where appid in (?, ?, ?, ?) or name like 'Paged %'", HADES, REMOVED, NOBODY, PERCENT); + jdbc.update("insert into steam_game (appid, name) values (?, 'Hades'), (?, 'Removed Game'), " + + "(?, 'Nobody Game'), (?, '100% Orange Juice')", HADES, REMOVED, NOBODY, PERCENT); + jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source) " + + "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM')", OWNER, HADES); + jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source, platform) " + + "values (?, ?, 0, now(), now(), 'BACKLOG', 'MANUAL', 'PS5')", OTHER, HADES); + jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source, removed_at) " + + "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM', now())", OWNER, REMOVED); + jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source) " + + "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM')", OWNER, PERCENT); + jdbc.update("insert into game_time_to_beat (appid, normally_minutes, completely_minutes, fetched_at) " + + "values (?, 600, 1200, now())", HADES); + } + + private ResultActions call(Role role, String path) throws Exception { + return mockMvc.perform(get("/api/library/catalog" + path) + .header("Authorization", "Bearer " + ContractTokens.loginToken(AUDITOR_ID, role)) + .header(ClientHeaderFilter.CLIENT_HEADER, "test")); + } + + @Test + void search_FindsAGameOnlyOthersOwnWithItsPlatforms() throws Exception { + call(Role.AUDITOR, "?q=hAD").andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(HADES)) + .andExpect(jsonPath("$.content[0].name").value("Hades")) + .andExpect(jsonPath("$.content[0].platforms", contains("PS5", "Steam"))); + } + + @Test + void search_ListsOnlyGamesSomeoneStillHasAndMatchesPercentLiterally() throws Exception { + call(Role.AUDITOR, "").andExpect(jsonPath("$.content[*].appid", contains((int) PERCENT, (int) HADES))); + call(Role.PLATFORM_ADMIN, "?q=%").andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].appid").value(PERCENT)); + } + + @Test + void search_PagesTheMatches() throws Exception { + for (int i = 0; i < 21; i++) { + long appid = -1_950_000L - i; + jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Paged %02d".formatted(i)); + jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source) " + + "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM')", OWNER, appid); + } + + call(Role.AUDITOR, "?q=paged&page=0&size=20").andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(20))) + .andExpect(jsonPath("$.totalElements").value(21)) + .andExpect(jsonPath("$.totalPages").value(2)); + call(Role.AUDITOR, "?q=paged&page=1&size=20") + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].name").value("Paged 20")); + } + + @Test + void detail_ShowsWhatTheDialogNeedsForAGameTheAuditorDoesNotOwn() throws Exception { + call(Role.AUDITOR, "/" + HADES).andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Hades")) + .andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600)) + .andExpect(jsonPath("$.moods", hasSize(0))) + .andExpect(jsonPath("$.corrections", hasSize(0))); + call(Role.AUDITOR, "/-1940099").andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + } + + @Test + void roles_UserIsForbiddenAndAdminIsAllowedAndLongQueryIsRefused() throws Exception { + call(Role.USER, "").andExpect(status().isForbidden()); + call(Role.USER, "/" + HADES).andExpect(status().isForbidden()); + call(Role.PLATFORM_ADMIN, "/" + HADES).andExpect(status().isOk()); + call(Role.AUDITOR, "?q=" + "a".repeat(101)).andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java b/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java index ef11fdd..45778e5 100644 --- a/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameCorrectionFlowTest.java @@ -7,6 +7,7 @@ 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.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.shared.security.Role; @@ -17,6 +18,7 @@ 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.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -60,12 +62,20 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest { @Autowired private GameCorrectionRepository gameCorrectionRepository; + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @Autowired + private JdbcTemplate jdbc; + @BeforeEach void seed() { gameCorrectionRepository.deleteAll(); userGameRepository.deleteAll(); gameMoodsRepository.deleteAll(); gameSpeedrunRepository.deleteAll(); + gameTimeToBeatRepository.deleteAll(); + jdbc.update("delete from admin_audit"); 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. @@ -73,6 +83,7 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest { 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()); + gameTimeToBeatRepository.upsert(GAME, 300, 600, 1200, Instant.now()); } private static String token(Role role) { @@ -181,4 +192,75 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest { correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").andExpect(status().isBadRequest()); correct(Role.AUDITOR, GAME, "name", "\" \"").andExpect(status().isBadRequest()); } + + private static final String TIMES = "{\"normallyMinutes\":700,\"completelyMinutes\":1500}"; + + @Test + void timeToBeat_IsCorrectedForEveryoneAndSurvivesTheRefresh() throws Exception { + correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent()); + + details().andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(700)) + .andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1500)) + .andExpect(jsonPath("$.timeToBeat.hastilyMinutes").value(300)) + .andExpect(jsonPath("$.corrections[0].field").value("TIME_TO_BEAT")); + + gameTimeToBeatRepository.upsert(GAME, 310, 610, 1210, Instant.now()); + + details().andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(700)) + .andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1500)) + .andExpect(jsonPath("$.timeToBeat.hastilyMinutes").value(310)); + } + + @Test + void timeToBeat_ResetRestoresIgdbAndAsksAgain() throws Exception { + correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent()); + + reset("time_to_beat").andExpect(status().isNoContent()); + + details().andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600)) + .andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1200)) + .andExpect(jsonPath("$.corrections", empty())); + assertThat(gameTimeToBeatRepository.findById(GAME).orElseThrow().getFetchedAt()).isEqualTo(Instant.EPOCH); + } + + @Test + void timeToBeat_CanBeSetForAGameWithoutIgdbData() throws Exception { + gameTimeToBeatRepository.deleteAll(); + + correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent()); + + var row = gameTimeToBeatRepository.findById(GAME).orElseThrow(); + assertThat(row.getNormallyMinutes()).isEqualTo(700); + assertThat(row.getCompletelyMinutes()).isEqualTo(1500); + assertThat(row.getFetchedAt()).isEqualTo(Instant.EPOCH); + } + + @Test + void timeToBeat_BadValuesAreRefused() throws Exception { + for (String bad : new String[]{"{\"normallyMinutes\":-1}", "{\"normallyMinutes\":0}", + "{\"normallyMinutes\":1.5}", "{\"normallyMinutes\":60001}", "{\"normallyMinutes\":\"60\"}", + "{\"normallyMinutes\":60,\"hastilyMinutes\":30}", "{\"normallyMinutes\":100,\"completelyMinutes\":99}"}) { + correct(Role.AUDITOR, GAME, "time_to_beat", bad).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_CORRECTION")); + } + } + + @Test + void corrections_AreAudited() throws Exception { + correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent()); + reset("time_to_beat").andExpect(status().isNoContent()); + reset("name").andExpect(status().isNoContent()); + + var rows = jdbc.queryForList("select actor_user_id, action, appid, detail from admin_audit order by id"); + assertThat(rows).hasSize(2); + assertThat(rows.get(0)).containsEntry("action", "GAME_CORRECT").containsEntry("actor_user_id", AUDITOR_ID) + .containsEntry("appid", GAME); + assertThat((String) rows.get(0).get("detail")).startsWith("TIME_TO_BEAT "); + assertThat(rows.get(1)).containsEntry("action", "GAME_CORRECTION_RESET"); + assertThat((String) rows.get(1).get("detail")).startsWith("TIME_TO_BEAT "); + + mockMvc.perform(get("/api/library/audit-log?action=GAME_CORRECT") + .header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test")) + .andExpect(status().isOk()).andExpect(jsonPath("$.totalElements").value(1)); + } } diff --git a/src/test/java/core_service/modules/library/repo/GameTimeToBeatRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameTimeToBeatRepositoryTest.java new file mode 100644 index 0000000..3bedab1 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameTimeToBeatRepositoryTest.java @@ -0,0 +1,93 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.shared.test.AbstractPersistenceTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +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 GameTimeToBeatRepositoryTest extends AbstractPersistenceTest { + + private static final long GAME = 620L; + private static final Instant OLD = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant NEW = Instant.parse("2026-02-01T00:00:00Z"); + + @Autowired + private GameTimeToBeatRepository repository; + + @Autowired + private GameCorrectionRepository correctionRepository; + + @Autowired + private EntityManager entityManager; + + @BeforeEach + void clean() { + correctionRepository.deleteAll(); + repository.deleteAll(); + } + + private GameTimeToBeat stored() { + entityManager.clear(); + return repository.findById(GAME).orElseThrow(); + } + + private void corrected() { + correctionRepository.upsert(GAME, "TIME_TO_BEAT", "{}", "{}", 1L, OLD); + } + + @Test + void upsert_UpdatesEveryTimeWithoutACorrection() { + repository.upsert(GAME, 300, 600, 1200, OLD); + + repository.upsert(GAME, 310, 610, 1210, NEW); + + GameTimeToBeat t = stored(); + assertThat(t.getHastilyMinutes()).isEqualTo(310); + assertThat(t.getNormallyMinutes()).isEqualTo(610); + assertThat(t.getCompletelyMinutes()).isEqualTo(1210); + } + + @Test + void upsert_KeepsTheCorrectedTimesAndUpdatesTheRest() { + repository.upsert(GAME, 300, 600, 1200, OLD); + corrected(); + repository.writeCorrected(GAME, 700, 1500, OLD); + + repository.upsert(GAME, 310, 610, 1210, NEW); + + GameTimeToBeat t = stored(); + assertThat(t.getNormallyMinutes()).isEqualTo(700); + assertThat(t.getCompletelyMinutes()).isEqualTo(1500); + assertThat(t.getHastilyMinutes()).isEqualTo(310); + assertThat(t.getFetchedAt()).isEqualTo(NEW); + } + + @Test + void writeCorrected_NewRowKeepsTheGivenFetchedAt() { + repository.writeCorrected(GAME, 700, null, Instant.EPOCH); + + GameTimeToBeat t = stored(); + assertThat(t.getNormallyMinutes()).isEqualTo(700); + assertThat(t.getCompletelyMinutes()).isNull(); + assertThat(t.getHastilyMinutes()).isNull(); + assertThat(t.getFetchedAt()).isEqualTo(Instant.EPOCH); + } + + @Test + void writeCorrected_OldRowKeepsFetchedAtAndHastily() { + repository.upsert(GAME, 300, 600, 1200, OLD); + + repository.writeCorrected(GAME, 700, 1500, Instant.EPOCH); + + GameTimeToBeat t = stored(); + assertThat(t.getFetchedAt()).isEqualTo(OLD); + assertThat(t.getHastilyMinutes()).isEqualTo(300); + assertThat(t.getNormallyMinutes()).isEqualTo(700); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java b/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java index 43a235e..5842b14 100644 --- a/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameCorrectionServiceTest.java @@ -5,7 +5,9 @@ import core_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.Test; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -17,6 +19,39 @@ class GameCorrectionServiceTest extends AbstractUnitTest { .as(field + " " + value).isInstanceOf(InvalidCorrectionException.class); } + private static Map times(Object normally, Object completely) { + Map map = new HashMap<>(); + map.put("normallyMinutes", normally); + map.put("completelyMinutes", completely); + return map; + } + + @Test + void timeToBeat_AcceptsNullsEqualAndBounds() { + for (Map ok : List.of(times(null, null), times(60, null), times(null, 90), times(100, 100), + times(1, 60_000))) { + assertThat(GameCorrectionService.validate(CorrectionField.TIME_TO_BEAT, ok)).isEqualTo(ok); + } + Map missing = Map.of("normallyMinutes", 60); + assertThat(GameCorrectionService.validate(CorrectionField.TIME_TO_BEAT, missing)) + .isEqualTo(times(60, null)); + } + + @Test + void timeToBeat_RefusesBadValues() { + refused(CorrectionField.TIME_TO_BEAT, null); + refused(CorrectionField.TIME_TO_BEAT, "60"); + refused(CorrectionField.TIME_TO_BEAT, times(-1, null)); + refused(CorrectionField.TIME_TO_BEAT, times(0, null)); + refused(CorrectionField.TIME_TO_BEAT, times(1.5, null)); + refused(CorrectionField.TIME_TO_BEAT, times(60_001, null)); + refused(CorrectionField.TIME_TO_BEAT, times("60", null)); + refused(CorrectionField.TIME_TO_BEAT, times(100, 99)); + Map extra = times(60, 90); + extra.put("hastilyMinutes", 30); + refused(CorrectionField.TIME_TO_BEAT, extra); + } + @Test void image_OnlyHttpsOnTheAllowedHosts() { assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,