Public Access
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:
1 parent
a2ea1e45d3
commit
44af19d32e
16 files changed
+596
-13
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user