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