feat(psn): sync PlayStation titles in the library sync

Exact IGDB matches are added; unsure titles wait in a review import.
Playtime and last played come from PSN: summed per game, not editable.
PSN games give no Pile Points. Users with only a PSN link sync too
(V39 drops steam_id NOT NULL). Linking starts a sync, or schedules one
for the end of the cooldown. Inbox notes 2 days before the PSN link
ends and when it has ended.
This commit is contained in:
lobo committed 2026-10-08 08:15:37 +02:00
1 parent 7ac887ed5a
commit f5ca7237a9
54 files changed
+2230 -313

No files matched your search

@@ -1,14 +1,18 @@
package core_service.modules.library.api;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.ContractTokens;
import core_service.shared.test.AbstractMockedAdaptersTest;
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;
@@ -18,6 +22,8 @@ import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -28,19 +34,16 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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;
/**
* The dev profile authenticates every request as user 1. PlayStation is mocked at the two adapter services.
* Every request is user 1 with a login token that has no Steam ID: a user with only a PlayStation link. PlayStation is
* mocked at the two adapter services.
*/
@AutoConfigureMockMvc
class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
@@ -50,6 +53,8 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
private static final String ACCESS = "ACCESS-SECRET-VALUE";
private static final IgdbApiService.Game GHOST = new IgdbApiService.Game(300, "Ghost of Yotei", 2025, null,
List.of("PS5"), null);
private static final Cookie PSN_ONLY_USER = new Cookie("tbp_access", ContractTokens.loginToken(1L));
private static final Instant PLAYED_FIRST = Instant.parse("2026-10-05T18:30:00Z");
@Autowired
private MockMvc mockMvc;
@@ -60,14 +65,26 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private LibrarySyncWorker worker;
// True once a link started the background sync of this test; the next test must not meet it.
private boolean syncStarted;
@Value("${app.psn.token-key}")
private String tokenKey;
@BeforeEach
void seed() {
syncStarted = false;
jdbc.update("delete from user_psn_account");
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_alias");
userGameRepository.deleteAll();
jdbc.update("delete from user_game_playtime_day");
jdbc.update("delete from sync_result");
jdbc.update("delete from user_steam_sync");
jdbc.update("delete from inbox_note");
jdbc.update("delete from game_import");
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(GHOST)));
@@ -75,8 +92,52 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of()));
when(psnAuthService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00",
"Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(125), 10L)));
when(igdbApiService.searchGames("Monster Hunter Wilds")).thenReturn(Optional.of(List.of()));
playing(125, PLAYED_FIRST);
}
private void playing(int ghostMinutes, Instant ghostLastPlayed) {
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(
new PsnApiService.Title("PPSA1_00", "Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(ghostMinutes),
10L, ghostLastPlayed),
new PsnApiService.Title("PPSA2_00", "Monster Hunter Wilds", "ps5_native_game", Duration.ofMinutes(30),
11L, null)));
}
@AfterEach
void awaitTheSync() throws InterruptedException {
if (syncStarted) {
awaitSync();
}
}
private void awaitSync() throws InterruptedException {
for (int attempt = 0; attempt < 100; attempt++) {
String status = jdbc.queryForList("select last_sync_status from user_steam_sync where user_id = 1",
String.class).stream().findFirst().orElse(null);
if ("OK".equals(status) || "FAILED".equals(status)) {
assertThat(status).isEqualTo("OK");
return;
}
Thread.sleep(100);
}
throw new AssertionError("The sync did not finish");
}
private static MockHttpServletRequestBuilder get(String url) {
return MockMvcRequestBuilders.get(url).cookie(PSN_ONLY_USER);
}
private static MockHttpServletRequestBuilder post(String url) {
return MockMvcRequestBuilders.post(url).cookie(PSN_ONLY_USER);
}
private static MockHttpServletRequestBuilder put(String url) {
return MockMvcRequestBuilders.put(url).cookie(PSN_ONLY_USER);
}
private static MockHttpServletRequestBuilder delete(String url) {
return MockMvcRequestBuilders.delete(url).cookie(PSN_ONLY_USER);
}
private static PsnAuthService.Tokens tokens(Instant refreshExpires) {
@@ -84,29 +145,17 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
}
private ResultActions link(String npsso) throws Exception {
return mockMvc.perform(post("/api/library/psn-account")
ResultActions linked = mockMvc.perform(post("/api/library/psn-account")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"npsso\": \"" + npsso + "\"}"));
syncStarted |= linked.andReturn().getResponse().getStatus() == 200;
return linked;
}
private ResultActions startImport() throws Exception {
return mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private void awaitStatus(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
return;
}
Thread.sleep(100);
}
throw new AssertionError("Import " + id + " not " + expected);
}
private long startedImport() throws Exception {
return ((Number) JsonPath.read(startImport().andExpect(status().isAccepted()).andReturn().getResponse()
.getContentAsString(), "$.id")).longValue();
private long reviewImportId() throws Exception {
return ((Number) JsonPath.read(mockMvc.perform(get("/api/library/psn-account")).andReturn().getResponse()
.getContentAsString(), "$.reviewImportId")).longValue();
}
@Test
@@ -169,88 +218,199 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
}
@Test
void import_StartsAPsnImportWithTheTitlesAsLines() throws Exception {
void link_StartsASync_ThatAddsTheExactTitle_AndPutsTheUnsureOneInAReview() throws Exception {
link(NPSSO).andExpect(status().isOk());
awaitSync();
long id = startedImport();
assertThat(jdbc.queryForObject("select source from game_import where id = ?", String.class, id)).isEqualTo("PSN");
assertThat(jdbc.queryForMap("select title, platform, playtime_minutes from game_import_row where import_id = ?", id))
.containsEntry("title", "Ghost of Yotei").containsEntry("platform", "PS5")
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, last_played_at "
+ "from user_game where user_id = 1 and appid = -300");
assertThat(row).containsEntry("source", "PSN").containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125);
assertThat(((java.sql.Timestamp) row.get("last_played_at")).toInstant()).isEqualTo(PLAYED_FIRST);
assertThat(jdbc.queryForObject("select imported_games from sync_result where user_id = 1", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForMap("select state, appid from user_psn_title where psn_key = 'c10'"))
.containsEntry("state", "LINKED").containsEntry("appid", -300L);
long importId = reviewImportId();
assertThat(jdbc.queryForMap("select source, status, total from game_import where id = ?", importId))
.containsEntry("source", "PSN").containsEntry("status", "READY").containsEntry("total", 1);
assertThat(jdbc.queryForMap("select title, psn_key, matched_at is not null as matched from game_import_row "
+ "where import_id = ?", importId))
.containsEntry("title", "Monster Hunter Wilds").containsEntry("psn_key", "c11")
.containsEntry("matched", true);
mockMvc.perform(get("/api/library/imports/" + importId)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("READY")).andExpect(jsonPath("$.rows.length()").value(1));
}
@Test
void import_NotLinked_Is404() throws Exception {
startImport().andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PSN_NOT_LINKED"));
}
@Test
void import_ExpiredLink_Is409Relink_WithoutAPsnCall() throws Exception {
void secondSync_BooksThePlaytimeGrowth_AndOffersNothingTwice_AnUncheckedReviewIsDismissedForGood() throws Exception {
link(NPSSO).andExpect(status().isOk());
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 hour'");
awaitSync();
long importId = reviewImportId();
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
verify(psnAuthService, never()).refresh(any());
playing(185, Instant.parse("2026-10-06T20:00:00Z"));
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1"))
.containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60);
assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300",
Integer.class)).isEqualTo(60);
assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300"))
.containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60);
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
mockMvc.perform(post("/api/library/imports/" + importId + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"rows\": []}"))
.andExpect(status().isAccepted());
awaitImport(importId, "SAVED");
assertThat(jdbc.queryForObject("select state from user_psn_title where psn_key = 'c11'", String.class))
.isEqualTo("DISMISSED");
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
verify(igdbApiService, times(1)).searchGames("Monster Hunter Wilds");
}
@Test
void import_RefreshRefused_Is409Relink_AndTheLinkShowsAsExpired() throws Exception {
void save_LeavesALineTheUserNeverSawOpen_InANewReview() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.empty());
awaitSync();
long importId = reviewImportId();
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
mockMvc.perform(post("/api/library/imports/" + importId + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"rows\": [], \"shownLineNos\": []}"))
.andExpect(status().isAccepted());
awaitImport(importId, "SAVED");
assertThat(jdbc.queryForObject("select refresh_expires_at <= now() from user_psn_account", Boolean.class)).isTrue();
assertThat(jdbc.queryForObject("select count(*) from user_psn_title where psn_key = 'c11' and state = 'DISMISSED'",
Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_import where status = 'READY' and total = 1",
Integer.class)).isEqualTo(1);
}
@Test
void import_NoTitles_Is204() throws Exception {
void savedReviewLine_LinksTheTitle_SoARemovedGameIsNeverAddedAgain() throws Exception {
when(igdbApiService.searchGames("Monster Hunter Wilds")).thenReturn(Optional.of(List.of(
new IgdbApiService.Game(301, "Monster Hunter Wilds", 2025, null, List.of("PC"), null))));
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
awaitSync();
long importId = reviewImportId();
startImport().andExpect(status().isNoContent());
mockMvc.perform(post("/api/library/imports/" + importId + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 301, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 30}]}"))
.andExpect(status().isAccepted());
awaitImport(importId, "SAVED");
assertThat(jdbc.queryForMap("select state, appid from user_psn_title where psn_key = 'c11'"))
.containsEntry("state", "LINKED").containsEntry("appid", -301L);
mockMvc.perform(delete("/api/library/games/-301").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
}
@Test
void import_PsnDown_Is502_AndIgdbOff_Is503() throws Exception {
void savedReview_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
when(igdbApiService.searchGames("Monster Hunter Wilds")).thenReturn(Optional.of(List.of(
new IgdbApiService.Game(301, "Monster Hunter Wilds", 2025, null, List.of("PC"), null))));
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
startImport().andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
doReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game", Duration.ZERO, null)))
.when(psnApiService).getPlayedTitles(ACCESS);
when(igdbApiService.isEnabled()).thenReturn(false);
startImport().andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("IGDB_OFF"));
}
@Test
void savedPsnImport_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
link(NPSSO).andExpect(status().isOk());
long id = startedImport();
awaitStatus(id, "READY");
awaitSync();
long id = reviewImportId();
mockMvc.perform(post("/api/library/imports/" + id + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 300, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 125}]}"))
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 301, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 30}]}"))
.andExpect(status().isAccepted());
awaitStatus(id, "SAVED");
awaitImport(id, "SAVED");
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, import_id from user_game "
+ "where user_id = 1 and appid = -300");
assertThat(row).containsEntry("source", GameSource.PSN.name()).containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125).containsEntry("import_id", id);
+ "where user_id = 1 and appid = -301");
assertThat(row).containsEntry("source", "PSN").containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 30).containsEntry("import_id", id);
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check")).andExpect(jsonPath("$.games").value(1));
mockMvc.perform(put("/api/library/games/-300/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
mockMvc.perform(put("/api/library/games/-301/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"platform\": \"PS4\", \"playtimeMinutes\": 200}"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select platform from user_game where appid = -300", String.class)).isEqualTo("PS4");
assertThat(jdbc.queryForObject("select platform from user_game where appid = -301", String.class)).isEqualTo("PS4");
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -300", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
}
@Test
void gamesCount_AnswersCanSync_AlsoForAUserWithOnlyAPsnLink() throws Exception {
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.canSync").value(false));
link(NPSSO).andExpect(status().isOk());
awaitSync();
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.canSync").value(true))
.andExpect(jsonPath("$.hasSteamId").value(false));
}
@Test
void expiredLink_WritesOneInboxNote_ForManySyncs_AndCallsPsnNoMore() throws Exception {
link(NPSSO).andExpect(status().isOk());
awaitSync();
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 minute'");
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'PSN_EXPIRED' and user_id = 1 "
+ "and subject_user_id = 1", Integer.class)).isEqualTo(1);
mockMvc.perform(get("/api/inbox")).andExpect(jsonPath("$.results[0].kind").value("PSN_EXPIRED"))
.andExpect(jsonPath("$.unread").value(1));
verify(psnAuthService, times(1)).refresh(REFRESH);
}
@Test
void linkEndingTomorrow_WritesTheExpiringNoteOnce_AndTheSyncStillRuns() throws Exception {
link(NPSSO).andExpect(status().isOk());
awaitSync();
jdbc.update("update user_psn_account set refresh_expires_at = now() + interval '1 day'");
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'PSN_EXPIRING'", Integer.class))
.isEqualTo(1);
verify(psnAuthService, times(3)).refresh(REFRESH);
}
@Test
void psnDown_DuringTheSync_LeavesTheLinkAndTheSyncOk() throws Exception {
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
link(NPSSO).andExpect(status().isOk());
awaitSync();
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(true));
assertThat(jdbc.queryForObject("select count(*) from user_game", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from inbox_note", Integer.class)).isZero();
}
@Test
void importEndpointOfTheOldFlow_IsGone() throws Exception {
mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().is4xxClientError());
}
private void awaitImport(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
return;
}
Thread.sleep(100);
}
throw new AssertionError("Import " + id + " not " + expected);
}
@Test
@@ -0,0 +1,155 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import({GameImportRepository.class, GameImportRepositoryTest.Mapper.class})
class GameImportRepositoryTest extends AbstractPersistenceTest {
@TestConfiguration
static class Mapper {
@Bean
JsonMapper jsonMapper() {
return JsonMapper.builder().build();
}
}
private static final IgdbGame GAME = new IgdbGame(300, "Ghost of Yotei", 2025, null, List.of("PS5"), null);
@Autowired
private GameImportRepository repository;
@Autowired
private JdbcTemplate jdbc;
private static GameImport.Row row(String title, String psnKey, IgdbGame... candidates) {
return new GameImport.Row(new GameImport.Line(1, title, "PS5", 60, GameStatus.PLAYING, null, psnKey), false, false,
List.of(candidates));
}
private void setStatus(long id, String status) {
jdbc.update("update game_import set status = ? where id = ?", status, id);
}
@Test
void createReady_WritesAReadyPsnImportWithMatchedRowsPsnKeyAndCandidates() {
long id = repository.createReady(1L, List.of(row("Ghost of Yotei", "c10", GAME), row("Wilds", "c11")));
GameImport found = repository.find(1L, id).orElseThrow();
assertThat(found.source()).isEqualTo(GameImport.Source.PSN);
assertThat(found.status()).isEqualTo(GameImport.Status.READY);
assertThat(found.total()).isEqualTo(2);
assertThat(found.matched()).isEqualTo(2);
List<GameImport.Row> rows = repository.findRows(id);
assertThat(rows).extracting(r -> r.line().lineNo()).containsExactly(1, 2);
assertThat(rows).extracting(r -> r.line().psnKey()).containsExactly("c10", "c11");
assertThat(rows.getFirst().candidates()).containsExactly(GAME);
assertThat(rows.get(1).candidates()).isEmpty();
assertThat(repository.findUnmatchedLines(id)).isEmpty();
}
@Test
void appendReadyRows_ContinuesTheLineNumbers_AndRaisesTheTotal() {
long id = repository.createReady(2L, List.of(row("A", "c1")));
assertThat(repository.appendReadyRows(id, List.of(row("B", "c2"), row("C", "c3")))).isTrue();
assertThat(repository.findRows(id)).extracting(r -> r.line().lineNo()).containsExactly(1, 2, 3);
assertThat(repository.find(2L, id).orElseThrow().total()).isEqualTo(3);
}
@Test
void appendReadyRows_AnImportTheUserSavedMeanwhile_GetsNothing() {
long id = repository.createReady(3L, List.of(row("A", "c1")));
setStatus(id, "SAVING");
assertThat(repository.appendReadyRows(id, List.of(row("B", "c2")))).isFalse();
assertThat(repository.findRows(id)).hasSize(1);
assertThat(repository.find(3L, id).orElseThrow().total()).isEqualTo(1);
}
@Test
void findOpenPsnImport_AndKeys_SeeOnlyThePsnImportsOfTheUserInTheFourOpenStates() {
long finished = repository.createReady(4L, List.of(row("Old", "c1")));
setStatus(finished, "SAVED");
long undone = repository.createReady(4L, List.of(row("Undone", "c2")));
setStatus(undone, "UNDONE");
long failed = repository.createReady(4L, List.of(row("Failed", "c3")));
setStatus(failed, "FAILED");
repository.createReady(5L, List.of(row("Other user", "c4")));
long text = repository.create(4L, GameImport.Source.TEXT, List.of(
new GameImport.Line(1, "Typed", null, null, null, null, null)));
assertThat(text).isPositive();
assertThat(repository.findOpenPsnImport(4L)).isEmpty();
assertThat(repository.findOpenPsnKeys(4L)).isEmpty();
// The V22 index allows one queued import per user.
setStatus(text, "SAVED");
long open = repository.createReady(4L, List.of(row("Open", "c5"), row("No key", null)));
assertThat(repository.findOpenPsnImport(4L)).hasValueSatisfying(found -> assertThat(found.id()).isEqualTo(open));
assertThat(repository.findOpenPsnKeys(4L)).containsExactly("c5");
for (String status : List.of("QUEUED", "MATCHING", "SAVING")) {
setStatus(open, status);
assertThat(repository.findOpenPsnImport(4L)).isPresent();
}
}
@Test
void findPsnKeysWithoutChoice_ListsTheLinesTheUserDidNotCheck() {
long id = repository.createReady(6L, List.of(row("A", "c1"), row("B", "c2"), row("Typed", null)));
repository.saveChoices(id, List.of(new GameImport.Choice(1, 300, "PS5", GameStatus.PLAYING, 60)));
assertThat(repository.findPsnKeysWithoutChoice(id)).containsExactly("c2");
}
@Test
void moveUnseenToNewReady_TakesTheLinesWithoutChoiceTheUserDidNotSee_ToANewReadyImport() {
long id = repository.createReady(8L, List.of(row("A", "c1"), row("B", "c2"), row("C", "c3"), row("D", "c4")));
repository.saveChoices(id, List.of(new GameImport.Choice(1, 300, "PS5", GameStatus.PLAYING, 60)));
repository.moveUnseenToNewReady(8L, id, java.util.Set.of(1, 2));
assertThat(repository.findPsnKeysWithoutChoice(id)).containsExactly("c2");
assertThat(repository.find(8L, id).orElseThrow().total()).isEqualTo(2);
GameImport open = repository.findOpenPsnImport(8L).orElseThrow();
assertThat(open.id()).isNotEqualTo(id);
assertThat(open.status()).isEqualTo(GameImport.Status.READY);
assertThat(open.total()).isEqualTo(2);
assertThat(repository.findRows(open.id())).extracting(r -> r.line().lineNo(), r -> r.line().psnKey())
.containsExactly(org.assertj.core.groups.Tuple.tuple(1, "c3"), org.assertj.core.groups.Tuple.tuple(2, "c4"));
}
@Test
void moveUnseenToNewReady_WhenAllWereSeen_CreatesNothing() {
long id = repository.createReady(9L, List.of(row("A", "c1")));
repository.moveUnseenToNewReady(9L, id, java.util.Set.of(1));
assertThat(repository.findOpenPsnImport(9L)).hasValueSatisfying(found -> assertThat(found.id()).isEqualTo(id));
}
@Test
void create_StoresThePsnKeyOfALine() {
long id = repository.create(7L, GameImport.Source.PSN, List.of(
new GameImport.Line(1, "A", "PS5", 1, GameStatus.PLAYING, null, "c9")));
assertThat(jdbc.queryForMap("select psn_key from game_import_row where import_id = ?", id))
.isEqualTo(Map.of("psn_key", "c9"));
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -10,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -57,6 +59,19 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void minutesPerGameSince_SumsOnlyTheAskedGamesOfTheUserFromTheDate() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.plusDays(1), 20);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 10);
playtimeDayRepository.addMinutes(USER_ID, 730L, DAY, 500);
playtimeDayRepository.addMinutes(2L, 220L, DAY, 500);
assertEquals(Set.of(new AppidMinutes(220L, 50), new AppidMinutes(620L, 10)),
Set.copyOf(playtimeDayRepository.minutesPerGameSince(USER_ID, List.of(220L, 620L), DAY)));
}
@Test
void minutesPerDay_LeavesOutHiddenGames() {
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -5,6 +5,7 @@ import core_service.shared.test.AbstractPersistenceTest;
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.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -17,6 +18,9 @@ class PsnAccountRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnAccountRepository repository;
@Autowired
private JdbcTemplate jdbc;
@Test
void upsert_StoresTheBytes_AndALaterLinkReplacesThem() {
Instant first = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
@@ -62,4 +66,58 @@ class PsnAccountRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.find(8L)).isEmpty();
assertThat(repository.delete(8L)).isZero();
}
private int notes(long userId, String kind) {
return jdbc.queryForObject("select count(*) from inbox_note where user_id = ? and subject_user_id = ? and kind = ?",
Integer.class, userId, userId, kind);
}
@Test
void noteExpiry_Expiring_WritesOneNoteForTheReader_AndOnlyOncePerLink() {
repository.upsert(10L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
assertThat(repository.noteExpiry(10L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(repository.noteExpiry(10L, "PSN_EXPIRING")).isZero();
assertThat(notes(10L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(repository.find(10L).orElseThrow().expiryNote()).isEqualTo("PSN_EXPIRING");
}
@Test
void noteExpiry_ExpiredAfterExpiring_WritesOneMore_AndExpiringAfterExpiredNone() {
repository.upsert(11L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
repository.noteExpiry(11L, "PSN_EXPIRING");
assertThat(repository.noteExpiry(11L, "PSN_EXPIRED")).isEqualTo(1);
assertThat(repository.noteExpiry(11L, "PSN_EXPIRED")).isZero();
assertThat(repository.noteExpiry(11L, "PSN_EXPIRING")).isZero();
assertThat(notes(11L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(notes(11L, "PSN_EXPIRED")).isEqualTo(1);
assertThat(repository.find(11L).orElseThrow().expiryNote()).isEqualTo("PSN_EXPIRED");
}
@Test
void noteExpiry_ExpiredWithoutAnEarlierNote_WritesIt() {
repository.upsert(12L, new byte[]{1}, Instant.now().minusSeconds(5));
assertThat(repository.noteExpiry(12L, "PSN_EXPIRED")).isEqualTo(1);
}
@Test
void noteExpiry_UserWithoutALink_WritesNothing() {
assertThat(repository.noteExpiry(13L, "PSN_EXPIRED")).isZero();
assertThat(notes(13L, "PSN_EXPIRED")).isZero();
}
@Test
void upsert_ANewLink_ResetsTheNote_SoTheNextExpiryIsAnnouncedAgain() {
repository.upsert(14L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
repository.noteExpiry(14L, "PSN_EXPIRED");
repository.upsert(14L, new byte[]{2}, Instant.now().plus(10, ChronoUnit.DAYS));
assertThat(repository.find(14L).orElseThrow().expiryNote()).isNull();
assertThat(repository.noteExpiry(14L, "PSN_EXPIRING")).isEqualTo(1);
}
}
@@ -0,0 +1,75 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.shared.test.AbstractPersistenceTest;
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 static org.assertj.core.api.Assertions.assertThat;
@Import(PsnTitleRepository.class)
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnTitleRepository repository;
@Autowired
private JdbcTemplate jdbc;
private void game(long userId, long appid, String source) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", appid);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', ?)", userId, appid,
source);
}
@Test
void linkOwned_ASteamRowIsSteamOwned_AnyOtherRowIsLinked() {
game(5L, -1L, "STEAM");
game(5L, -2L, "MANUAL");
repository.linkOwned(5L, "c1", -1L);
repository.linkOwned(5L, "c2", -2L);
repository.linkOwned(5L, "c3", -3L);
assertThat(repository.findAll(5L)).containsOnly(
Map.entry("c1", new PsnTitle("c1", PsnTitle.State.STEAM_OWNED, -1L)),
Map.entry("c2", new PsnTitle("c2", PsnTitle.State.LINKED, -2L)),
Map.entry("c3", new PsnTitle("c3", PsnTitle.State.LINKED, -3L)));
}
@Test
void link_StoresTheAppid_AndALaterLinkOverADismissedTitleWins() {
repository.dismiss(1L, List.of("c1"));
repository.link(1L, "c1", -300L);
assertThat(repository.findAll(1L)).containsOnly(Map.entry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -300L)));
}
@Test
void dismiss_LeavesALinkedTitleLinked_AndIsIdempotent() {
repository.link(2L, "c1", -300L);
repository.dismiss(2L, List.of("c1", "t2"));
repository.dismiss(2L, List.of("t2"));
repository.dismiss(2L, List.of());
assertThat(repository.findAll(2L)).containsOnly(
Map.entry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -300L)),
Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null)));
}
@Test
void findAll_ReturnsOnlyTheUsersTitles() {
repository.link(3L, "c1", -1L);
repository.link(4L, "c1", -2L);
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
assertThat(repository.findAll(5L)).isEmpty();
}
}
@@ -261,4 +261,50 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(lastSuccessAt(24L)).isEqualTo(success);
}
@Test
void tryClaimRun_WithoutSteamId_ClaimsARowForAPsnOnlyUser_AndMarkFinishedWorksOnIt() {
Instant started = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS);
assertThat(repository.tryClaimRun(40L, null, started, started.minusSeconds(600))).isEqualTo(1);
assertThat(repository.tryClaimRun(40L, null, started, started.minusSeconds(600))).isZero();
assertThat(repository.markFinished(40L, started, "OK", Instant.now())).isEqualTo(1);
UserSteamSync row = repository.findById(40L).orElseThrow();
assertThat(row.getSteamId()).isNull();
assertThat(row.getLastSyncStatus()).isEqualTo(UserSteamSync.Status.OK);
assertThat(row.getLastSuccessAt()).isNotNull();
}
@Test
void tryClaimRun_SteamIdAppearsOnAPsnOnlyRow_ResetsTheSuccessAndStoresTheId() {
givenRow(41L, null, "OK", null, "1 hour", null);
assertThat(claim(41L)).isEqualTo(1);
UserSteamSync row = repository.findById(41L).orElseThrow();
assertThat(row.getSteamId()).isEqualTo(STEAM_ID);
assertThat(row.getLastSuccessAt()).isNull();
}
@Test
void tryStartLoginSync_WithoutSteamId_CreatesTheRow_AndTheGateHoldsLikeForSteam() {
assertThat(tryLogin(42L, null)).isEqualTo(1);
assertThat(repository.findById(42L).orElseThrow().getSteamId()).isNull();
givenRow(43L, null, "OK", null, "10 hours", null);
assertThat(tryLogin(43L, null)).isZero();
givenRow(44L, null, "OK", null, "21 hours", null);
assertThat(tryLogin(44L, null)).isEqualTo(1);
}
@Test
void tryStartLoginSync_NullVersusAnId_CountsAsAChangedAccountInBothDirections() {
givenRow(45L, null, "OK", null, "1 hour", null);
givenRow(46L, STEAM_ID, "OK", null, "1 hour", null);
assertThat(tryLogin(45L, STEAM_ID)).isEqualTo(1);
assertThat(tryLogin(46L, null)).isEqualTo(1);
}
}
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
@@ -25,7 +26,7 @@ class GameImportWorkerTest {
}
private static GameImport.Line line(String title, String platform) {
return new GameImport.Line(1, title, platform, null, null, null);
return new GameImport.Line(1, title, platform, null, null, null, null);
}
@Test
@@ -56,7 +57,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
mock(PsnTitleRepository.class), mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
try {
worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -2,14 +2,18 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
@@ -18,6 +22,7 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -26,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
@@ -50,6 +56,15 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@Mock
private FinishedEstimateService finishedEstimateService;
@Mock
private PsnAccountRepository psnAccountRepository;
@Mock
private TaskScheduler taskScheduler;
@Mock
private ScheduledFuture<Object> pendingTask;
@InjectMocks
private LibrarySyncService syncService;
@@ -281,4 +296,149 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
private void givenPsnLink(boolean linked) {
when(psnAccountRepository.find(USER_ID)).thenReturn(linked
? Optional.of(new PsnAccount(USER_ID, new byte[0], Instant.now().plus(Duration.ofDays(9)), Instant.now(), null))
: Optional.empty());
}
@Test
void syncIfStale_NoSteamIdAndNoPsnLink_StopsTheNightlySyncAndStartsNothing() {
givenPsnLink(false);
assertFalse(syncService.syncIfStale(USER_ID, null));
verify(userSteamSyncRepository).clearUserSeen(USER_ID);
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), any(), any(), any(), any(), any());
verify(worker, never()).runSync(anyLong(), any(), any());
}
@Test
void syncIfStale_NoSteamIdButAPsnLink_UsesTheLoginGateAndRunsWithoutSteam() {
givenPsnLink(true);
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
when(userSteamSyncRepository.tryStartLoginSync(eq(USER_ID), eq(null), any(), any(), any(), any())).thenReturn(1);
assertTrue(syncService.syncIfStale(USER_ID, null));
verify(worker).runSync(USER_ID, null, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_PsnOnlyUserWithAFreshSync_DoesNotAskTheGate() {
givenPsnLink(true);
givenSync(null, UserSteamSync.Status.OK, Duration.ofHours(1), null);
assertFalse(syncService.syncIfStale(USER_ID, null));
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), any(), any(), any(), any(), any());
}
@Test
void forceSync_NoSteamIdAndNoPsnLink_Throws() {
givenPsnLink(false);
assertThrows(IllegalArgumentException.class, () -> syncService.forceSync(USER_ID, null));
verify(worker, never()).runSync(anyLong(), any(), any());
}
@Test
void forceSync_NoSteamIdButAPsnLink_StartsAManualSync() {
givenPsnLink(true);
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
syncService.forceSync(USER_ID, null);
verify(worker).runSync(USER_ID, null, SyncTrigger.MANUAL);
}
@Test
void canSync_NeedsASteamIdOrAPsnLink() {
givenPsnLink(false);
assertTrue(syncService.canSync(USER_ID, STEAM_ID));
assertFalse(syncService.canSync(USER_ID, null));
givenPsnLink(true);
assertTrue(syncService.canSync(USER_ID, null));
}
@Test
void startAfterLink_AfterTheCooldown_RunsAManualSync() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
userSteamSyncRepository.findById(USER_ID).ifPresent(sync -> sync.setLastSyncedAt(Instant.now().minusSeconds(120)));
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
@Test
void startAfterLink_InsideTheCooldown_SchedulesOneSyncAtTheCooldownEnd_SoRepeatedLinksNeverStack() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
Instant lastSyncedAt = userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncedAt();
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
syncService.startAfterLink(USER_ID, STEAM_ID);
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), any(), any());
verify(taskScheduler, times(1)).schedule(any(Runnable.class), eq(lastSyncedAt.plusSeconds(60)));
}
@Test
void startAfterLink_ScheduledSync_RunsAtItsTime_AndTheNextLinkMaySchedule() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(taskScheduler).schedule(task.capture(), any(Instant.class));
userSteamSyncRepository.findById(USER_ID).orElseThrow().setLastSyncedAt(Instant.now().minusSeconds(120));
task.getValue().run();
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
@Test
void startAfterLink_ScheduledSync_UsesTheSteamIdOfTheSyncRowAtThatTime() {
givenSync(null, UserSteamSync.Status.OK, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
syncService.startAfterLink(USER_ID, null);
verify(taskScheduler).schedule(task.capture(), any(Instant.class));
UserSteamSync later = userSteamSyncRepository.findById(USER_ID).orElseThrow();
later.setSteamId(STEAM_ID);
later.setLastSyncedAt(Instant.now().minusSeconds(120));
task.getValue().run();
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
verify(worker, never()).runSync(eq(USER_ID), eq(null), any());
}
@Test
void startAfterLink_RunningSync_SchedulesTheSyncOneCooldownLater() {
givenSync(STEAM_ID, UserSteamSync.Status.RUNNING, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), any(), any());
ArgumentCaptor<Instant> runAt = ArgumentCaptor.forClass(Instant.class);
verify(taskScheduler).schedule(any(Runnable.class), runAt.capture());
assertTrue(runAt.getValue().isAfter(Instant.now().plusSeconds(50)));
}
@Test
void startAfterLink_NoSteamId_RunsWithoutSteam_AndMarksTheUserAsSeen() {
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, null);
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo));
syncService.startAfterLink(USER_ID, null);
verify(userSteamSyncRepository).touchUserSeen(eq(USER_ID), any());
verify(worker).runSync(USER_ID, null, SyncTrigger.MANUAL);
}
}
@@ -18,6 +18,7 @@ import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.event.LibraryActivityEvent;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -139,6 +140,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private PsnSyncStep psnSyncStep;
@Spy
private TransactionTemplate transaction = new TransactionTemplate(mock(PlatformTransactionManager.class));
@@ -148,6 +152,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
lenient().when(psnSyncStep.run(USER_ID)).thenReturn(PsnSyncStep.Prepared.NONE);
lenient().when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), anyString(), any())).thenReturn(1);
}
@@ -745,6 +750,80 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verifyNoInteractions(syncChangeRepository);
}
@Test
void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100);
steamReturns(game(160, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository);
order.verify(psnSyncStep).run(USER_ID);
order.verify(userGameRepository).findByUserId(USER_ID);
order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any());
order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList());
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60),
SyncChange.added(-9L), SyncChange.playtime(-8L, 30));
}
@Test
void runSync_PsnBaselineAdds_AreCountedNotListed() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), true);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 1)).containsExactly(SyncChange.playtime(-8L, 30));
}
@Test
void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())).thenReturn(List.of(SyncChange.added(-9L)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
verifyNoInteractions(steamApiService);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verifyNoInteractions(wishlistGameRepository);
verify(userGameRepository, never()).save(any(UserGame.class));
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactly(SyncChange.added(-9L));
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
verify(eventPublisher).publishEvent(new LibraryActivityEvent(USER_ID));
verify(eventPublisher, never()).publishEvent(any(LibrarySyncedEvent.class));
}
@Test
void runSync_WithoutSteamIdAndNothingFromPsn_IsAnOkRunWithAnEmptyResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).isEmpty();
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
}
@Test
void runSync_UserLostSteam_KeepsTheGames() {
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verifyNoInteractions(wishlistGameRepository);
}
@Test
void runSync_SteamFails_KeepsTheOldResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
@@ -1,8 +1,10 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
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.domain.UserGameId;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.SteamGameRepository;
@@ -203,4 +205,15 @@ class ManualGameServiceTest extends AbstractUnitTest {
verify(igdbApiService, never()).searchGames("Silksong");
verify(igdbApiService, times(3)).searchGames(anyString());
}
@Test
void update_PsnRow_KeepsThePsnPlaytime() {
UserGame psnRow = UserGame.manual(USER_ID, -119388L, GameSource.PSN, "PS5", GameStatus.PLAYING, 600,
Instant.parse("2026-10-01T10:00:00Z"));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.of(psnRow));
service.update(USER_ID, -119388L, "PS5", 0);
assertThat(psnRow.getPlaytimeMinutes()).isEqualTo(600);
}
}
@@ -2,11 +2,13 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -50,14 +52,14 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Mock
private PsnApiService apiService;
@Mock
private GameImportService importService;
private GameImportRepository importRepository;
private final SecretBox box = SecretBox.fromBase64Key(KEY);
private PsnAccountService service;
@BeforeEach
void setUp() {
service = new PsnAccountService(repository, authService, apiService, importService, KEY);
service = new PsnAccountService(repository, authService, apiService, importRepository, KEY, 2);
}
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
@@ -65,7 +67,7 @@ class PsnAccountServiceTest extends AbstractUnitTest {
}
private void linked(Instant expires) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now())));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now(), null)));
}
private static void assertNoSecrets(CapturedOutput output) {
@@ -76,7 +78,7 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Test
void link_SealsTheRefreshToken_AndLogsNoSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(NEW_REFRESH, EXPIRES)));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now())));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now(), null)));
PsnAccountService.LinkResult result = service.link(1L, NPSSO);
@@ -111,60 +113,55 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Test
void link_WithoutAKey_IsOff_AndCallsNothing() {
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importService, "");
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importRepository, "", 2);
assertThat(off.isEnabled()).isFalse();
assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class);
assertThat(off.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Off.class);
assertThat(off.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Off.class);
assertThat(off.get(1L).enabled()).isFalse();
verifyNoInteractions(authService, apiService, repository);
verifyNoInteractions(authService, apiService, repository, importRepository);
}
@Test
void constructor_RejectsAKeyOfTheWrongLength() {
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
new PsnAccountService(repository, authService, apiService, importService, "c2hvcnQ="))
new PsnAccountService(repository, authService, apiService, importRepository, "c2hvcnQ=", 2))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void startImport_RefreshesReadsTitles_AndCreatesAPsnImport(CapturedOutput output) {
void fetchTitles_RefreshesAndReadsTitles(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(5))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00", "Ghost",
"ps5_native_game", Duration.ofMinutes(90), 7L)));
when(importService.createLines(eq(1L), eq(GameImport.Source.PSN), any()))
.thenReturn(new GameImportService.CreateResult.Created(42L));
List<PsnApiService.Title> played = List.of(new PsnApiService.Title("PPSA1_00", "Ghost", "ps5_native_game",
Duration.ofMinutes(90), 7L, null));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(played);
PsnAccountService.ImportResult result = service.startImport(1L);
assertThat(service.fetchTitles(1L)).isEqualTo(new PsnAccountService.FetchResult.Titles(played));
assertThat(result).isEqualTo(new PsnAccountService.ImportResult.Started(42L));
ArgumentCaptor<List<GameImport.Line>> lines = ArgumentCaptor.captor();
verify(importService).createLines(eq(1L), eq(GameImport.Source.PSN), lines.capture());
assertThat(lines.getValue()).containsExactly(new GameImport.Line(1, "Ghost", "PS5", 90,
core_service.modules.library.domain.GameStatus.PLAYING, null));
verify(repository, never()).noteExpiry(anyLong(), any());
assertNoSecrets(output);
}
@Test
void startImport_SameRefreshToken_KeepsTheStoredExpiry() {
void fetchTitles_SameRefreshToken_KeepsTheStoredExpiry() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(999))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NoTitles.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Titles.class);
verify(repository, never()).updateToken(anyLong(), any(), any());
}
@Test
void startImport_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
void fetchTitles_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
linked(EXPIRES);
Instant newExpiry = Instant.now().plus(Duration.ofDays(10));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(NEW_REFRESH, newExpiry)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
service.startImport(1L);
service.fetchTitles(1L);
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
verify(repository).updateToken(eq(1L), sealed.capture(), eq(newExpiry));
@@ -173,69 +170,109 @@ class PsnAccountServiceTest extends AbstractUnitTest {
}
@Test
void startImport_ExpiredLink_IsRelink_WithoutAPsnCall() {
void fetchTitles_ExpiredLink_IsRelink_WithTheExpiredNote_AndNoPsnCall() {
linked(Instant.now().minusSeconds(1));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Relink.class);
verify(repository).noteExpiry(1L, "PSN_EXPIRED");
verifyNoInteractions(authService, apiService);
}
@Test
void startImport_RefreshRefused_ExpiresTheLink_AndIsRelink() {
void fetchTitles_LinkEndsInOneDay_WritesTheExpiringNote_AndStillFetches() {
linked(Instant.now().plus(Duration.ofDays(1)));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Titles.class);
verify(repository).noteExpiry(1L, "PSN_EXPIRING");
verify(repository, never()).noteExpiry(1L, "PSN_EXPIRED");
}
@Test
void fetchTitles_LinkEndsInThreeDays_WritesNoNote() {
linked(Instant.now().plus(Duration.ofDays(3)));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
service.fetchTitles(1L);
verify(repository, never()).noteExpiry(anyLong(), any());
}
@Test
void fetchTitles_RefreshRefused_ExpiresTheLink_WritesTheExpiredNote_AndIsRelink() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Relink.class);
verify(repository).expireNow(1L);
verify(repository).noteExpiry(1L, "PSN_EXPIRED");
verify(apiService, never()).getPlayedTitles(any());
}
@Test
void startImport_TokenSealedWithAnotherKey_IsRelink(CapturedOutput output) {
void fetchTitles_TokenSealedWithAnotherKey_IsRelink_WithTheExpiredNote(CapturedOutput output) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L,
SecretBox.fromBase64Key(Base64.getEncoder().encodeToString(new byte[32])).seal(OLD_REFRESH), EXPIRES,
Instant.now())));
Instant.now(), null)));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Relink.class);
verify(repository).expireNow(1L);
verify(repository).noteExpiry(1L, "PSN_EXPIRED");
assertNoSecrets(output);
}
@Test
void startImport_NotLinked() {
void fetchTitles_NotLinked() {
when(repository.find(1L)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.NotLinked.class);
}
@Test
void startImport_PsnServerError_IsUnavailable_AndLogsNoSecret(CapturedOutput output) {
void fetchTitles_PsnServerError_IsUnavailable_KeepsTheLink_AndLogsNoSecret(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenThrow(HttpServerErrorException.create(
HttpStatus.BAD_GATEWAY, "Bad", null, (ACCESS + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Unavailable.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Unavailable.class);
verify(importService, never()).createLines(anyLong(), any(), any());
verify(repository, never()).expireNow(anyLong());
verify(repository, never()).noteExpiry(anyLong(), any());
assertNoSecrets(output);
}
@Test
void startImport_MapsTheImportResults() {
void fetchTitles_RateLimiterRefusal_IsUnavailable_KeepsTheLink() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game",
Duration.ZERO, null)));
when(importService.createLines(eq(1L), any(), any()))
.thenReturn(new GameImportService.CreateResult.AlreadyMatching())
.thenReturn(new GameImportService.CreateResult.SearchUnavailable());
when(authService.refresh(OLD_REFRESH)).thenThrow(RequestNotPermitted.createRequestNotPermitted(
io.github.resilience4j.ratelimiter.RateLimiter.ofDefaults("psnApi")));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.AlreadyMatching.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.SearchUnavailable.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Unavailable.class);
verify(repository, never()).expireNow(anyLong());
}
@Test
void get_CarriesTheReviewImportOnlyForAReadyPsnImport() {
linked(EXPIRES);
when(importRepository.findOpenPsnImport(1L)).thenReturn(Optional.of(psnImport(5L, GameImport.Status.READY)))
.thenReturn(Optional.of(psnImport(6L, GameImport.Status.MATCHING))).thenReturn(Optional.empty());
assertThat(service.get(1L).reviewImportId()).isEqualTo(5L);
assertThat(service.get(1L).reviewImportId()).isNull();
assertThat(service.get(1L).reviewImportId()).isNull();
}
private static GameImport psnImport(long id, GameImport.Status status) {
Instant now = Instant.now();
return new GameImport(id, 1L, GameImport.Source.PSN, status, 2, 2, 0, 0, 0, false, 0, null, now, now, null);
}
@Test
@@ -253,8 +290,8 @@ class PsnAccountServiceTest extends AbstractUnitTest {
entered.await();
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Busy.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Busy.class);
assertThat(service.startImport(2L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Busy.class);
assertThat(service.fetchTitles(2L)).isInstanceOf(PsnAccountService.FetchResult.NotLinked.class);
release.countDown();
assertThat(first.get()).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
@@ -0,0 +1,317 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
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 PsnSyncStepTest extends AbstractUnitTest {
private static final long USER = 1L;
private static final Instant LAST_PLAYED = Instant.parse("2026-10-05T18:30:00Z");
private static final LocalDate DAY = LocalDate.of(2026, 10, 7);
@Mock
private PsnAccountService accountService;
@Mock
private PsnTitleRepository psnTitleRepository;
@Mock
private GameImportRepository importRepository;
@Mock
private GameMergeRepository mergeRepository;
@Mock
private ManualGameService manualGameService;
@Mock
private UserGameRepository userGameRepository;
@Mock
private PlaytimeDayRepository playtimeDayRepository;
@Mock
private PlatformTransactionManager transactionManager;
@InjectMocks
private PsnSyncStep step;
private static PsnApiService.Title ghost() {
return new PsnApiService.Title("PPSA1_00", "Ghost of Yotei", "ps5_native_game", Duration.ofMinutes(120), 7L,
LAST_PLAYED);
}
private static IgdbGame igdb(long id, String name, String... platforms) {
return new IgdbGame(id, name, 2025, null, List.of(platforms), null);
}
private void fetches(PsnApiService.Title... titles) {
when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Titles(List.of(titles)));
}
private static GameImport psnImport(long id, GameImport.Status status) {
Instant now = Instant.now();
return new GameImport(id, USER, GameImport.Source.PSN, status, 1, 1, 0, 0, 0, false, 0, null, now, now, null);
}
@Test
void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).containsExactly(SyncChange.added(-1L));
assertThat(prepared.baseline()).isTrue();
verify(psnTitleRepository).link(USER, "c7", -1L);
verify(userGameRepository).setLastPlayedAt(USER, -1L, LAST_PLAYED);
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_ExactMatchAlreadyInTheLibrary_LinksTheOwnedKey_WithoutAChange() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), anyString(), any(), anyInt(), any(), any()))
.thenReturn(new ManualGameService.AddResult.AlreadyInLibrary());
when(manualGameService.ownedKey(USER, game)).thenReturn(Optional.of(-5L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
verify(psnTitleRepository).linkOwned(USER, "c7", -5L);
verify(userGameRepository, never()).setLastPlayedAt(anyLong(), anyLong(), any());
}
@Test
void run_FirstCandidateIsOwnedUnderAnotherSpelling_LinksItAndAddsNothing() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei: Legends", "PS5");
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of(game)));
when(manualGameService.ownedKey(USER, game)).thenReturn(Optional.of(-5L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
verify(psnTitleRepository).linkOwned(USER, "c7", -5L);
verify(manualGameService, never()).addGame(anyLong(), any(), anyString(), any(), anyInt(), any(), any());
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_NameMatchOnAnotherPlatform_IsNotExact_AndWaitsInANewReview() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PC");
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of(game)));
when(importRepository.findOpenPsnImport(USER)).thenReturn(Optional.empty());
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
ArgumentCaptor<List<GameImport.Row>> rows = ArgumentCaptor.captor();
verify(importRepository).createReady(eq(USER), rows.capture());
assertThat(rows.getValue()).hasSize(1);
assertThat(rows.getValue().getFirst().line().psnKey()).isEqualTo("c7");
assertThat(rows.getValue().getFirst().line().title()).isEqualTo("Ghost of Yotei");
assertThat(rows.getValue().getFirst().candidates()).containsExactly(game);
verify(manualGameService, never()).addGame(anyLong(), any(), anyString(), any(), anyInt(), any(), any());
verify(psnTitleRepository, never()).link(anyLong(), anyString(), anyLong());
}
@Test
void run_NoCandidates_WaitsInAReadyReviewThatIsAppendedTo() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of()));
when(importRepository.findOpenPsnImport(USER)).thenReturn(Optional.of(psnImport(9L, GameImport.Status.READY)));
step.run(USER);
ArgumentCaptor<List<GameImport.Row>> rows = ArgumentCaptor.captor();
verify(importRepository).appendReadyRows(eq(9L), rows.capture());
assertThat(rows.getValue().getFirst().candidates()).isEmpty();
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_AnImportStillMatchingOrSaving_GetsNoRows_TheTitleIsNewAgainNextSync() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of()));
when(importRepository.findOpenPsnImport(USER)).thenReturn(Optional.of(psnImport(9L, GameImport.Status.SAVING)));
step.run(USER);
verify(importRepository, never()).appendReadyRows(anyLong(), any());
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_LinkedTitle_GivesItsPlaytimeAndLastPlayed_WithoutAnIgdbSearch() {
fetches(ghost());
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of("c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L)));
when(mergeRepository.findTargets(List.of(-1L))).thenReturn(Map.of(-1L, -2L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-2L, 120, LAST_PLAYED));
assertThat(prepared.baseline()).isFalse();
verifyNoInteractions(manualGameService);
}
@Test
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
fetches(ghost(), other);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of("c7", new PsnTitle("c7", PsnTitle.State.DISMISSED, null)));
when(importRepository.findOpenPsnKeys(USER)).thenReturn(Set.of("c8"));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
assertThat(prepared.playtimes()).isEmpty();
verifyNoInteractions(manualGameService);
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_IgdbDoesNotAnswer_LeavesTheTitleNew() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.empty());
step.run(USER);
verify(manualGameService, never()).addGame(anyLong(), any(), anyString(), any(), anyInt(), any(), any());
verify(importRepository, never()).createReady(anyLong(), any());
verify(psnTitleRepository, never()).link(anyLong(), anyString(), anyLong());
}
@Test
void run_PsnNotAvailable_IsNoneAndTouchesNothing() {
when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Unavailable())
.thenReturn(new PsnAccountService.FetchResult.Relink()).thenReturn(new PsnAccountService.FetchResult.NotLinked())
.thenReturn(new PsnAccountService.FetchResult.Off()).thenReturn(new PsnAccountService.FetchResult.Busy());
for (int i = 0; i < 5; i++) {
assertThat(step.run(USER)).isSameAs(PsnSyncStep.Prepared.NONE);
}
verifyNoInteractions(psnTitleRepository, importRepository, manualGameService);
}
@Test
void run_ARepositoryThrows_ReturnsWhatWasDone_AndNeverThrows() {
fetches(ghost());
when(psnTitleRepository.findAll(USER)).thenThrow(new IllegalStateException("database down"));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
assertThat(prepared.playtimes()).isEmpty();
}
private UserGame psnRow(long appid, int minutes) {
UserGame game = UserGame.manual(USER, appid, GameSource.PSN, "PS5", GameStatus.PLAYING, minutes, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, appid))).thenReturn(Optional.of(game));
return game;
}
private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) {
return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), false);
}
@Test
void apply_GrowthIsBooked_AsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() {
UserGame game = psnRow(-1L, 30);
when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13))))
.thenReturn(List.of(new AppidMinutes(-1L, 75)));
Instant now = Instant.now();
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED)), now, DAY);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, DAY, 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
assertThat(game.getPlaytime2Weeks()).isEqualTo(75);
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
assertThat(game.getLastSeenAt()).isEqualTo(now);
}
@Test
void apply_LowerTotal_IsStored_WithoutChangeOrDayMinutes_AndAMissingLastPlayedKeepsTheOld() {
UserGame game = psnRow(-1L, 90);
game.setLastPlayedAt(LAST_PLAYED);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null)), Instant.now(), DAY);
assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
assertThat(game.getPlaytimeMinutes()).isEqualTo(60);
assertThat(game.getPlaytime2Weeks()).isZero();
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
}
@Test
void apply_SteamRowRemovedRowAndMissingRow_AreUntouched() {
UserGame steam = UserGame.manual(USER, -1L, GameSource.STEAM, null, GameStatus.PLAYING, 30, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -1L))).thenReturn(Optional.of(steam));
UserGame removed = psnRow(-2L, 30);
removed.setRemovedAt(Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty());
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED),
new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED)),
Instant.now(), DAY);
assertThat(changes).containsExactly(SyncChange.added(-9L));
assertThat(steam.getPlaytimeMinutes()).isEqualTo(30);
assertThat(removed.getPlaytimeMinutes()).isEqualTo(30);
verifyNoInteractions(playtimeDayRepository);
}
@Test
void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() {
UserGame game = psnRow(-1L, 600);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null),
new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY);
assertThat(game.getPlaytimeMinutes()).isEqualTo(600);
assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
}
@@ -10,7 +10,9 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,7 +20,12 @@ import static org.assertj.core.api.Assertions.assertThat;
class PsnTitlesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, String duration, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept);
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept, null);
}
private static PsnApiService.Title played(String id, Long concept, String lastPlayed) {
return new PsnApiService.Title(id, id, "ps4_game", Duration.ZERO, concept,
lastPlayed == null ? null : Instant.parse(lastPlayed));
}
@ParameterizedTest
@@ -59,8 +66,27 @@ class PsnTitlesTest extends AbstractUnitTest {
assertThat(lines).hasSize(2);
assertThat(lines.get(0)).isEqualTo(new GameImport.Line(1, "Hades", "PS5", 65 * 60 + 9 + 120,
GameStatus.PLAYING, null));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null));
GameStatus.PLAYING, null, "c1"));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null, "c2"));
}
@Test
void toLines_KeepsThePsnKeyOfTheGroup() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("A_00", "A", "ps4_game", "PT1H", 5L), title("B_00", "B", "ps4_game", "PT1H", null)));
assertThat(lines).extracting(GameImport.Line::psnKey).containsExactly("c5", "tB_00");
}
@Test
void lastPlayedByKey_TakesTheNewestOfAConcept_AndSkipsTitlesWithoutOne() {
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(List.of(
played("A_00", 1L, "2026-10-01T10:00:00Z"), played("A_01", 1L, "2026-10-03T10:00:00Z"),
played("A_02", 1L, null), played("B_00", null, null), played("C_00", null, "2026-09-01T00:00:00Z")));
assertThat(lastPlayed).containsOnly(
Map.entry("c1", Instant.parse("2026-10-03T10:00:00Z")),
Map.entry("tC_00", Instant.parse("2026-09-01T00:00:00Z")));
}
@Test
@@ -10,6 +10,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -41,7 +42,8 @@ class PsnApiServiceTest extends AbstractUnitTest {
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"titleId":"PPSA1_00","name":"Ghost","category":"ps5_native_game",
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},"extra":1}],
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},
"lastPlayedDateTime":"2026-10-05T18:30:00.123Z","extra":1}],
"totalItemCount":2,"nextOffset":200}""", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=200")))
.andRespond(withSuccess("""
@@ -54,6 +56,8 @@ class PsnApiServiceTest extends AbstractUnitTest {
assertThat(titles.get(0).playDuration()).isEqualTo(Duration.parse("PT65H9M15S"));
assertThat(titles.get(0).conceptId()).isEqualTo(10L);
assertThat(titles.get(1).conceptId()).isNull();
assertThat(titles.get(0).lastPlayedAt()).isEqualTo(Instant.parse("2026-10-05T18:30:00.123Z"));
assertThat(titles.get(1).lastPlayedAt()).isNull();
server.verify();
}
@@ -71,6 +75,16 @@ class PsnApiServiceTest extends AbstractUnitTest {
assertThat(titles.getFirst().playDuration()).isZero();
}
@Test
void getPlayedTitles_UnreadableLastPlayed_IsNull() {
server.expect(requestTo(containsString("/titles")))
.andRespond(withSuccess("""
{"titles":[{"titleId":"A_00","name":"A","lastPlayedDateTime":"yesterday"}],"nextOffset":null}""",
MediaType.APPLICATION_JSON));
assertThat(service.getPlayedTitles("t").getFirst().lastPlayedAt()).isNull();
}
@Test
void getPlayedTitles_ServerErrorPropagates() {
server.expect(requestTo(containsString("/titles"))).andRespond(withServerError());
@@ -245,4 +245,56 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.isActive(9L)).isFalse();
assertThat(repository.activeUserIds()).contains(USER).doesNotContain(8L, 9L);
}
private void psnGame(String status) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source, platform) "
+ "values (?, ?, ?, 600, 'PSN', 'PS5')", USER, GAME, status);
}
@Test
void psnRow_GivesNoStartFinishCompletedOrPlaytimeFacts() {
psnGame("BACKLOG");
setStatus("PLAYING");
changedAt("PLAYING", "2026-09-10T10:00:00Z");
playedOn("2026-09-12");
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED', ?, 10, now())",
USER, String.valueOf(GAME));
setStatus("COMPLETED");
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty();
assertThat(repository.minutesPerMonth(USER)).isEmpty();
}
@Test
void psnDaysOfARowSteamTookOver_GiveNoPlaytimeFacts() {
psnGame("PLAYING");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
playedOn("2026-09-12");
jdbc.update("update user_game set source = 'STEAM', platform = null where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.minutesPerMonth(USER)).isEmpty();
}
@Test
void psnDaysOfADeletedRow_GiveNoPlaytimeFacts() {
psnGame("PLAYING");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
playedOn("2026-09-12");
jdbc.update("delete from user_game where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.minutesPerMonth(USER)).isEmpty();
}
@Test
void steamRowAPsnTitleIsLinkedTo_KeepsItsSteamFacts() {
game("PLAYING", 600);
playedOn("2026-09-12");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', ?)", USER, GAME);
assertThat(repository.minutesPerMonth(USER)).containsOnly(java.util.Map.entry(YearMonth.of(2026, 9), 30L));
}
}