feat(library): auditor catalog search and time to beat correction

GET /api/library/catalog finds any game a user has, by name, for
auditors; GET /api/library/catalog/{appid} loads it for the correction
dialog. New correction field TIME_TO_BEAT (normal + completionist,
1..60000 min, completionist >= normal) that survives the IGDB refresh.
Every correction and reset writes GAME_CORRECT / GAME_CORRECTION_RESET
to admin_audit.
This commit is contained in:
lobo committed 2026-10-02 11:21:15 +02:00
1 parent a2ea1e45d3
commit 44af19d32e
16 files changed
+596 -13

No files matched your search

+10 -4
View File
@@ -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.
@@ -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<PageDto<CatalogHitDto>> 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).<ResponseEntity<?>>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());
}
@@ -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<String> moods,
Boolean endless,
List<String> genres,
GameDetailDto.TimeToBeatDto timeToBeat,
List<CorrectionMarkDto> corrections
) {}
@@ -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<String> platforms) {}
@@ -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) {}
@@ -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<String> moods,
Boolean endless, List<String> genres, GameTimeToBeat timeToBeat,
List<GameCorrection> corrections) {}
@@ -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<String> platforms) {}
@@ -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"
@@ -15,7 +15,8 @@ import java.util.List;
@RequiredArgsConstructor
public class AdminAuditRepository {
public static final List<String> ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS");
public static final List<String> ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS",
"GAME_CORRECT", "GAME_CORRECTION_RESET");
private final NamedParameterJdbcTemplate jdbc;
@@ -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<CatalogHit> 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<String> platforms(java.sql.Array array) throws SQLException {
return Arrays.stream((String[]) array.getArray()).sorted().toList();
}
}
@@ -13,6 +13,9 @@ import java.util.Set;
public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat, Long> {
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<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
@@ -22,7 +25,10 @@ public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat,
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
"values (:appid, :hastily, :normally, :completely, :fetchedAt) " +
"on conflict (appid) do update set hastily_minutes = excluded.hastily_minutes, " +
"normally_minutes = excluded.normally_minutes, completely_minutes = excluded.completely_minutes, " +
"normally_minutes = case when " + CORRECTED + " then game_time_to_beat.normally_minutes " +
"else excluded.normally_minutes end, " +
"completely_minutes = case when " + CORRECTED + " then game_time_to_beat.completely_minutes " +
"else excluded.completely_minutes end, " +
"fetched_at = excluded.fetched_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid,
@@ -30,4 +36,21 @@ public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat,
@Param("normally") Integer normallyMinutes,
@Param("completely") Integer completelyMinutes,
@Param("fetchedAt") Instant fetchedAt);
// fetchedAt is set only on a new row, i.e. a game IGDB has no times for.
@Modifying
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
"values (:appid, null, :normally, :completely, :fetchedAt) " +
"on conflict (appid) do update set normally_minutes = excluded.normally_minutes, " +
"completely_minutes = excluded.completely_minutes",
nativeQuery = true)
void writeCorrected(@Param("appid") Long appid, @Param("normally") Integer normally,
@Param("completely") Integer completely, @Param("fetchedAt") Instant fetchedAt);
/**
* The next time to beat refresh asks IGDB again.
*/
@Modifying
@Query("update GameTimeToBeat t set t.fetchedAt = :longAgo where t.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
}
@@ -1,19 +1,28 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CatalogGameDetail;
import core_service.modules.library.domain.CatalogHit;
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.GameTimeToBeat;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.AdminAuditRepository;
import core_service.modules.library.repo.CatalogRepository;
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.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.databind.json.JsonMapper;
@@ -22,7 +31,9 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -43,6 +54,9 @@ public class GameCorrectionService {
private static final int MAX_TEXT = 512;
private static final int MAX_GENRES = 10;
private static final int MAX_GENRE_LENGTH = 64;
private static final String NORMALLY = "normallyMinutes";
private static final String COMPLETELY = "completelyMinutes";
static final int MAX_TIME_TO_BEAT_MINUTES = 60_000;
// Far enough back that the next refresh asks the source again.
private static final Instant LONG_AGO = Instant.EPOCH;
@@ -50,6 +64,9 @@ public class GameCorrectionService {
private final GameCorrectionRepository correctionRepository;
private final GameMoodsRepository gameMoodsRepository;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final AdminAuditRepository adminAuditRepository;
private final CatalogRepository catalogRepository;
private final JsonMapper jsonMapper;
/**
@@ -65,10 +82,12 @@ public class GameCorrectionService {
return Result.NOT_FOUND;
}
Object clean = validate(field, value);
String json = jsonMapper.writeValueAsString(clean);
// 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),
correctionRepository.upsert(appid, field.name(), json,
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
write(field, game.get(), clean);
adminAuditRepository.write(auditorId, "GAME_CORRECT", appid, null, field.name() + " " + json);
return Result.DONE;
}
@@ -77,7 +96,7 @@ public class GameCorrectionService {
* asked again at the next refresh.
*/
@Transactional
public Result reset(long appid, String fieldName) {
public Result reset(long appid, String fieldName, long auditorId) {
CorrectionField field = field(fieldName);
Optional<SteamGame> 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<CatalogHit> 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<CatalogGameDetail> detail(long appid) {
return steamGameRepository.findById(appid).map(game -> {
Optional<GameMoods> 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<GameTimeToBeat> t = gameTimeToBeatRepository.findById(game.getAppid());
yield times(t.map(GameTimeToBeat::getNormallyMinutes).orElse(null),
t.map(GameTimeToBeat::getCompletelyMinutes).orElse(null));
}
};
}
@@ -121,8 +174,31 @@ 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<String, Integer> times(Integer normally, Integer completely) {
Map<String, Integer> 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);
}
};
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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<String, Object> times(Object normally, Object completely) {
Map<String, Object> map = new HashMap<>();
map.put("normallyMinutes", normally);
map.put("completelyMinutes", completely);
return map;
}
@Test
void timeToBeat_AcceptsNullsEqualAndBounds() {
for (Map<String, Object> 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<String, Object> 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<String, Object> extra = times(60, 90);
extra.put("hastilyMinutes", 30);
refused(CorrectionField.TIME_TO_BEAT, extra);
}
@Test
void image_OnlyHttpsOnTheAllowedHosts() {
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,