feat(library): add IGDB editions, game links and auditor queue

Tables game_edition (IGDB "same game" id per library key), game_link
(global sets set by auditors), game_link_pair (queue of IGDB and
requested pairs), game_link_request and account_name. Fetch IGDB
editions, link and unlink with audit rows, serve the paged auditor queue
with request counts. WireMock recordings for the editions query.
This commit is contained in:
lobo committed 2026-10-02 09:10:56 +02:00
1 parent a12ed20705
commit e806670d26
43 files changed
+1877 -22

No files matched your search

@@ -45,6 +45,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
Map.entry("igdb-external-games", IgdbResponses.ExternalGame[].class),
Map.entry("igdb-time-to-beats", IgdbResponses.TimeToBeat[].class),
Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
Map.entry("igdb-editions", IgdbResponses.Edition[].class),
Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
@@ -17,7 +17,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped");
"user_steam_sync", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped");
@Autowired
private AccountCleanupRepository repository;
@@ -42,6 +43,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId);
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
@@ -109,9 +109,6 @@ class IgdbApiServiceTest extends AbstractUnitTest {
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
}
// REVIEW-FINDING: one Steam app linked to two IGDB games (main entry + edition) keeps the tags of whichever game
// the HashMap yields first and drops the other's. Here game 72 has no tags and game 233 is Horror; 72 sits in the
// earlier bucket, so 620 is stored with no mood. Time to beat picks the most-voted entry; tags should be the union.
@Test
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
expectToken();
@@ -201,6 +198,56 @@ class IgdbApiServiceTest extends AbstractUnitTest {
igdb.verify();
}
@Test
void getEditions_ResolvesEditionsRemastersPortsAndSameGame() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 11, \"uid\": \"1\"}, {\"game\": 12, \"uid\": \"2\"},"
+ " {\"game\": 13, \"uid\": \"3\"}, {\"game\": 14, \"uid\": \"4\"},"
+ " {\"game\": 15, \"uid\": \"5\"}, {\"game\": 99, \"uid\": \"6\"},"
+ " {\"game\": 22, \"uid\": \"7\"}, {\"game\": 21, \"uid\": \"7\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("fields version_parent,parent_game,game_type;")))
.andRespond(withSuccess("[{\"id\": 11, \"version_parent\": 10, \"game_type\": 0},"
+ " {\"id\": 12, \"parent_game\": 10, \"game_type\": 11},"
+ " {\"id\": 13, \"parent_game\": 10, \"game_type\": 9},"
+ " {\"id\": 14, \"parent_game\": 10, \"game_type\": 8},"
+ " {\"id\": 15, \"game_type\": 0},"
+ " {\"id\": 22, \"game_type\": 0}, {\"id\": 21, \"game_type\": 0}]",
MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.Edition> editions = service.getEditions(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L)).orElseThrow();
assertThat(editions).containsOnlyKeys(1L, 2L, 3L, 4L, 5L, 7L);
assertThat(editions.get(1L)).isEqualTo(new IgdbApiService.Edition(11, 10));
assertThat(editions.get(2L).sameGameId()).isEqualTo(10);
assertThat(editions.get(3L).sameGameId()).isEqualTo(10);
assertThat(editions.get(4L).sameGameId()).isEqualTo(14);
assertThat(editions.get(5L).sameGameId()).isEqualTo(15);
assertThat(editions.get(7L).igdbId()).isEqualTo(21);
}
@Test
void getEditionsByGameIds_KeepsOnlyTheAskedGames() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (1025)")))
.andRespond(withSuccess("[{\"id\": 1025, \"game_type\": 0}, {\"id\": 999, \"version_parent\": 1}]",
MediaType.APPLICATION_JSON));
assertThat(service.getEditionsByGameIds(List.of(1025L)).orElseThrow())
.containsOnly(Map.entry(1025L, new IgdbApiService.Edition(1025, 1025)));
}
@Test
void getEditions_IgdbError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getEditions(List.of(1L))).isEmpty();
}
@Test
void getGameTagsByGameIds_AsksTheGamesDirectly() {
expectToken();
@@ -0,0 +1,270 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
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.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.Mockito.doAnswer;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Links and requests through the real (non-dev) filter chain, so the role in the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GameLinkFlowTest extends AbstractIntegrationTest {
private static final long AUDITOR_ID = 5003L;
private static final long USER_A = 5001L;
private static final long USER_B = 5002L;
private static final long GAME = -1_910_007L;
private static final long GAME_2 = -1_910_008L;
private static final long FILLER = -1_920_000L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@MockitoSpyBean
private GameLinkRepository linkRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
jdbc.update("delete from " + table);
}
userGameRepository.deleteAll();
jdbc.update("delete from steam_game where appid in (?, ?) or appid <= ?", GAME, GAME_2, FILLER);
steamGameRepository.save(new SteamGame(GAME, "Hades (Switch)", null, Instant.now()));
steamGameRepository.save(new SteamGame(GAME_2, "Hades (PS5)", null, Instant.now()));
for (long user : List.of(USER_A, USER_B)) {
own(user, GAME);
own(user, GAME_2);
}
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, 7, 7, now()), (?, 8, 7, now())",
GAME, GAME_2);
}
private void own(long userId, long appid) {
userGameRepository.save(UserGame.manual(userId, appid, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
}
private MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId, Role role) {
return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId, role))
.header(ClientHeaderFilter.CLIENT_HEADER, "test");
}
private ResultActions body(MockHttpServletRequestBuilder request, long userId, Role role, String json) throws Exception {
return mockMvc.perform(as(request, userId, role).contentType(MediaType.APPLICATION_JSON).content(json));
}
private ResultActions linkAs(Role role, long appid, long otherAppid) throws Exception {
return body(post("/api/library/games/" + appid + "/link"), AUDITOR_ID, role, "{\"otherAppid\": " + otherAppid + "}");
}
private ResultActions requestAs(long userId, long appid, long otherAppid) throws Exception {
return body(post("/api/library/games/" + appid + "/link-requests"), userId, Role.USER,
"{\"otherAppid\": " + otherAppid + "}");
}
private ResultActions queue() throws Exception {
return mockMvc.perform(as(get("/api/library/link-queue"), AUDITOR_ID, Role.AUDITOR));
}
private List<String> audit() {
return jdbc.query("select * from admin_audit order by id", (rs, i) -> rs.getString("action") + " "
+ rs.getString("detail"));
}
@Test
void queue_ShowsTheIgdbPair() throws Exception {
queue().andExpect(status().isOk()).andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].igdb").value(true))
.andExpect(jsonPath("$.content[0].requests").value(0))
.andExpect(jsonPath("$.content[0].a.name").value("Hades (PS5)"));
}
@Test
void roles_AUserCannotUseTheAuditorCalls() throws Exception {
mockMvc.perform(as(get("/api/library/link-queue"), USER_A, Role.USER)).andExpect(status().isForbidden());
linkAs(Role.USER, GAME, GAME_2).andExpect(status().isForbidden());
mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), USER_A, Role.USER)).andExpect(status().isForbidden());
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME + "/" + GAME_2), USER_A, Role.USER))
.andExpect(status().isForbidden());
assertThat(audit()).isEmpty();
}
@Test
void requests_AreCountedPerUserAndPair() throws Exception {
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
requestAs(USER_B, GAME_2, GAME).andExpect(status().isNoContent());
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
queue().andExpect(jsonPath("$.content", hasSize(1))).andExpect(jsonPath("$.content[0].requests").value(2));
}
@Test
void requests_BadInput_IsRefused() throws Exception {
requestAs(USER_A, GAME, GAME).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("SAME_GAME"));
requestAs(USER_A, GAME, -1_910_999L).andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
requestAs(AUDITOR_ID, GAME, GAME_2).andExpect(status().isNotFound());
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isZero();
}
@Test
void requests_The21stOpenOneIsTooMany() throws Exception {
for (int i = 0; i < 21; i++) {
steamGameRepository.save(new SteamGame(FILLER - i, "Filler " + i, null, Instant.now()));
own(USER_A, FILLER - i);
}
for (int i = 0; i < 20; i++) {
requestAs(USER_A, GAME, FILLER - i).andExpect(status().isNoContent());
}
requestAs(USER_A, GAME, FILLER - 20).andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("TOO_MANY_REQUESTS"));
}
@Test
void link_ChangesEveryUsersNumbersAndShowsTheEditions() throws Exception {
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
queue().andExpect(jsonPath("$.content", hasSize(0)));
assertThat(audit()).containsExactly("GAME_LINK \"Hades (Switch)\" with \"Hades (PS5)\"");
for (long user : List.of(USER_A, USER_B)) {
mockMvc.perform(as(get("/api/library/games/" + GAME), user, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.linked").value(true))
.andExpect(jsonPath("$.editions", hasSize(1)))
.andExpect(jsonPath("$.editions[0].appid").value(GAME_2));
mockMvc.perform(as(get("/api/library/games"), user, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.content[*].editions", hasItem(2)));
}
requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_LINKED"));
linkAs(Role.AUDITOR, GAME_2, GAME).andExpect(status().isConflict());
linkAs(Role.AUDITOR, GAME, GAME).andExpect(status().isBadRequest());
linkAs(Role.AUDITOR, GAME, -1_910_999L).andExpect(status().isNotFound());
}
@Test
void unlink_DismissesThePairForGood() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNoContent());
assertThat(audit()).hasSize(2).last().asString().startsWith("GAME_UNLINK").contains("Hades (PS5)");
queue().andExpect(jsonPath("$.content", hasSize(0)));
queue().andExpect(jsonPath("$.content", hasSize(0)));
requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PAIR_DISMISSED"));
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isZero();
mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED"));
}
@Test
void dismiss_WritesTheAuditRowAndAnswers404TheSecondTime() throws Exception {
queue().andExpect(jsonPath("$.content", hasSize(1)));
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNoContent());
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PAIR_NOT_OPEN"));
assertThat(audit()).containsExactly("GAME_LINK_DISMISS null");
queue().andExpect(jsonPath("$.content", hasSize(0)));
}
// Without the per-user lock every call counts 19 before any call inserts (the barrier holds the count until all
// calls have counted); with it the first call waits alone, the barrier times out, and the calls run one by one.
@Test
void requests_ParallelCallsCannotPassTheCap() throws Exception {
int parallel = 12;
for (int i = 0; i < 19 + parallel; i++) {
steamGameRepository.save(new SteamGame(FILLER - i, "Filler " + i, null, Instant.now()));
own(USER_A, FILLER - i);
}
for (int i = 0; i < 19; i++) {
requestAs(USER_A, GAME, FILLER - i).andExpect(status().isNoContent());
}
CyclicBarrier counted = new CyclicBarrier(parallel);
doAnswer(call -> {
Object count = call.callRealMethod();
try {
counted.await(500, TimeUnit.MILLISECONDS);
} catch (TimeoutException | BrokenBarrierException e) {
// The other calls wait for the lock: the barrier is broken, the calls go on one by one.
}
return count;
}).when(linkRepository).countOpenRequests(USER_A);
ExecutorService pool = Executors.newFixedThreadPool(parallel);
try {
List<Future<ResultActions>> calls = new ArrayList<>();
for (int i = 0; i < parallel; i++) {
long other = FILLER - 19 - i;
calls.add(pool.submit(() -> requestAs(USER_A, GAME, other)));
}
for (Future<ResultActions> call : calls) {
call.get();
}
} finally {
pool.shutdownNow();
}
assertThat(jdbc.queryForObject("select count(*) from game_link_request where user_id = ?", Integer.class, USER_A))
.isEqualTo(20);
}
@Test
void queue_AnotherPageIsEmptyAndKeepsTheTotal() throws Exception {
mockMvc.perform(as(get("/api/library/link-queue?page=1"), AUDITOR_ID, Role.AUDITOR)).andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(0))).andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.page").value(1));
}
}
@@ -161,6 +161,55 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
}
@Test
void merge_MovesTheSetRowAndDropsThePairsRequestsAndEditionOfTheMergedKey() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
jdbc.update("delete from game_link");
jdbc.update("delete from game_link_pair");
jdbc.update("delete from game_link_request");
jdbc.update("delete from game_edition");
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, MANUAL_2);
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, STEAM_2);
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, MANUAL);
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, ?, ?)", OTHER_USER, MANUAL_2, STEAM_2);
jdbc.update("insert into game_edition (appid, same_game_id, checked_at) values (?, 1, now())", MANUAL);
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForList("select appid from game_link order by appid", Long.class)).containsExactly(MANUAL_2, STEAM);
assertThat(jdbc.queryForObject("select count(*) from game_link_pair where ? in (appid_a, appid_b)", Integer.class, MANUAL))
.isZero();
assertThat(jdbc.queryForObject("select count(*) from game_link_pair", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from game_edition", Integer.class)).isZero();
assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE");
}
@Test
void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
jdbc.update("delete from game_link");
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1), (?, 2), (?, 2)",
MANUAL, MANUAL_2, STEAM, STEAM_2);
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForList("select appid from game_link order by appid", Long.class))
.containsExactly(MANUAL_2, STEAM, STEAM_2);
assertThat(jdbc.queryForObject("select count(distinct set_id) from game_link", Integer.class)).isEqualTo(1);
}
@Test
void merge_TwoKeysOfOneSet_DropsTheSetThatHasOneKeyLeft() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
jdbc.update("delete from game_link");
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, STEAM);
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForObject("select count(*) from game_link", Integer.class)).isZero();
}
@Test
void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
@@ -0,0 +1,127 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CountedUserGameViewTest extends AbstractPersistenceTest {
private static final String COLUMNS = "select column_name from information_schema.columns "
+ "where table_schema = 'public' and table_name = ? order by ordinal_position";
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from game_link");
jdbc.update("delete from user_game");
for (long appid : List.of(-10L, 10L, 20L, 30L, 40L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void set(long appid, long setId) {
jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId);
}
private void own(long userId, long appid, String status, int playtime) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
userId, appid, status, playtime);
}
private List<Long> counted(long userId) {
return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, userId);
}
@Test
void view_HasEveryColumnOfUserGame() {
assertEquals(jdbc.queryForList(COLUMNS, String.class, "user_game"),
jdbc.queryForList(COLUMNS, String.class, "counted_user_game"));
}
@Test
void set_CountsTheMostAdvancedEdition() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "COMPLETED", 0);
assertThat(counted(USER)).containsExactly(20L);
}
@Test
void set_AllBacklog_CountsTheOneWithMorePlaytimeOnce() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 5);
own(USER, 20, "BACKLOG", 50);
assertThat(counted(USER)).containsExactly(20L);
}
@Test
void set_FullTieOnStatusAndPlaytime_CountsTheOlderRow() {
set(-10, 1);
set(20, 1);
own(USER, -10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set first_seen_at = now() where appid = -10");
jdbc.update("update user_game set first_seen_at = now() - interval '3 years' where appid = 20");
assertThat(counted(USER)).containsExactly(20L);
}
@Test
void set_FullTieAlsoOnTime_CountsTheLowerKey() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set first_seen_at = '2025-01-01T00:00:00Z'");
assertThat(counted(USER)).containsExactly(10L);
}
@Test
void set_DroppedBeatsBacklog() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "DROPPED", 0);
assertThat(counted(USER)).containsExactly(20L);
}
@Test
void set_HiddenSiblingDoesNotWin() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "COMPLETED", 0);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER);
assertThat(counted(USER)).containsExactly(10L);
}
@Test
void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(OTHER_USER, 20, "COMPLETED", 0);
own(USER, 30, "BACKLOG", 0);
assertThat(counted(USER)).containsExactly(10L, 30L);
assertThat(counted(OTHER_USER)).containsExactly(20L);
}
}
@@ -0,0 +1,270 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@Import(GameLinkRepository.class)
class GameLinkRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private GameLinkRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
jdbc.update("insert into steam_game (appid, name, image_url) values (?, ?, ?) on conflict do nothing",
appid, "Game " + appid, "img" + appid);
}
edition(100, 10);
edition(-7, 10);
edition(200, 10);
edition(-8, 11);
}
private void edition(long appid, long sameGame) {
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now())",
appid, Math.abs(appid), sameGame);
}
private void own(long userId, long appid, String status) {
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, ?)", userId, appid, status);
}
private List<String> pairs(String state) {
return jdbc.queryForList("select appid_a || ':' || appid_b from game_link_pair where state = ? order by 1",
String.class, state);
}
@Test
void addIgdbPairs_InsertsEachPairOnce() {
assertThat(repository.addIgdbPairs()).isEqualTo(3);
assertThat(repository.addIgdbPairs()).isZero();
assertThat(pairs("OPEN")).containsExactlyInAnyOrder("-7:100", "-7:200", "100:200");
}
@Test
void addIgdbPairs_SkipsKeysOfOneSet() {
repository.link(100, 200);
repository.addIgdbPairs();
assertThat(pairs("OPEN")).containsExactlyInAnyOrder("-7:100", "-7:200");
}
@Test
void addIgdbPairs_DoesNotBringBackADismissedPair() {
repository.addIgdbPairs();
repository.dismiss(100, 200);
repository.addIgdbPairs();
assertThat(pairs("DISMISSED")).containsExactly("100:200");
assertThat(pairs("OPEN")).doesNotContain("100:200");
}
@Test
void link_MakesExtendsAndMergesSets() {
long first = repository.link(100, 200);
assertThat(repository.setOf(100)).contains(first);
assertThat(repository.setOf(200)).contains(first);
assertThat(repository.link(200, -7)).isEqualTo(first);
assertThat(repository.setOf(-7)).contains(first);
long second = repository.link(-8, 300);
assertThat(second).isNotEqualTo(first);
long merged = repository.link(-8, 100);
assertThat(jdbc.queryForList("select distinct set_id from game_link", Long.class)).containsExactly(merged);
assertThat(jdbc.queryForObject("select count(*) from game_link", Integer.class)).isEqualTo(5);
assertThat(pairs("LINKED")).contains("100:200", "-8:300", "-8:100");
}
@Test
void link_DecidesTheOtherOpenPairsInsideTheSet() {
repository.addIgdbPairs();
repository.link(-7, 100);
repository.link(100, 200);
assertThat(pairs("OPEN")).isEmpty();
assertThat(pairs("LINKED")).containsExactlyInAnyOrder("-7:100", "100:200", "-7:200");
}
@Test
void unlink_RemovesTheKeyDismissesItsPairsAndDropsASetOfOne() {
repository.link(100, 200);
repository.link(100, -7);
assertThat(repository.unlink(-7)).containsExactly(100L, 200L);
assertThat(repository.setOf(-7)).isEmpty();
assertThat(repository.setOf(100)).isPresent();
assertThat(pairs("DISMISSED")).containsExactlyInAnyOrder("-7:100", "-7:200");
assertThat(repository.unlink(100)).containsExactly(200L);
assertThat(repository.setOf(200)).isEmpty();
assertThat(repository.unlink(100)).isEmpty();
}
@Test
void dismiss_OnlyAnOpenPair() {
repository.addIgdbPairs();
assertThat(repository.dismiss(200, 100)).isTrue();
assertThat(repository.dismiss(100, 200)).isFalse();
assertThat(repository.dismiss(100, 300)).isFalse();
}
@Test
void addRequest_CreatesThePairOnceAndOneRequestPerUser() {
assertThat(repository.addRequest(USER, 300, 100)).isTrue();
assertThat(repository.addRequest(USER, 100, 300)).isFalse();
assertThat(repository.addRequest(OTHER_USER, 100, 300)).isTrue();
assertThat(pairs("OPEN")).containsExactly("100:300");
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(2);
assertThat(repository.pairState(300, 100)).isEqualTo("OPEN");
assertThat(repository.pairState(100, 200)).isNull();
}
@Test
void addIgdbPairs_MarksAnOpenRequestedPairAsIgdb() {
repository.addRequest(USER, 100, 200);
repository.addIgdbPairs();
assertThat(repository.openPairs(0, 10)).filteredOn(pair -> pair.a().appid() == 100 && pair.b().appid() == 200)
.singleElement().extracting(LinkPair::igdb).isEqualTo(true);
}
@Test
void countOpenRequests_IgnoresPairsThatAreNotOpen() {
repository.addRequest(USER, 100, 300);
repository.addRequest(USER, 100, 200);
repository.addRequest(USER, -7, 300);
repository.link(100, 300);
repository.dismiss(100, 200);
assertThat(repository.countOpenRequests(USER)).isEqualTo(1);
assertThat(repository.countOpenRequests(OTHER_USER)).isZero();
}
@Test
void openPairs_CarryNamesImagesRequestsAndTheOrder() {
repository.addIgdbPairs();
repository.addRequest(USER, 100, 300);
repository.addRequest(USER, 200, 300);
repository.addRequest(OTHER_USER, 200, 300);
repository.link(-7, 100);
List<LinkPair> pairs = repository.openPairs(0, 10);
assertThat(pairs).hasSize(4);
assertThat(pairs.getFirst().requests()).isEqualTo(2);
assertThat(pairs.getFirst().igdb()).isFalse();
assertThat(pairs.getFirst().a().appid()).isEqualTo(200);
assertThat(pairs.getFirst().b().name()).isEqualTo("Game 300");
assertThat(pairs.getFirst().b().imageUrl()).isEqualTo("img300");
assertThat(pairs.get(1).requests()).isEqualTo(1);
assertThat(pairs.get(2).igdb()).isTrue();
assertThat(pairs.get(2).requests()).isZero();
assertThat(pairs.get(3).igdb()).isTrue();
}
@Test
void openPairs_PageWithoutGapsAndCountOnlyOpenPairs() {
repository.addIgdbPairs();
assertThat(repository.countOpenPairs()).isEqualTo(3);
List<LinkPair> first = repository.openPairs(0, 2);
List<LinkPair> second = repository.openPairs(2, 2);
assertThat(first).hasSize(2);
assertThat(second).hasSize(1);
assertThat(Stream.concat(first.stream(), second.stream())
.map(pair -> pair.a().appid() + ":" + pair.b().appid()))
.containsExactlyInAnyOrder("-7:100", "-7:200", "100:200");
repository.dismiss(100, 200);
assertThat(repository.countOpenPairs()).isEqualTo(2);
}
@Test
void addIgdbPairs_KeepsADismissedPairDismissed() {
repository.addRequest(USER, 100, 200);
repository.dismiss(100, 200);
repository.addIgdbPairs();
assertThat(pairs("DISMISSED")).containsExactly("100:200");
assertThat(jdbc.queryForObject("select igdb from game_link_pair where appid_a = 100 and appid_b = 200",
Boolean.class)).isFalse();
}
@Test
void linkedEditions_ListsTheOtherListedRowsOfTheUserOnly() {
repository.link(100, 200);
repository.link(100, -7);
repository.link(100, -8);
own(USER, 100, "PLAYING");
own(USER, 200, "BACKLOG");
own(USER, -7, "FINISHED");
own(USER, -8, "BACKLOG");
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = -8", USER);
own(OTHER_USER, 200, "COMPLETED");
List<Edition> editions = repository.linkedEditions(USER, 100);
assertThat(editions).extracting(Edition::appid).containsExactly(-7L, 200L);
assertThat(editions.getFirst().status()).isEqualTo(GameStatus.FINISHED);
assertThat(editions.getFirst().name()).isEqualTo("Game -7");
}
@Test
void linkedEditions_RemovedRowIsLeftOut() {
repository.link(100, 200);
own(USER, 100, "PLAYING");
own(USER, 200, "BACKLOG");
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 200", USER);
assertThat(repository.linkedEditions(USER, 100)).isEmpty();
}
@Test
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
repository.link(100, 200);
repository.link(100, -7);
own(USER, 100, "PLAYING");
own(USER, 200, "BACKLOG");
own(USER, -7, "BACKLOG");
own(OTHER_USER, 100, "BACKLOG");
assertThat(repository.editionCounts(USER, List.of(100L, 200L, 300L))).isEqualTo(Map.of(100L, 3, 200L, 3));
assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty();
assertThat(repository.editionCounts(USER, List.of())).isEmpty();
}
}
@@ -0,0 +1,94 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class GameEditionServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameEditionRepository gameEditionRepository;
@Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@InjectMocks
private GameEditionService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "ttlDays", 30L);
}
@Test
void refresh_StoresStaleGamesOnly_AndNullsForAnAnsweredUnknownGame() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.Edition(72, 10))));
service.refresh(List.of(220L, 620L, 700L));
verify(gameEditionRepository).upsert(eq(620L), eq(72L), eq(10L), any(Instant.class));
verify(gameEditionRepository).upsert(eq(700L), isNull(), isNull(), any(Instant.class));
verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any());
}
@Test
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getEditionsByGameIds(Set.of(119388L))).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.Edition(119388, 5))));
service.refresh(List.of(-119388L));
verify(gameEditionRepository).upsert(eq(-119388L), eq(119388L), eq(5L), any(Instant.class));
}
@Test
void refresh_IgdbFailed_StoresNothingSoTheGamesAreAskedAgain() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getEditions(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameEditionRepository, never()).upsert(any(), any(), any(), any());
}
@Test
void refresh_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L));
verifyNoInteractions(gameEditionRepository);
}
}
@@ -120,6 +120,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private MoodService moodService;
@Mock
private GameEditionService gameEditionService;
@Mock
private ApplicationEventPublisher eventPublisher;
@@ -526,6 +529,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(timeToBeatService).refresh(List.of(220L, -1025L));
verify(moodService).refresh(List.of(220L, -1025L));
verify(gameEditionService).refresh(List.of(220L, -1025L));
}
@Test
@@ -50,6 +50,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock
private MoodService moodService;
@Mock
private GameEditionService gameEditionService;
@Mock
private PlatformTransactionManager transactionManager;
@@ -58,7 +61,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
moodService, transactionManager);
moodService, gameEditionService, transactionManager);
}
@Test