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

@@ -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,