feat(psn): sync PlayStation trophies as achievements
Build, Test & Push Core Service / quality-check (push) Successful in 1m30s
Build, Test & Push Core Service / docker (push) Successful in 1m49s

Sets map by exact title id, else by name and platform.
Trophy gains count as achievements; platinum or all earned is perfect.
Max 30 PSN calls per run; trophy lists load only when changed.
Hidden trophies stay hidden (name and detail) until earned.
This commit is contained in:
lobo committed 2026-10-08 09:18:15 +02:00
1 parent f5ca7237a9
commit 0ddb345a41
54 files changed
+2937 -68

No files matched your search

@@ -2,7 +2,10 @@ package core_service.configuration;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTitleTrophySetsResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTrophiesResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTrophyTitlesResponse;
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
@@ -52,6 +55,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("psn-token", PsnTokenResponse.class),
Map.entry("psn-gamelist", PsnGameListResponse.class),
Map.entry("psn-trophy-titles", PsnTrophyTitlesResponse.class),
Map.entry("psn-trophy-title-sets", PsnTitleTrophySetsResponse.class),
Map.entry("psn-trophies-", PsnTrophiesResponse.class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired
@@ -58,6 +58,12 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (?, '\\x01'::bytea, now())", userId);
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1_00', 'trophy', 'PS4', 'Game', 1, 0, 0, 0) "
+ "on conflict do nothing");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1_00', 220, 1, false)", userId);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id) values (?, 'NPWR1_00', 1)", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
}
}
@@ -1,11 +1,13 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -16,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
@@ -59,11 +62,24 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
@Autowired
private LibrarySyncWorker librarySyncWorker;
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set");
}
private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) {
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet(setId, "trophy2", "01.00", "Set " + setId, null,
platform, total, 0, 0, 0, earned, platinum, Instant.now()));
}
/**
@@ -130,6 +146,43 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
}
@Test
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
game(10L, null, null, true);
game(20L, 10, 4, true);
trophies(10L, "NPWR1_00", "PS5", 50, 30, true);
trophies(10L, "NPWR2_00", "PS4", 5, 2, false);
trophies(20L, "NPWR3_00", "PS4", 10, 6, false);
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.unlocked").value(42))
.andExpect(jsonPath("$.startedGames").value(2))
.andExpect(jsonPath("$.perfectGames").value(1))
.andExpect(jsonPath("$.averageCompletion").value(80.0))
.andExpect(jsonPath("$.closest", hasSize(1)))
.andExpect(jsonPath("$.closest[0].appid").value(20))
.andExpect(jsonPath("$.closest[0].unlocked").value(6))
.andExpect(jsonPath("$.closest[0].total").value(10));
}
@Test
void games_TheTileShowsTheRealTrophyCount_AndFlagsAPlatinumSetAsPerfect() throws Exception {
game(10L, null, null, true);
game(20L, 10, 4, true);
trophies(10L, "NPWR1_00", "PS5", 50, 30, true);
trophies(20L, "NPWR3_00", "PS4", 10, 2, false);
mockMvc.perform(get("/api/library/games").param("sort", "appid,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].achievementsUnlocked").value(30))
.andExpect(jsonPath("$.content[0].achievementsTotal").value(50))
.andExpect(jsonPath("$.content[0].achievementsPerfect").value(true))
.andExpect(jsonPath("$.content[1].achievementsUnlocked").value(4))
.andExpect(jsonPath("$.content[1].achievementsTotal").value(10))
.andExpect(jsonPath("$.content[1].achievementsPerfect").value(false));
}
@Test
void games_CarryTheAchievementProgress() throws Exception {
game(20L, 4, 3, true);
@@ -2,12 +2,14 @@ package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.PsnTrophySet;
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.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -17,6 +19,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
@@ -77,12 +80,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private JdbcTemplate jdbc;
private void platinum(long appid) {
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
50, 0, 0, 0, 30, true, Instant.now()));
}
@BeforeEach
void seed() {
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set");
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
@@ -148,6 +164,31 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.averageCompletion").value(62.5));
}
@Test
void publicLibrary_APlatinumSetIsPerfect_WhenTheSummaryIsShown() throws Exception {
platinum(220L);
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.perfectGames").value(1))
.andExpect(jsonPath("$.averageCompletion").value(100.0));
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("PLAYING"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.perfectGames").value(nullValue()))
.andExpect(jsonPath("$.averageCompletion").value(nullValue()));
}
@Test
void statGames_APlatinumGameIsInThePerfectList() throws Exception {
platinum(220L);
statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(220));
}
@Test
void publicLibrary_TicketWithoutStatGroups_HasNoStats() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
@@ -0,0 +1,49 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AchievementProgressTest extends AbstractUnitTest {
private static AchievementProgress set(long appid, int total, int unlocked, boolean platinum) {
return new AchievementProgress(appid, total, unlocked, true, platinum);
}
@Test
void platinum_MakesASetPerfectAndFullPercent_EvenWhenTrophiesAreMissing() {
AchievementProgress platinum = set(1L, 50, 30, true);
assertThat(platinum.perfect()).isTrue();
assertThat(platinum.percent()).isEqualTo(100.0);
assertThat(set(1L, 50, 30, false).perfect()).isFalse();
assertThat(set(1L, 50, 30, false).percent()).isEqualTo(60.0);
assertThat(set(1L, 50, 50, false).perfect()).isTrue();
}
@Test
void steamConstructor_HasNoPlatinum() {
assertThat(new AchievementProgress(1L, 10, 10, true).platinum()).isFalse();
}
@Test
void perfectAppids_ListsAGameOnce_WhenTwoSetsArePerfect() {
List<AchievementProgress> sets = List.of(set(1L, 10, 10, false), set(1L, 50, 30, true), set(2L, 10, 3, false));
assertThat(AchievementProgress.perfectAppids(sets)).containsExactly(1L);
}
@Test
void perGame_PicksAPerfectSet_ElseTheBestPercent_OnePerGameByAppid() {
AchievementProgress perfect = set(2L, 50, 30, true);
AchievementProgress best = set(1L, 10, 8, false);
List<AchievementProgress> games = AchievementProgress.perGame(List.of(set(2L, 10, 9, false), perfect,
set(1L, 10, 4, false), best));
assertThat(games).containsExactly(best, perfect);
}
}
@@ -0,0 +1,276 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.PsnTrophySetState;
import core_service.modules.library.domain.TrophyType;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(PsnTrophyRepository.class)
class PsnTrophyRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 5L;
private static final Instant UPDATED = Instant.parse("2026-10-01T20:00:00Z");
@Autowired
private PsnTrophyRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
jdbc.update("delete from psn_trophy_set");
jdbc.update("delete from psn_title_trophy_set");
}
private void game(long userId, long appid) {
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', 'PSN') "
+ "on conflict do nothing", userId, appid);
}
private static PsnTrophySet set(String id, String platform, String name, int earned, boolean platinum, String version) {
return new PsnTrophySet(id, "trophy2", version, name, "https://x/i.png", platform, 3, 1, 1, 1, earned, platinum,
UPDATED);
}
private static PsnTrophy trophy(String setId, int id, TrophyType type, String group, boolean hidden) {
return new PsnTrophy(setId, id, type, "Trophy " + id, "Detail " + id, "https://x/" + id + ".png", hidden, group,
null, false, null);
}
@Test
void saveSet_UpsertsTheSummary_AndKeepsTheListVersionAndTheListMarker() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of());
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost renamed", 4, true, "01.10"));
assertThat(repository.findStates(USER, List.of("NPWR1_00"))).containsOnly(Map.entry("NPWR1_00",
new PsnTrophySetState("NPWR1_00", "01.00", 4, UPDATED)));
assertThat(repository.findSets(USER, -1L)).singleElement().satisfies(found -> {
assertThat(found.name()).isEqualTo("Ghost renamed");
assertThat(found.platinumEarned()).isTrue();
assertThat(found.total()).isEqualTo(6);
assertThat(found.listed()).isTrue();
});
}
@Test
void findStates_ASetOfAnotherUserHasNoEarnedTotal_AndANeverStoredSetIsMissing() {
game(9L, -1L);
repository.saveSet(9L, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00"));
Map<String, PsnTrophySetState> states = repository.findStates(USER, List.of("NPWR1_00", "NPWR2_00"));
assertThat(states).containsOnly(Map.entry("NPWR1_00", new PsnTrophySetState("NPWR1_00", null, null, null)));
assertThat(repository.findStates(USER, List.of())).isEmpty();
}
@Test
void replaceTrophies_ReplacesTheList_KeepsKnownRates_AndDropsRemovedTrophies() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 2, TrophyType.GOLD, "default", false)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, 12.5),
new PsnEarned(2, false, null, 3.0)));
repository.replaceTrophies("NPWR1_00", "01.10", List.of(trophy("NPWR1_00", 1, TrophyType.SILVER, "001", true)));
List<PsnTrophy> trophies = repository.findTrophies(USER, -1L);
assertThat(trophies).singleElement().satisfies(found -> {
assertThat(found.trophyId()).isEqualTo(1);
assertThat(found.type()).isEqualTo(TrophyType.SILVER);
assertThat(found.groupId()).isEqualTo("001");
assertThat(found.hidden()).isTrue();
assertThat(found.earnedRate()).isEqualTo(12.5);
assertThat(found.earned()).isTrue();
assertThat(found.earnedAt()).isEqualTo(UPDATED);
});
assertThat(repository.findStates(USER, List.of("NPWR1_00")).get("NPWR1_00").listVersion()).isEqualTo("01.10");
}
@Test
void replaceEarned_ReplacesTheUsersRows_StoresRatesAndTheMarker() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 2, TrophyType.GOLD, "default", false)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, 12.5)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED.plusSeconds(60),
List.of(new PsnEarned(1, false, null, 20.0), new PsnEarned(2, true, null, 4.0)));
assertThat(repository.findTrophies(USER, -1L)).extracting(PsnTrophy::trophyId, PsnTrophy::earned,
PsnTrophy::earnedRate).containsExactly(org.assertj.core.groups.Tuple.tuple(1, false, 20.0),
org.assertj.core.groups.Tuple.tuple(2, true, 4.0));
assertThat(repository.findStates(USER, List.of("NPWR1_00")).get("NPWR1_00").listUpdatedAt())
.isEqualTo(UPDATED.plusSeconds(60));
}
@Test
void findProgress_HasThePlatinumFlag_AndLeavesOutHiddenAndRemovedGames() {
game(USER, -1L);
game(USER, -2L);
game(USER, -3L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, true, "01.00"));
repository.saveSet(USER, -2L, set("NPWR2_00", "PS4", "Hidden game", 1, false, "01.00"));
repository.saveSet(USER, -3L, set("NPWR3_00", "PS4", "Removed game", 1, false, "01.00"));
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = -2", USER);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = -3", USER);
assertThat(repository.findProgress(USER)).containsExactly(new AchievementProgress(-1L, 6, 2, true, true));
}
@Test
void findSets_OrdersPs5BeforePs4_ThenByName_AndShowsWhetherTheListIsStored() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS4", "B Game", 1, false, "01.00"));
repository.saveSet(USER, -1L, set("NPWR2_00", "PS5", "Z Game", 1, false, "01.00"));
repository.saveSet(USER, -1L, set("NPWR3_00", "PS4", "A Game", 1, false, "01.00"));
repository.replaceTrophies("NPWR3_00", "01.00", List.of());
assertThat(repository.findSets(USER, -1L)).containsExactly(
new PsnTrophySetProgress("NPWR2_00", "PS5", "Z Game", 6, 1, false, false),
new PsnTrophySetProgress("NPWR3_00", "PS4", "A Game", 6, 1, false, false),
new PsnTrophySetProgress("NPWR1_00", "PS4", "B Game", 6, 1, false, false));
}
@Test
void findSets_NotListedWhileTheUsersEarnedListIsMissing() {
game(9L, -1L);
game(USER, -1L);
repository.saveSet(9L, -1L, set("NPWR1_00", "PS5", "Ghost", 0, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 2, TrophyType.SILVER, "default", false)));
// This user's first sync stored the summary (2 earned), then the budget ran out before the earned list call.
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00"));
assertThat(repository.findTrophies(USER, -1L)).noneMatch(PsnTrophy::earned);
assertThat(repository.findSets(USER, -1L)).singleElement()
.satisfies(found -> assertThat(found.listed()).isFalse());
}
@Test
void findSets_ListedWhenTheUserHasNothingEarned_OrWhenBothListsAreStored() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Zero", 0, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of());
repository.saveSet(USER, -1L, set("NPWR2_00", "PS4", "Some", 2, false, "01.00"));
repository.replaceTrophies("NPWR2_00", "01.00", List.of());
repository.replaceEarned(USER, "NPWR2_00", UPDATED, List.of());
assertThat(repository.findSets(USER, -1L)).extracting(PsnTrophySetProgress::listed).containsExactly(true, true);
}
@Test
void deleteUserSetsExcept_KeepsTheListedSets_AndOtherUsersRows() {
game(USER, -1L);
game(6L, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Keep", 1, false, "01.00"));
repository.saveSet(USER, -1L, set("NPWR2_00", "PS4", "Old account", 1, false, "01.00"));
repository.saveSet(6L, -1L, set("NPWR2_00", "PS4", "Old account", 1, false, "01.00"));
repository.replaceEarned(USER, "NPWR2_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.deleteUserSetsExcept(USER, List.of("NPWR1_00"));
repository.deleteUserSetsExcept(USER, List.of());
assertThat(repository.findSets(USER, -1L)).extracting(PsnTrophySetProgress::id).containsExactly("NPWR1_00");
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero();
assertThat(repository.findSets(6L, -1L)).hasSize(1);
}
@Test
void deleteUser_RemovesEverySetAndTrophyOfTheUser_ForThatUserOnly() {
game(USER, -1L);
game(6L, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.saveSet(6L, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.deleteUser(USER);
assertThat(repository.findSets(USER, -1L)).isEmpty();
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero();
assertThat(repository.findSets(6L, -1L)).hasSize(1);
}
@Test
void findTrophies_OrdersBySetGroupAndId_AndSeesOnlyTheAskedUsersGame() {
game(USER, -1L);
game(USER, -2L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 0, false, "01.00"));
repository.saveSet(USER, -2L, set("NPWR2_00", "PS4", "Other", 0, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 2, TrophyType.BRONZE, "001", false),
trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 3, TrophyType.BRONZE, "default", false)));
repository.replaceTrophies("NPWR2_00", "01.00", List.of(trophy("NPWR2_00", 1, TrophyType.GOLD, "default", false)));
assertThat(repository.findTrophies(USER, -1L)).extracting(PsnTrophy::trophyId).containsExactly(1, 3, 2);
assertThat(repository.findTrophies(6L, -1L)).isEmpty();
}
@Test
void findTitleSets_RecheckWindow_AFoundSetStays_ANoSetAnswerExpires() {
repository.saveTitleSets(List.of("PPSA1_00", "PPSA2_00"), Map.of("PPSA1_00", "NPWR1_00"));
jdbc.update("update psn_title_trophy_set set checked_at = now() - interval '40 days'");
repository.saveTitleSets(List.of("PPSA3_00"), Map.of());
Map<String, String> fresh = repository.findTitleSets(List.of("PPSA1_00", "PPSA2_00", "PPSA3_00", "PPSA4_00"),
Instant.now().minusSeconds(86_400));
Map<String, String> expected = new HashMap<>();
expected.put("PPSA1_00", "NPWR1_00");
expected.put("PPSA3_00", null);
assertThat(fresh).isEqualTo(expected);
}
@Test
void saveTitleSets_AskedAgain_OverwritesTheAnswer() {
repository.saveTitleSets(List.of("PPSA1_00"), Map.of());
repository.saveTitleSets(List.of("PPSA1_00"), Map.of("PPSA1_00", "NPWR1_00"));
assertThat(repository.findTitleSets(List.of("PPSA1_00"), Instant.EPOCH)).containsEntry("PPSA1_00", "NPWR1_00");
}
@Test
void deleteUserSets_RemovesBothUserTables_ForThatUserOnly() {
game(USER, -1L);
game(6L, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.saveSet(6L, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.replaceEarned(6L, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.deleteUserSets(USER, List.of("NPWR1_00"));
repository.deleteUserSets(USER, List.of());
assertThat(repository.findSets(USER, -1L)).isEmpty();
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero();
assertThat(repository.findSets(6L, -1L)).hasSize(1);
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, 6L)).isEqualTo(1);
}
}
@@ -1,6 +1,13 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.TrophyType;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
@@ -24,6 +31,7 @@ import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.ResourceAccessException;
import java.time.Duration;
@@ -75,6 +83,12 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
@@ -85,6 +99,10 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
jdbc.update("delete from psn_trophy_set");
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
}
@@ -215,4 +233,63 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
assertEquals(1, detail.achievementsUnlocked());
assertTrue(detail.statsVisible());
}
private void trophySet(String id, String platform, String name, boolean platinumEarned) {
psnTrophyRepository.saveSet(USER_ID, 620L, new PsnTrophySet(id, "trophy2", "01.00", name, null, platform, 2, 0, 0,
1, 2, platinumEarned, Instant.now()));
psnTrophyRepository.replaceTrophies(id, "01.00", List.of(
new PsnTrophy(id, 0, TrophyType.PLATINUM, "Platinum", "All", "https://x/0.png", false, "default", null,
false, null),
new PsnTrophy(id, 1, TrophyType.BRONZE, "Secret name", "Secret detail", "https://x/1.png", true,
"default", null, false, null),
new PsnTrophy(id, 2, TrophyType.BRONZE, "DLC trophy", "Dlc", "https://x/2.png", false, "001", null,
false, null)));
psnTrophyRepository.replaceEarned(USER_ID, id, Instant.now(), List.of(new PsnEarned(0, true, Instant.now(), 2.5),
new PsnEarned(1, false, null, 1.0), new PsnEarned(2, true, Instant.now(), 12.0)));
}
@Test
void getGameDetail_PsnOnlyGame_ShowsTheSetsPs5First_AndHidesWhatAHiddenTrophyWouldTell() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.PSN, "PS5", GameStatus.PLAYING, 60, Instant.now()));
trophySet("NPWR2_00", "PS4", "Old version", false);
trophySet("NPWR1_00", "PS5", "Ghost", true);
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertTrue(detail.hasAchievements());
assertEquals(0, detail.achievementsTotal());
assertEquals(List.of("NPWR1_00", "NPWR2_00"), detail.achievementSets().stream().map(set -> set.id()).toList());
assertTrue(detail.achievementSets().getFirst().perfect());
assertEquals(AchievementKind.PSN, detail.achievementSets().getFirst().kind());
AchievementDto hidden = detail.achievements().stream()
.filter(a -> "NPWR1_00".equals(a.setId()) && a.apiName().equals("1")).findFirst().orElseThrow();
assertEquals(null, hidden.displayName());
assertEquals(null, hidden.description());
assertTrue(hidden.hidden());
AchievementDto dlc = detail.achievements().stream()
.filter(a -> "NPWR1_00".equals(a.setId()) && a.apiName().equals("2")).findFirst().orElseThrow();
assertEquals("001", dlc.group());
assertEquals(12.0, dlc.rarity());
assertTrue(dlc.unlocked());
assertEquals("Platinum", detail.achievements().getFirst().displayName());
}
@Test
void getGameDetail_SteamStatsHidden_StillListsTheTrophySets_AndTheSteamSetIsNotVisible() {
UserGame hiddenGame = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
hiddenGame.setHiddenAt(Instant.now());
userGameRepository.save(hiddenGame);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now()));
schemaRepository.save(new GameAchievementSchema(620L, "A", "A", null, "icon", "gray", false));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, 60));
trophySet("NPWR1_00", "PS5", "Ghost", false);
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertEquals(List.of("steam", "NPWR1_00"), detail.achievementSets().stream().map(set -> set.id()).toList());
assertEquals(false, detail.achievementSets().getFirst().visible());
assertEquals(AchievementKind.STEAM, detail.achievements().getFirst().kind());
assertEquals(4, detail.achievements().size());
assertEquals(1, detail.achievementsTotal());
}
}
@@ -3,6 +3,7 @@ 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.PsnTrophyRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
@@ -53,13 +54,15 @@ class PsnAccountServiceTest extends AbstractUnitTest {
private PsnApiService apiService;
@Mock
private GameImportRepository importRepository;
@Mock
private PsnTrophyRepository trophyRepository;
private final SecretBox box = SecretBox.fromBase64Key(KEY);
private PsnAccountService service;
@BeforeEach
void setUp() {
service = new PsnAccountService(repository, authService, apiService, importRepository, KEY, 2);
service = new PsnAccountService(repository, authService, apiService, importRepository, trophyRepository, KEY, 2);
}
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
@@ -113,7 +116,7 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Test
void link_WithoutAKey_IsOff_AndCallsNothing() {
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importRepository, "", 2);
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importRepository, trophyRepository, "", 2);
assertThat(off.isEnabled()).isFalse();
assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class);
@@ -125,7 +128,7 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Test
void constructor_RejectsAKeyOfTheWrongLength() {
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
new PsnAccountService(repository, authService, apiService, importRepository, "c2hvcnQ=", 2))
new PsnAccountService(repository, authService, apiService, importRepository, trophyRepository, "c2hvcnQ=", 2))
.isInstanceOf(IllegalArgumentException.class);
}
@@ -137,7 +140,10 @@ class PsnAccountServiceTest extends AbstractUnitTest {
Duration.ofMinutes(90), 7L, null));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(played);
assertThat(service.fetchTitles(1L)).isEqualTo(new PsnAccountService.FetchResult.Titles(played));
PsnAccountService.FetchResult result = service.fetchTitles(1L);
assertThat(result).isEqualTo(new PsnAccountService.FetchResult.Titles(played, ACCESS));
assertThat(result.toString()).doesNotContain(ACCESS);
verify(repository, never()).noteExpiry(anyLong(), any());
assertNoSecrets(output);
@@ -306,4 +312,11 @@ class PsnAccountServiceTest extends AbstractUnitTest {
verify(repository).delete(1L);
}
@Test
void unlink_AlsoDeletesTheTrophiesOfTheUser_SoAnotherAccountStartsFromNothing() {
service.unlink(1L);
verify(trophyRepository).deleteUser(1L);
}
}
@@ -51,6 +51,8 @@ class PsnSyncStepTest extends AbstractUnitTest {
@Mock
private PsnAccountService accountService;
@Mock
private PsnTrophyStep trophyStep;
@Mock
private PsnTitleRepository psnTitleRepository;
@Mock
private GameImportRepository importRepository;
@@ -78,7 +80,7 @@ class PsnSyncStepTest extends AbstractUnitTest {
}
private void fetches(PsnApiService.Title... titles) {
when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Titles(List.of(titles)));
when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Titles(List.of(titles), "ACCESS"));
}
private static GameImport psnImport(long id, GameImport.Status status) {
@@ -230,7 +232,18 @@ class PsnSyncStepTest extends AbstractUnitTest {
assertThat(step.run(USER)).isSameAs(PsnSyncStep.Prepared.NONE);
}
verifyNoInteractions(psnTitleRepository, importRepository, manualGameService);
verifyNoInteractions(psnTitleRepository, importRepository, manualGameService, trophyStep);
}
@Test
void run_TrophyStepGetsTheTokenAndTheTitles_AndItsChangesAreInThePreparedChanges() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.empty());
when(trophyStep.run(USER, "ACCESS", List.of(ghost()))).thenReturn(List.of(SyncChange.achievements(-4L, 3)));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).containsExactly(SyncChange.achievements(-4L, 3));
}
@Test
@@ -0,0 +1,163 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.TrophyType;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class PsnTrophiesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.ZERO, concept, null);
}
private static PsnApiService.TrophySet set(String id, String name, String platform) {
return new PsnApiService.TrophySet(id, "trophy", "01.00", name, "https://x/i.png", platform,
new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(0, 0, 0, 0), null, false);
}
private static PsnTitle linked(String key, long appid) {
return new PsnTitle(key, PsnTitle.State.LINKED, appid);
}
@Test
void setAppids_TitleIdMapsExactly_EvenWhenTheNamesDiffer() {
PsnApiService.Title ghost = title("PPSA1_00", "Ghost of Yotei", "ps5_native_game", 7L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(ghost), Map.of("c7", linked("c7", -1L)),
Map.of("PPSA1_00", "NPWR1_00"), List.of(set("NPWR1_00", "Totally Other Name", "PS5")));
assertThat(appids).containsOnly(Map.entry("NPWR1_00", -1L));
}
@Test
void setAppids_ASteamOwnedTitleCountsForTheOwnedGame() {
PsnApiService.Title ghost = title("PPSA1_00", "Ghost", "ps5_native_game", 7L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(ghost),
Map.of("c7", new PsnTitle("c7", PsnTitle.State.STEAM_OWNED, 620L)), Map.of("PPSA1_00", "NPWR1_00"),
List.of(set("NPWR1_00", "Ghost", "PS5")));
assertThat(appids).containsOnly(Map.entry("NPWR1_00", 620L));
}
@Test
void setAppids_NoAnswerForTheTitleId_FallsBackToCleanNameAndPlatform() {
PsnApiService.Title mega = title("CUSA1_00", "Mega Man Legacy Collection™", "ps4_game", 8L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(),
List.of(set("NPWR2_00", "Mega Man Legacy Collection", "PS4")));
assertThat(appids).containsOnly(Map.entry("NPWR2_00", -2L));
}
@Test
void setAppids_NameMatchOnTheWrongPlatform_IsNotMapped() {
PsnApiService.Title mega = title("CUSA1_00", "Mega Man", "ps4_game", 8L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(),
List.of(set("NPWR2_00", "Mega Man", "PS5")));
assertThat(appids).isEmpty();
}
@Test
void setAppids_ThePlatformListOfASetIsSplit() {
PsnApiService.Title mega = title("CUSA1_00", "Mega Man", "ps4_game", 8L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(),
List.of(set("NPWR2_00", "Mega Man", "PS4,PSVITA")));
assertThat(appids).containsOnlyKeys("NPWR2_00");
}
@Test
void setAppids_SameNameOnTwoGames_IsUnmapped_ButOneGameWithTwoTitlesMaps() {
PsnApiService.Title a = title("CUSA1_00", "Doom", "ps4_game", 1L);
PsnApiService.Title b = title("CUSA2_00", "Doom", "ps4_game", 2L);
List<PsnApiService.TrophySet> sets = List.of(set("NPWR1_00", "Doom", "PS4"));
assertThat(PsnTrophies.setAppids(List.of(a, b), Map.of("c1", linked("c1", -1L), "c2", linked("c2", -2L)),
Map.of(), sets)).isEmpty();
assertThat(PsnTrophies.setAppids(List.of(a, b), Map.of("c1", linked("c1", -1L), "c2", linked("c2", -1L)),
Map.of(), sets)).containsOnly(Map.entry("NPWR1_00", -1L));
}
@Test
void setAppids_TwoTitleIdsOfOneSetOnTwoGames_LeaveTheSetOut() {
PsnApiService.Title a = title("CUSA1_00", "A", "ps4_game", 1L);
PsnApiService.Title b = title("CUSA2_00", "B", "ps4_game", 2L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(a, b),
Map.of("c1", linked("c1", -1L), "c2", linked("c2", -2L)),
Map.of("CUSA1_00", "NPWR1_00", "CUSA2_00", "NPWR1_00"), List.of(set("NPWR1_00", "Other", "PS4")));
assertThat(appids).isEmpty();
}
@Test
void setAppids_DismissedAndUnknownTitles_MapNothing() {
PsnApiService.Title ghost = title("PPSA1_00", "Ghost", "ps5_native_game", 7L);
List<PsnApiService.TrophySet> sets = List.of(set("NPWR1_00", "Ghost", "PS5"));
Map<String, String> byId = Map.of("PPSA1_00", "NPWR1_00");
assertThat(PsnTrophies.setAppids(List.of(ghost), Map.of("c7", new PsnTitle("c7", PsnTitle.State.DISMISSED, null)),
byId, sets)).isEmpty();
assertThat(PsnTrophies.setAppids(List.of(ghost), Map.of(), byId, sets)).isEmpty();
}
@Test
void inScope_Ps4AndPs5AreIn_Ps3OnlyAndHiddenAreOut() {
assertThat(PsnTrophies.inScope(set("N", "A", "PS5"))).isTrue();
assertThat(PsnTrophies.inScope(set("N", "A", "PS4,PSVITA"))).isTrue();
assertThat(PsnTrophies.inScope(set("N", "A", "PS3"))).isFalse();
assertThat(PsnTrophies.inScope(set("N", "A", "PSVITA"))).isFalse();
assertThat(PsnTrophies.inScope(new PsnApiService.TrophySet("N", "trophy", "1", "A", null, "PS4",
new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(0, 0, 0, 0), null, true))).isFalse();
}
@Test
void toSet_TakesTheCountsAndPlatinum_AndNormalizesThePlatform() {
PsnApiService.TrophySet source = new PsnApiService.TrophySet("N", "trophy", "01.00", "A", "http://x/i.png",
"PS4,PSVITA", new PsnApiService.Counts(3, 2, 1, 1), new PsnApiService.Counts(2, 1, 0, 1),
Instant.EPOCH, false);
assertThat(PsnTrophies.toSet(source)).satisfies(set -> {
assertThat(set.platform()).isEqualTo("PS4");
assertThat(set.earnedTotal()).isEqualTo(4);
assertThat(set.platinumEarned()).isTrue();
assertThat(List.of(set.definedBronze(), set.definedSilver(), set.definedGold(), set.definedPlatinum()))
.containsExactly(3, 2, 1, 1);
assertThat(set.iconUrl()).isNull();
});
}
@Test
void toTrophy_HttpIconAndTooLongUrlAreNull_TextIsCut_UnknownTypeIsEmpty() {
PsnApiService.Trophy http = new PsnApiService.Trophy(1, "GOLD", "n".repeat(600), "d".repeat(3000),
"http://x/i.png", false, "default");
PsnApiService.Trophy longUrl = new PsnApiService.Trophy(2, "bronze", "n", "d", "https://x/" + "a".repeat(1100),
false, "default");
PsnApiService.Trophy ok = new PsnApiService.Trophy(3, "silver", "n", "d", "HTTPS://x/i.png", true, "001");
PsnTrophy mapped = PsnTrophies.toTrophy("N", http).orElseThrow();
assertThat(mapped.type()).isEqualTo(TrophyType.GOLD);
assertThat(mapped.iconUrl()).isNull();
assertThat(mapped.name()).hasSize(512);
assertThat(mapped.detail()).hasSize(2048);
assertThat(PsnTrophies.toTrophy("N", longUrl).orElseThrow().iconUrl()).isNull();
assertThat(PsnTrophies.toTrophy("N", ok).orElseThrow().iconUrl()).isEqualTo("HTTPS://x/i.png");
assertThat(PsnTrophies.toTrophy("N", new PsnApiService.Trophy(4, "legendary", "n", "d", null, false, "default")))
.isEqualTo(Optional.empty());
}
}
@@ -0,0 +1,386 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.PsnTrophySetState;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
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;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.http.HttpStatus;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(OutputCaptureExtension.class)
class PsnTrophyStepTest extends AbstractUnitTest {
private static final long USER = 1L;
private static final String TOKEN = "ACCESS-SECRET";
private static final Instant UPDATED = Instant.parse("2026-10-01T20:00:00Z");
@Mock
private PsnApiService apiService;
@Mock
private PsnTrophyRepository repository;
@Mock
private PsnTitleRepository titleRepository;
@Mock
private GameMergeRepository mergeRepository;
@Mock
private PlatformTransactionManager transactionManager;
private PsnTrophyStep step;
private final Map<String, PsnTitle> links = new HashMap<>();
@BeforeEach
void setUp() {
step = new PsnTrophyStep(apiService, repository, titleRepository, mergeRepository, transactionManager, 30);
lenient().when(titleRepository.findAll(USER)).thenReturn(links);
}
private static PsnApiService.Title title(int n) {
return new PsnApiService.Title("PPSA" + n + "_00", "Game " + n, "ps5_native_game", Duration.ZERO, (long) n, null);
}
private static PsnApiService.TrophySet set(int n, int earnedBronze, Instant updated) {
return new PsnApiService.TrophySet("NPWR" + n + "_00", "trophy2", "01.00", "Game " + n, "https://x/i.png", "PS5",
new PsnApiService.Counts(5, 0, 0, 0), new PsnApiService.Counts(earnedBronze, 0, 0, 0), updated, false);
}
// Title n is linked to appid -n and all its title ids are known to have no set: the name mapping applies.
private List<PsnApiService.Title> titles(int count) {
List<PsnApiService.Title> titles = new ArrayList<>();
Map<String, String> known = new HashMap<>();
for (int n = 1; n <= count; n++) {
titles.add(title(n));
links.put("c" + n, new PsnTitle("c" + n, PsnTitle.State.LINKED, (long) -n));
known.put("PPSA" + n + "_00", null);
}
when(repository.findTitleSets(anyCollection(), any())).thenReturn(known);
return titles;
}
private static PsnApiService.TrophySets complete(List<PsnApiService.TrophySet> sets) {
return new PsnApiService.TrophySets(sets, true);
}
private void state(int n, String listVersion, Integer earned, Instant listUpdatedAt) {
Map<String, PsnTrophySetState> states = new HashMap<>();
states.put("NPWR" + n + "_00", new PsnTrophySetState("NPWR" + n + "_00", listVersion, earned, listUpdatedAt));
when(repository.findStates(eq(USER), anyCollection())).thenReturn(states);
}
@Test
void run_UnchangedSet_FetchesNoListAtAll_ButSavesTheSummary() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED))));
state(1, "01.00", 2, UPDATED);
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).isEmpty();
verify(apiService, never()).getTrophies(anyString(), anyString(), anyString());
verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString());
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
verify(repository, never()).replaceTrophies(anyString(), anyString(), anyList());
verify(repository, never()).replaceEarned(anyLong(), anyString(), any(), anyList());
}
@Test
void run_MoreEarnedTrophies_ReportsTheGain_AndFetchesTheEarnedListOnly() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 5, UPDATED.plusSeconds(60)))));
state(1, "01.00", 2, UPDATED);
when(apiService.getEarnedTrophies(TOKEN, "NPWR1_00", "trophy2"))
.thenReturn(List.of(new PsnApiService.EarnedTrophy(1, true, UPDATED, 10.0)));
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).containsExactly(SyncChange.achievements(-1L, 3));
verify(apiService, never()).getTrophies(anyString(), anyString(), anyString());
verify(repository).replaceEarned(USER, "NPWR1_00", UPDATED.plusSeconds(60),
List.of(new PsnEarned(1, true, UPDATED, 10.0)));
}
@Test
void run_FirstSightOfASet_IsABaseline_AndFetchesBothLists() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 3, UPDATED))));
when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2"))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of(
new PsnApiService.Trophy(1, "bronze", "n", "d", "https://x/1.png", false, "default"),
new PsnApiService.Trophy(2, "weird", "n", "d", null, false, "default"))));
when(apiService.getEarnedTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(List.of());
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).isEmpty();
ArgumentCaptor<List<PsnTrophy>> trophies = ArgumentCaptor.captor();
verify(repository).replaceTrophies(eq("NPWR1_00"), eq("01.00"), trophies.capture());
assertThat(trophies.getValue()).extracting(PsnTrophy::trophyId).containsExactly(1);
verify(repository).replaceEarned(USER, "NPWR1_00", UPDATED, List.of());
}
@Test
void run_StoredSetWithoutUserRow_IsABaselineToo() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 3, UPDATED))));
state(1, "01.00", null, null);
assertThat(step.run(USER, TOKEN, titles)).isEmpty();
}
@Test
void run_ANewVersion_FetchesTheGlobalListAgain() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED))));
state(1, "00.90", 2, UPDATED);
when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(apiService).getTrophies(TOKEN, "NPWR1_00", "trophy2");
verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString());
verify(repository).replaceTrophies("NPWR1_00", "01.00", List.of());
}
@Test
void run_SetWithNothingEarned_NeedsNoEarnedCall() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString());
}
@Test
void run_FortySetsWithABudgetOf30_MakesAtMost30ListCalls_ButSavesEverySummary() {
List<PsnApiService.Title> titles = titles(40);
List<PsnApiService.TrophySet> sets = new ArrayList<>();
for (int n = 1; n <= 40; n++) {
sets.add(set(n, 1, UPDATED.plusSeconds(n)));
}
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(sets));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
when(apiService.getEarnedTrophies(anyString(), anyString(), anyString())).thenReturn(List.of());
step.run(USER, TOKEN, titles);
verify(apiService, times(15)).getTrophies(anyString(), anyString(), anyString());
verify(apiService, times(15)).getEarnedTrophies(anyString(), anyString(), anyString());
verify(repository, times(40)).saveSet(eq(USER), anyLong(), any(PsnTrophySet.class));
// newest first: set 40 is served, set 1 is not
verify(apiService).getTrophies(TOKEN, "NPWR40_00", "trophy2");
verify(apiService, never()).getTrophies(TOKEN, "NPWR1_00", "trophy2");
}
@Test
void run_ThreeErrorsInARow_StopTheListCalls_ButSummariesAreSaved() {
List<PsnApiService.Title> titles = titles(6);
List<PsnApiService.TrophySet> sets = new ArrayList<>();
for (int n = 1; n <= 6; n++) {
sets.add(set(n, 0, UPDATED));
}
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(sets));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenThrow(new HttpServerErrorException(HttpStatus.BAD_GATEWAY));
step.run(USER, TOKEN, titles);
verify(apiService, times(PsnTrophyStep.MAX_ERRORS_IN_A_ROW)).getTrophies(anyString(), anyString(), anyString());
verify(repository, times(6)).saveSet(eq(USER), anyLong(), any(PsnTrophySet.class));
verify(repository, never()).replaceTrophies(anyString(), anyString(), anyList());
}
@Test
void run_ARateLimitedCall_CountsAsAnError_AndDoesNotThrow() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenThrow(RequestNotPermitted.createRequestNotPermitted(
io.github.resilience4j.ratelimiter.RateLimiter.ofDefaults("t")));
assertThat(step.run(USER, TOKEN, titles)).isEmpty();
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
}
@Test
void run_SetNowMappedToAnotherGame_IsSavedWithTheNewAppid() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED))));
state(1, "01.00", 2, UPDATED);
when(mergeRepository.findTargets(anyCollection())).thenReturn(Map.of(-1L, -77L));
step.run(USER, TOKEN, titles);
verify(repository).saveSet(eq(USER), eq(-77L), any(PsnTrophySet.class));
}
@Test
void run_TrophySetsFail_ReturnsNoChanges_AndWritesNothing(CapturedOutput output) {
when(apiService.getTrophySets(TOKEN)).thenThrow(new HttpServerErrorException(HttpStatus.BAD_GATEWAY, "body " + TOKEN));
assertThat(step.run(USER, TOKEN, List.of(title(1)))).isEmpty();
verifyNoInteractions(repository, mergeRepository);
assertThat(output.getAll()).doesNotContain(TOKEN).contains("status 502");
}
@Test
void run_TitleIdsAlreadyKnown_AreNotAskedAgain_UnknownOnesAre() {
List<PsnApiService.Title> titles = new ArrayList<>(List.of(title(1), title(2)));
links.put("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L));
links.put("c2", new PsnTitle("c2", PsnTitle.State.LINKED, -2L));
Map<String, String> known = new HashMap<>();
known.put("PPSA1_00", "NPWR1_00");
when(repository.findTitleSets(anyCollection(), any())).thenReturn(known);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null), set(2, 0, null))));
when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of("PPSA2_00", "NPWR2_00"),
List.of("PPSA2_00"), 1, null));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(apiService).getTrophySetIds(TOKEN, List.of("PPSA2_00"));
verify(repository).saveTitleSets(List.of("PPSA2_00"), Map.of("PPSA2_00", "NPWR2_00"));
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
verify(repository).saveSet(eq(USER), eq(-2L), any(PsnTrophySet.class));
}
@Test
void run_TheTitleIdEndpointGivesNothing_TheNameFallbackMapsTheSet_AndTheLogSaysWhichPath(CapturedOutput output) {
List<PsnApiService.Title> titles = List.of(title(1));
links.put("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L));
when(repository.findTitleSets(anyCollection(), any())).thenReturn(new HashMap<>());
when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of(),
List.of("PPSA1_00"), 1, null));
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(repository, atLeastOnce()).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
assertThat(output.getAll()).contains("mapped by title id=0 by name=1").doesNotContain(TOKEN);
}
@Test
void run_AFailedTitleIdCall_MarksNoTitleAsNoSet_AndTakesBudgetOnlyForTheCallsMade() {
step = new PsnTrophyStep(apiService, repository, titleRepository, mergeRepository, transactionManager, 2);
List<PsnApiService.Title> titles = new ArrayList<>();
for (int n = 1; n <= 2; n++) {
titles.add(title(n));
links.put("c" + n, new PsnTitle("c" + n, PsnTitle.State.LINKED, (long) -n));
}
when(repository.findTitleSets(anyCollection(), any())).thenReturn(new HashMap<>());
when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of(),
List.of(), 1, new HttpServerErrorException(HttpStatus.TOO_MANY_REQUESTS)));
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, UPDATED), set(2, 0, null))));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(repository).saveTitleSets(List.of(), Map.of());
verify(apiService, times(1)).getTrophies(anyString(), anyString(), anyString());
}
@Test
void run_ACompleteSummary_RemovesTheSetsOfAnEarlierAccount_AndKeepsTheBaselineOfTheSameAccount() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED), set(7, 0, null))));
state(1, "01.00", 2, UPDATED);
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).isEmpty();
verify(repository).deleteUserSetsExcept(USER, List.of("NPWR1_00", "NPWR7_00"));
}
@Test
void run_ASummaryThatEndedOnABrokenPage_RemovesNothing_ButStillSavesWhatItHas() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(new PsnApiService.TrophySets(List.of(set(1, 2, UPDATED)), false));
state(1, "01.00", 2, UPDATED);
step.run(USER, TOKEN, titles);
verify(repository, never()).deleteUserSetsExcept(anyLong(), anyCollection());
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
}
@Test
void run_AnEmptyOrPartialSummary_RemovesNothing() {
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of()));
step.run(USER, TOKEN, List.of(title(1)));
List<PsnApiService.TrophySet> capped = new ArrayList<>();
for (int n = 0; n < PsnApiService.MAX_TITLES; n++) {
capped.add(set(n, 0, null));
}
when(apiService.getTrophySets(TOKEN)).thenReturn(new PsnApiService.TrophySets(capped, false));
step.run(USER, TOKEN, List.of(title(1)));
verify(repository, never()).deleteUserSetsExcept(anyLong(), anyCollection());
}
@Test
void run_HiddenSet_HasItsUserRowsDeleted_AndIsNotStored() {
List<PsnApiService.Title> titles = titles(1);
PsnApiService.TrophySet hidden = new PsnApiService.TrophySet("NPWR9_00", "trophy", "1", "Game 1", null, "PS4",
new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(1, 0, 0, 0), UPDATED, true);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(hidden)));
step.run(USER, TOKEN, titles);
verify(repository).deleteUserSets(USER, List.of("NPWR9_00"));
verify(repository, never()).saveSet(anyLong(), anyLong(), any());
}
@Test
void run_ARepositoryThrows_NeverThrowsAndReturnsTheChangesSoFar() {
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(titleRepository.findAll(USER)).thenThrow(new IllegalStateException("database down"));
assertThat(step.run(USER, TOKEN, List.of(title(1)))).isEmpty();
}
}
@@ -4,6 +4,7 @@ import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
@@ -12,6 +13,7 @@ import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -19,6 +21,7 @@ import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class PsnApiServiceTest extends AbstractUnitTest {
@@ -91,4 +94,156 @@ class PsnApiServiceTest extends AbstractUnitTest {
assertThatThrownBy(() -> service.getPlayedTitles("t")).isInstanceOf(RestClientException.class);
}
@Test
void getTrophySets_FollowsNextOffset_ReadsCountsHiddenFlagAndInstant_WithBearer() {
server.expect(requestTo(containsString("/trophy/v1/users/me/trophyTitles?")))
.andExpect(requestTo(containsString("limit=800")))
.andExpect(requestTo(containsString("offset=0")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophyTitles":[{"npServiceName":"trophy2","npCommunicationId":"NPWR1_00","trophySetVersion":"01.10",
"trophyTitleName":"Ghost","trophyTitleIconUrl":"https://x/i.png","trophyTitlePlatform":"PS5",
"hiddenFlag":false,"definedTrophies":{"bronze":3,"silver":2,"gold":1,"platinum":1},
"earnedTrophies":{"bronze":1,"silver":1,"gold":0,"platinum":0},
"lastUpdatedDateTime":"2026-10-01T20:41:07Z","extra":1},
{"npCommunicationId":"NPWR2_00"}],"totalItemCount":3,"nextOffset":800}""",
MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=800")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophyTitles":[{"npServiceName":"trophy","npCommunicationId":"NPWR3_00","trophyTitleName":"Mega",
"trophyTitlePlatform":"PS4,PSVITA","hiddenFlag":true,"definedTrophies":{"bronze":1,"silver":0,
"gold":0,"platinum":0}}],"nextOffset":null}""", MediaType.APPLICATION_JSON));
PsnApiService.TrophySets result = service.getTrophySets("ACCESS-1");
List<PsnApiService.TrophySet> sets = result.sets();
assertThat(sets).extracting(PsnApiService.TrophySet::npCommunicationId).containsExactly("NPWR1_00", "NPWR3_00");
PsnApiService.TrophySet ghost = sets.getFirst();
assertThat(ghost.defined().total()).isEqualTo(7);
assertThat(ghost.earned()).isEqualTo(new PsnApiService.Counts(1, 1, 0, 0));
assertThat(ghost.lastUpdatedAt()).isEqualTo(Instant.parse("2026-10-01T20:41:07Z"));
assertThat(ghost.hidden()).isFalse();
assertThat(sets.get(1).hidden()).isTrue();
assertThat(sets.get(1).earned().total()).isZero();
assertThat(sets.get(1).lastUpdatedAt()).isNull();
// totalItemCount says 3, one entry had no name: not complete
assertThat(result.complete()).isFalse();
server.verify();
}
@Test
void getTrophySets_CompleteOnlyWhenTheCountEqualsTheTotal_AClearedPageIsPartial() {
server.expect(requestTo(containsString("offset=0"))).andRespond(withSuccess("""
{"trophyTitles":[{"npCommunicationId":"NPWR1_00","trophyTitleName":"A"}],"totalItemCount":1,"nextOffset":null}""",
MediaType.APPLICATION_JSON));
assertThat(service.getTrophySets("t").complete()).isTrue();
server.reset();
server.expect(requestTo(containsString("offset=0"))).andRespond(withSuccess("""
{"trophyTitles":[{"npCommunicationId":"NPWR1_00","trophyTitleName":"A"}],"totalItemCount":3,"nextOffset":1}""",
MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=1"))).andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
PsnApiService.TrophySets broken = service.getTrophySets("t");
assertThat(broken.sets()).hasSize(1);
assertThat(broken.complete()).isFalse();
}
@Test
void getTrophySetIds_SixIds_AreAskedAsFiveAndOne_FirstSetPerTitleWins() {
server.expect(requestTo(containsString("/trophy/v1/users/me/titles/trophyTitles?")))
.andExpect(requestTo(containsString("npTitleIds=A_00,B_00,C_00,D_00,E_00")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"npTitleId":"A_00","trophyTitles":[{"npCommunicationId":"NPWR1_00"},
{"npCommunicationId":"NPWR9_00"}]},
{"npTitleId":"B_00","trophyTitles":[]}]}""", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("npTitleIds=F_00")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"npTitleId":"F_00","trophyTitles":[{"npCommunicationId":"NPWR2_00"}]}]}""",
MediaType.APPLICATION_JSON));
PsnApiService.TrophySetIds ids = service.getTrophySetIds("ACCESS-1",
List.of("A_00", "B_00", "C_00", "D_00", "E_00", "F_00"));
assertThat(ids.found()).containsOnly(Map.entry("A_00", "NPWR1_00"), Map.entry("F_00", "NPWR2_00"));
assertThat(ids.answered()).containsExactly("A_00", "B_00", "C_00", "D_00", "E_00", "F_00");
assertThat(ids.calls()).isEqualTo(2);
assertThat(ids.failure()).isNull();
server.verify();
}
@Test
void getTrophySetIds_AFailedCallEndsTheCalls_OnlyAnsweredTitlesAreAnswered_AndTheFailedCallCounts() {
server.expect(requestTo(containsString("npTitleIds=A_00,B_00,C_00,D_00,E_00")))
.andRespond(withSuccess("""
{"titles":[{"npTitleId":"A_00","trophyTitles":[{"npCommunicationId":"NPWR1_00"}]}]}""",
MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("npTitleIds=F_00,G_00,H_00,I_00,J_00")))
.andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS));
PsnApiService.TrophySetIds ids = service.getTrophySetIds("t", List.of("A_00", "B_00", "C_00", "D_00", "E_00",
"F_00", "G_00", "H_00", "I_00", "J_00", "K_00"));
assertThat(ids.found()).containsOnly(Map.entry("A_00", "NPWR1_00"));
assertThat(ids.answered()).containsExactly("A_00", "B_00", "C_00", "D_00", "E_00");
assertThat(ids.calls()).isEqualTo(2);
assertThat(ids.failure()).isInstanceOf(RestClientException.class);
server.verify();
}
@Test
void getTrophySetIds_ServerErrorIsReturnedAsFailure_NotThrown() {
server.expect(requestTo(containsString("npTitleIds="))).andRespond(withServerError());
PsnApiService.TrophySetIds ids = service.getTrophySetIds("t", List.of("A_00"));
assertThat(ids.answered()).isEmpty();
assertThat(ids.failure()).isInstanceOf(RestClientException.class);
}
@Test
void getTrophies_SendsTheServiceName_AndReadsVersionTypeGroupAndHiddenFlag() {
server.expect(requestTo(containsString("/trophy/v1/npCommunicationIds/NPWR1_00/trophyGroups/all/trophies?")))
.andExpect(requestTo(containsString("npServiceName=trophy2")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophySetVersion":"01.10","trophies":[
{"trophyId":0,"trophyHidden":false,"trophyType":"platinum","trophyName":"Legend",
"trophyDetail":"All","trophyIconUrl":"https://x/0.png","trophyGroupId":"default"},
{"trophyId":7,"trophyHidden":true,"trophyType":"bronze","trophyGroupId":"001"},
{"trophyId":8},{"trophyType":"gold"}]}""", MediaType.APPLICATION_JSON));
PsnApiService.TrophyList list = service.getTrophies("ACCESS-1", "NPWR1_00", "trophy2");
assertThat(list.version()).isEqualTo("01.10");
assertThat(list.trophies()).containsExactly(
new PsnApiService.Trophy(0, "platinum", "Legend", "All", "https://x/0.png", false, "default"),
new PsnApiService.Trophy(7, "bronze", null, null, null, true, "001"));
server.verify();
}
@Test
void getEarnedTrophies_SendsTheServiceName_ParsesTheRate_AndABadRateIsNull() {
server.expect(requestTo(containsString("/trophy/v1/users/me/npCommunicationIds/NPWR1_00/trophyGroups/all/trophies?")))
.andExpect(requestTo(containsString("npServiceName=trophy")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophies":[
{"trophyId":0,"earned":true,"earnedDateTime":"2026-09-20T18:00:00Z","trophyEarnedRate":"12.3"},
{"trophyId":1,"earned":false,"trophyEarnedRate":"x"},
{"trophyId":2,"earned":false,"trophyEarnedRate":"NaN"}]}""", MediaType.APPLICATION_JSON));
List<PsnApiService.EarnedTrophy> earned = service.getEarnedTrophies("ACCESS-1", "NPWR1_00", "trophy");
assertThat(earned).containsExactly(
new PsnApiService.EarnedTrophy(0, true, Instant.parse("2026-09-20T18:00:00Z"), 12.3),
new PsnApiService.EarnedTrophy(1, false, null, null),
new PsnApiService.EarnedTrophy(2, false, null, null));
server.verify();
}
}
@@ -110,6 +110,19 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
});
}
@Test
void finishCandidates_APlatinumTrophySetNeverCountsAsAllAchievements() {
game("BACKLOG", 120);
setStatus("COMPLETED");
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1_00', 'trophy', 'PS4', 'Game', 4, 0, 0, 1)");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1_00', ?, 5, true)", USER, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.allAchievements()).isFalse());
}
@Test
void finishCandidates_GoneOnceAFinishedRowExistsOrTheGameWentBackToBacklog() {
game("BACKLOG", 120);