Public Access
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:
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user