feat(library): group same game across platforms into sets

One entry per game across platforms (Gitea #34, slice 1 of 3).
Same IGDB game is linked; a user can split one key off its set.
V42 keeps status, rating and notes per set; rewards pay once per set.
This commit is contained in:
lobo committed 2026-10-09 10:57:23 +02:00
1 parent 8e4c382563
commit 82d3b59165
21 files changed
+1134 -64

No files matched your search

@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired
@@ -35,6 +35,9 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row.
jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 220)", userId);
jdbc.update("insert into user_game_set_backup (user_id, appid, status, status_set_by_user, notes) "
+ "values (?, 220, 'PLAYING', true, 'old note')", userId);
jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, 220, 'START', 0)", userId);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId);
@@ -8,6 +8,7 @@ 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.modules.library.service.GameEditionService;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
@@ -41,6 +42,7 @@ 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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -67,6 +69,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameEditionService gameEditionService;
@MockitoSpyBean
private GameLinkRepository linkRepository;
@@ -351,4 +356,95 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.sources[1]").value("PSN"))
.andExpect(jsonPath("$.sources", hasSize(2)));
}
private ResultActions splitAs(long userId, long appid) throws Exception {
return mockMvc.perform(as(delete("/api/library/games/" + appid + "/set"), userId, Role.USER));
}
@Test
void autoLink_LinksTheIgdbPairWithoutAnAuditor() throws Exception {
assertThat(gameEditionService.linkSameGames(List.of(GAME))).isEqualTo(1);
assertThat(gameEditionService.linkSameGames(List.of(GAME))).isZero();
queue().andExpect(jsonPath("$.content", hasSize(0)));
assertThat(audit()).isEmpty();
mockMvc.perform(as(get("/api/library/games/" + GAME), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.editions", hasSize(1)));
}
@Test
void autoLink_NeverLinksADismissedPair() throws Exception {
queue();
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNoContent());
assertThat(gameEditionService.linkSameGames(null)).isZero();
}
@Test
void split_StandsAloneForTheUserOnly_AndStaysSplit() throws Exception {
gameEditionService.linkSameGames(null);
splitAs(USER_A, GAME).andExpect(status().isNoContent());
splitAs(USER_A, GAME).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED"));
gameEditionService.linkSameGames(null);
mockMvc.perform(as(get("/api/library/games/" + GAME), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.editions", hasSize(0)));
mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2));
mockMvc.perform(as(get("/api/library/games/" + GAME), USER_B, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.editions", hasSize(1)));
mockMvc.perform(as(get("/api/library/summary"), USER_B, Role.USER)).andExpect(jsonPath("$.total").value(1));
}
@Test
void split_GameWithoutAnotherEdition_Is404() throws Exception {
splitAs(USER_A, GAME).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED"));
}
@Test
void status_ChangeOnOneEditionShowsOnTheOther_UntilTheUserSplits() throws Exception {
gameEditionService.linkSameGames(null);
mockMvc.perform(as(put("/api/library/games/" + GAME + "/status"), USER_A, Role.USER)
.contentType(MediaType.APPLICATION_JSON).content("{\"status\": \"PLAYING\"}")).andExpect(status().isOk());
mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"));
mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_B, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("BACKLOG"));
splitAs(USER_A, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(put("/api/library/games/" + GAME + "/status"), USER_A, Role.USER)
.contentType(MediaType.APPLICATION_JSON).content("{\"status\": \"FINISHED\"}")).andExpect(status().isOk());
mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"));
}
@Test
void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception {
setSource(GAME_2, GameSource.PSN);
gameEditionService.linkSameGames(null);
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2));
splitAs(USER_A, GAME).andExpect(status().isNoContent());
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(GAME_2));
}
@Test
void autoLink_DismissedPairStaysApartAlsoThroughAThirdEdition() throws Exception {
long third = FILLER - 1;
steamGameRepository.save(new SteamGame(third, "Hades (Xbox)", null, Instant.now()));
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, 9, 7, now())", third);
queue();
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
.andExpect(status().isNoContent());
gameEditionService.linkSameGames(null);
assertThat(linkRepository.inOneSet(GAME, GAME_2)).isFalse();
}
}
@@ -39,6 +39,13 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
userId, appid, status, playtime);
}
// The set trigger would copy the status to the sibling; the view rule is about rows that differ.
private void differ(long appid, String status) {
jdbc.execute("alter table user_game disable trigger user_game_set_share");
jdbc.update("update user_game set status = ? where appid = ?", status, appid);
jdbc.execute("alter table user_game enable trigger user_game_set_share");
}
private List<Long> counted(long userId) {
return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, userId);
}
@@ -49,12 +56,25 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
jdbc.queryForList(COLUMNS, String.class, "counted_user_game"));
}
@Test
void join_CountsTheEditionThatWasMoreAdvancedBeforeIt() {
own(USER, 10, "BACKLOG", 500);
own(USER, 20, "BACKLOG", 100);
jdbc.update("update user_game set status = 'FINISHED' where appid = 20");
set(10, 1);
set(20, 1);
assertThat(counted(USER)).containsExactly(10L);
}
@Test
void set_CountsTheMostAdvancedEdition() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "COMPLETED", 0);
own(USER, 20, "BACKLOG", 0);
differ(20, "COMPLETED");
assertThat(counted(USER)).containsExactly(20L);
}
@@ -97,7 +117,8 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "DROPPED", 0);
own(USER, 20, "BACKLOG", 0);
differ(20, "DROPPED");
assertThat(counted(USER)).containsExactly(20L);
}
@@ -113,6 +134,31 @@ class CountedUserGameViewTest extends AbstractPersistenceTest {
assertThat(counted(USER)).containsExactly(10L);
}
@Test
void splitEdition_IsCountedOnItsOwn() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
assertThat(counted(USER)).containsExactly(10L, 20L);
}
@Test
void splitOfOneUser_DoesNotChangeTheSetOfAnotherUser() {
set(10, 1);
set(20, 1);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
own(OTHER_USER, 10, "BACKLOG", 0);
own(OTHER_USER, 20, "BACKLOG", 0);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
assertThat(counted(USER)).containsExactly(10L, 20L);
assertThat(counted(OTHER_USER)).hasSize(1);
}
@Test
void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() {
set(10, 1);
@@ -267,4 +267,63 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty();
assertThat(repository.editionCounts(USER, List.of())).isEmpty();
}
@Test
void sameGamePairs_FindsEveryPairOnceAndFiltersByKey() {
assertThat(repository.sameGamePairs(null)).containsExactly(new long[]{-7, 100}, new long[]{-7, 200},
new long[]{100, 200});
assertThat(repository.sameGamePairs(List.of(200L))).containsExactly(new long[]{-7, 200}, new long[]{100, 200});
assertThat(repository.sameGamePairs(List.of(-8L, 300L))).isEmpty();
assertThat(repository.sameGamePairs(List.of())).isEmpty();
}
@Test
void sameGamePairs_SkipsPairsInOneSetAndDismissedPairs() {
repository.link(100, 200);
repository.addIgdbPairs();
repository.dismiss(-7, 100);
assertThat(repository.sameGamePairs(null)).containsExactly(new long[]{-7, 200});
}
@Test
void split_OnlyWhileAnotherListedEditionExists_AndOnlyOnce() {
repository.link(100, 200);
own(USER, 100, "BACKLOG");
own(USER, 200, "BACKLOG");
assertThat(repository.split(USER, 100)).isTrue();
assertThat(repository.split(USER, 100)).isFalse();
assertThat(repository.split(USER, 200)).isFalse();
assertThat(repository.split(USER, 300)).isFalse();
assertThat(repository.split(OTHER_USER, 100)).isFalse();
}
@Test
void split_AHiddenOrRemovedEditionDoesNotCount() {
repository.link(100, 200);
own(USER, 100, "BACKLOG");
own(USER, 200, "BACKLOG");
jdbc.update("update user_game set hidden_at = now() where appid = 200");
assertThat(repository.split(USER, 100)).isFalse();
}
@Test
void linkedEditionsAndEditionCounts_IgnoreASplitKey() {
repository.link(100, 200);
repository.link(100, -7);
own(USER, 100, "BACKLOG");
own(USER, 200, "BACKLOG");
own(USER, -7, "BACKLOG");
own(OTHER_USER, 100, "BACKLOG");
own(OTHER_USER, 200, "BACKLOG");
repository.split(USER, -7);
assertThat(repository.linkedEditions(USER, 100)).extracting(Edition::appid).containsExactly(200L);
assertThat(repository.linkedEditions(USER, -7)).isEmpty();
assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2));
assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2));
}
}
@@ -0,0 +1,239 @@
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 java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The triggers and functions of V42: one status, rating, notes and priority per user set.
*/
class GameSetTriggerTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
jdbc.update("delete from game_link");
jdbc.update("delete from user_game");
for (long appid : List.of(10L, 20L, 30L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
}
private void link(long appid, long other) {
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", appid, other);
}
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 Map<String, Object> row(long userId, long appid) {
return jdbc.queryForMap("select status, status_set_by_user, rating, notes, priority from user_game "
+ "where user_id = ? and appid = ?", userId, appid);
}
private Map<String, Object> backup(long appid) {
return jdbc.queryForMap("select status, rating, notes from user_game_set_backup where user_id = ? and appid = ?",
USER, appid);
}
@Test
void statusChange_GoesToTheOtherEditionOfTheSet() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = true where appid = 10");
assertThat(row(USER, 20)).containsEntry("status", "PLAYING").containsEntry("status_set_by_user", true);
}
@Test
void ratingNotesAndPriority_GoToTheOtherEditionToo() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set rating = 4, notes = 'great', priority = 'HIGH' where appid = 20");
assertThat(row(USER, 10)).containsEntry("rating", 4).containsEntry("notes", "great")
.containsEntry("priority", "HIGH");
}
@Test
void otherUsersAndUnlinkedGames_AreNotTouched() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
own(OTHER_USER, 20, "BACKLOG", 0);
own(USER, 30, "BACKLOG", 0);
jdbc.update("update user_game set status = 'DROPPED' where user_id = ? and appid = 10", USER);
assertThat(row(OTHER_USER, 20)).containsEntry("status", "BACKLOG");
assertThat(row(USER, 30)).containsEntry("status", "BACKLOG");
}
@Test
void splitEdition_IsNotChanged_ButAHiddenEditionIs() {
link(10, 20);
jdbc.update("insert into game_link (appid, set_id) values (30, 1)");
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
own(USER, 30, "BACKLOG", 0);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
jdbc.update("update user_game set hidden_at = now() where appid = 30");
jdbc.update("update user_game set status = 'FINISHED' where appid = 10");
assertThat(row(USER, 20)).containsEntry("status", "BACKLOG");
assertThat(row(USER, 30)).containsEntry("status", "FINISHED");
}
@Test
void statusLog_HasOneRowPerEditionAndNoEndlessLoop() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("delete from user_game_status_change");
jdbc.update("update user_game set status = 'PLAYING' where appid = 10");
assertThat(jdbc.queryForObject("select count(*) from user_game_status_change", Integer.class)).isEqualTo(2);
}
@Test
void finishIfUntouched_OnTheSteamEdition_AlsoFinishesTheOtherEdition() {
link(10, 20);
own(USER, 10, "PLAYING", 60);
own(USER, 20, "PLAYING", 0);
jdbc.update("update user_game set source = 'PSN' where appid = 20");
int changed = userGameRepository.finishIfUntouched(USER, 10L);
assertThat(changed).isEqualTo(1);
assertThat(row(USER, 20)).containsEntry("status", "FINISHED");
}
@Test
void join_TheMostAdvancedEditionWins_GapsAreFilledAndNotesJoined() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("update user_game set rating = 4, notes = 'first' where appid = 10");
jdbc.update("update user_game set notes = 'second', status_set_by_user = true where appid = 20");
link(10, 20);
for (long appid : List.of(10L, 20L)) {
assertThat(row(USER, appid)).containsEntry("status", "PLAYING").containsEntry("status_set_by_user", true)
.containsEntry("rating", 4).containsEntry("notes", "second\n\nfirst");
}
assertThat(backup(10)).containsEntry("status", "BACKLOG").containsEntry("notes", "first");
assertThat(backup(20)).containsEntry("status", "PLAYING").containsEntry("notes", "second");
}
@Test
void join_NotesAreCutTo2000() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("update user_game set notes = ? where appid = 10", "a".repeat(1500));
jdbc.update("update user_game set notes = ? where appid = 20", "b".repeat(1500));
link(10, 20);
assertThat((String) row(USER, 10).get("notes")).hasSize(2000).startsWith("bbb").endsWith("aaa");
}
@Test
void join_ASecondJoinDoesNotOverwriteTheBackup() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("update user_game set notes = 'kept' where appid = 20");
link(10, 20);
jdbc.update("delete from game_link where appid = 10");
jdbc.update("update user_game set status = 'DROPPED', notes = 'newer' where appid = 10");
jdbc.update("insert into game_link (appid, set_id) values (10, 1)");
assertThat(row(USER, 10)).containsEntry("status", "PLAYING");
assertThat(backup(10)).containsEntry("status", "BACKLOG").containsEntry("notes", null);
}
@Test
void join_ASplitEditionStaysOutOfTheRule() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "PLAYING", 100);
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
link(10, 20);
assertThat(row(USER, 10)).containsEntry("status", "BACKLOG");
assertThat(row(USER, 20)).containsEntry("status", "PLAYING");
}
@Test
void newRow_TakesTheValuesOfTheSet() {
link(10, 20);
own(USER, 10, "PLAYING", 0);
jdbc.update("update user_game set rating = 3 where appid = 10");
own(USER, 20, "BACKLOG", 0);
assertThat(row(USER, 20)).containsEntry("status", "PLAYING").containsEntry("rating", 3);
}
@Test
void newRow_MoreAdvancedThanTheSet_WinsForAllEditions() {
link(10, 20);
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "FINISHED", 0);
assertThat(row(USER, 10)).containsEntry("status", "FINISHED");
}
@Test
void join_IsNoHandStatusChangeOfTheUser() {
own(USER, 10, "BACKLOG", 0);
own(USER, 20, "BACKLOG", 0);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where appid = 20");
jdbc.update("delete from user_game_status_change");
link(10, 20);
assertThat(jdbc.queryForObject("select count(*) from user_game_status_change where user_id = ? and appid = 10 "
+ "and by_user", Integer.class, USER)).isZero();
}
@Test
void join_KeepsTheFinishDateOfTheCountedEdition() {
own(USER, 10, "BACKLOG", 500);
own(USER, 20, "BACKLOG", 100);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where appid = 20");
jdbc.update("update user_game_status_change set changed_at = '2025-03-01T10:00:00Z' where appid = 20");
link(10, 20);
assertThat(jdbc.queryForObject("""
select extract(year from min(sc.changed_at))::int from counted_user_game c
join user_game_status_change sc on sc.user_id = c.user_id and sc.appid = c.appid
where c.user_id = ? and sc.to_status in ('FINISHED', 'COMPLETED')
and sc.from_status not in ('FINISHED', 'COMPLETED')""", Integer.class, USER)).isEqualTo(2025);
}
}
@@ -115,6 +115,15 @@ class QuestionRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactly(10L);
}
@Test
void facts_ASplitEditionIsItsOwnEntry() {
game(USER, 20, "BACKLOG", 0);
jdbc.update("insert into game_link (appid, set_id) values (10, 1), (20, 1)");
jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER);
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactlyInAnyOrder(10L, 20L);
}
@Test
void facts_AppidFilter() {
game(USER, 20, "BACKLOG", 0);
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,6 +12,7 @@ import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Instant;
import java.util.List;
@@ -19,7 +21,9 @@ import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
@@ -35,6 +39,12 @@ class GameEditionServiceTest extends AbstractUnitTest {
@Mock
private GameEditionRepository gameEditionRepository;
@Mock
private GameLinkRepository gameLinkRepository;
@Mock
private PlatformTransactionManager transactionManager;
@Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@@ -89,6 +99,42 @@ class GameEditionServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L));
verifyNoInteractions(gameEditionRepository);
verifyNoInteractions(gameEditionRepository, gameLinkRepository);
}
@Test
void refresh_LinksThePairsOfTheAnsweredGames() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.Edition(72, 10), 700L, new IgdbApiService.Edition(73, 10))));
when(gameLinkRepository.sameGamePairs(any())).thenReturn(List.<long[]>of(new long[]{620, 700}));
when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A", 700L, "B"));
service.refresh(List.of(620L, 700L));
verify(gameLinkRepository).sameGamePairs(Set.of(620L, 700L));
verify(gameLinkRepository).link(620, 700);
}
@Test
void linkSameGames_PairAlreadyInOneSet_IsNotLinkedAgain() {
when(gameLinkRepository.sameGamePairs(null)).thenReturn(List.<long[]>of(new long[]{620, 700}));
when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A", 700L, "B"));
when(gameLinkRepository.inOneSet(620, 700)).thenReturn(true);
assertEquals(0, service.linkSameGames(null));
verify(gameLinkRepository, never()).link(anyLong(), anyLong());
}
@Test
void linkSameGames_UnknownKey_IsNotLinked() {
when(gameLinkRepository.sameGamePairs(null)).thenReturn(List.<long[]>of(new long[]{620, 700}));
when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A"));
assertEquals(0, service.linkSameGames(null));
verify(gameLinkRepository, never()).link(anyLong(), anyLong());
}
}
@@ -23,6 +23,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L;
private static final long GAME = 220L;
private static final long EDITION = 221L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
@@ -222,6 +223,75 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.completedCandidates(USER)).isEmpty();
}
// The game and a manual edition of it, one set, both with the given status.
private void setOfTwo(String status) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (manual)') on conflict do nothing", EDITION);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, EDITION);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, EDITION);
setStatus(status);
}
private void paid(RewardKind kind, long appid) {
ledger.add(USER, new RewardEntry(kind, String.valueOf(appid), 10, Instant.now()));
}
@Test
void finishCandidates_ASetGivesACandidatePerEdition() {
setOfTwo("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(2);
}
@Test
void finishCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() {
setOfTwo("FINISHED");
paid(RewardKind.FINISHED_MANUAL, EDITION);
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void startCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() {
setOfTwo("PLAYING");
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(2);
paid(RewardKind.STARTED, EDITION);
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
}
@Test
void completedCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() {
setOfTwo("COMPLETED");
paid(RewardKind.FINISHED, GAME);
paid(RewardKind.FINISHED, EDITION);
assertThat(repository.completedCandidates(USER)).hasSize(2);
paid(RewardKind.COMPLETED, EDITION);
assertThat(repository.completedCandidates(USER)).isEmpty();
}
@Test
void candidates_ASplitEditionIsItsOwnCandidateAgain() {
setOfTwo("FINISHED");
jdbc.update("insert into user_game_split (user_id, appid) values (?, ?)", USER, EDITION);
paid(RewardKind.FINISHED, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.appid()).isEqualTo(EDITION));
}
@Test
void finishCandidates_AGameInNoSetIsUnchanged() {
game("BACKLOG", 120);
setStatus("FINISHED");
paid(RewardKind.FINISHED, EDITION);
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(1);
}
@Test
void pileFirstAndLast_NeedsBothDays() {
YearMonth month = YearMonth.of(2026, 9);
@@ -310,4 +380,46 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.minutesPerMonth(USER)).containsOnly(java.util.Map.entry(YearMonth.of(2026, 9), 30L));
}
@Test
void finishCandidates_SetKeepsTheEditionThatCanPay() {
long unplayed = 219L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (old key)') on conflict do nothing", unplayed);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)",
USER, unplayed);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, unplayed);
setStatus("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).extracting(FinishFacts::appid).contains(GAME);
}
@Test
void startCandidates_ManualEditionDoesNotBlockTheSteamStart() {
long manual = -1_220L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (Switch)') on conflict do nothing", manual);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, manual);
setStatus("PLAYING");
assertThat(repository.startCandidates(USER, BERLIN)).extracting(StartFacts::appid).contains(GAME);
}
@Test
void completedCandidates_FinishPaidOnAnotherEditionOfTheSet() {
long other = 219L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (old key)') on conflict do nothing", other);
game("BACKLOG", 120);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 30)",
USER, other);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, other);
setStatus("COMPLETED");
jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true) "
+ "on conflict do nothing", GAME);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
paid(RewardKind.FINISHED, other);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME);
}
}
@@ -0,0 +1,49 @@
package core_service.modules.rewards.service;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
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 static org.assertj.core.api.Assertions.assertThat;
class RewardServiceSetTest extends AbstractIntegrationTest {
private static final long USER = 14L;
private static final long GAME = 230L;
private static final long EDITION = 231L;
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION);
jdbc.update("delete from user_game where user_id = ?", USER);
}
@Test
void wallet_TwoEditionsThatCouldPay_BooksTheStartOnce() {
for (long appid : new long[]{GAME, EDITION}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Set game') on conflict do nothing", appid);
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, 'BACKLOG')", USER, appid);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, appid);
}
jdbc.update("insert into game_link (appid, set_id) values (?, 2300), (?, 2300)", GAME, EDITION);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER);
rewardService.wallet(USER, Role.USER);
assertThat(jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'STARTED'",
Integer.class, USER)).isEqualTo(1);
}
}