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
@@ -22,7 +22,7 @@ public class AccountCleanupRepository {
|
||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
|
||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_account", "reward_ledger", "reward_equipped",
|
||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
|
||||
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
|
||||
|
||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.List;
|
||||
public record InboxDto(List<Note> results, int unread) {
|
||||
|
||||
/**
|
||||
* @param kind DIGEST or VISIBILITY
|
||||
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
|
||||
* @param displayName the name of the followed user when the note was written; never a user id
|
||||
*/
|
||||
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
|
||||
|
||||
@@ -2,5 +2,8 @@ package core_service.modules.inbox.domain;
|
||||
|
||||
public enum NoteKind {
|
||||
DIGEST,
|
||||
VISIBILITY
|
||||
VISIBILITY,
|
||||
/** About the reader's own PlayStation link; subject is the reader. */
|
||||
PSN_EXPIRING,
|
||||
PSN_EXPIRED
|
||||
}
|
||||
@@ -62,7 +62,8 @@ public class GameImportController {
|
||||
@PostMapping("/{id}/save")
|
||||
public ResponseEntity<?> save(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id,
|
||||
@Valid @RequestBody ImportSaveRequest request) {
|
||||
return switch (importService.save(principal.getIdAsLong(), id, request.rows())) {
|
||||
return switch (importService.save(principal.getIdAsLong(), id, request.rows(),
|
||||
request.shownLineNos())) {
|
||||
case DONE -> ResponseEntity.accepted().build();
|
||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
||||
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
|
||||
@@ -83,7 +83,6 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -118,15 +117,11 @@ public class LibraryController {
|
||||
@GetMapping("/games-count")
|
||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
boolean syncStarted = principal.steamIdText()
|
||||
.map(steamId -> syncService.syncIfStale(userId, steamId))
|
||||
.orElseGet(() -> {
|
||||
syncService.stopNightlySync(userId);
|
||||
return false;
|
||||
});
|
||||
String steamId = principal.steamIdText().orElse(null);
|
||||
boolean syncStarted = syncService.syncIfStale(userId, steamId);
|
||||
long count = queryService.countOwnedGames(userId);
|
||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
|
||||
"syncStarted", syncStarted));
|
||||
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted));
|
||||
}
|
||||
|
||||
@GetMapping("/games")
|
||||
@@ -142,7 +137,7 @@ public class LibraryController {
|
||||
@RequestParam(defaultValue = "false") boolean hideDuplicates
|
||||
) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
|
||||
|
||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||
int safePage = Math.max(0, page);
|
||||
@@ -164,13 +159,12 @@ public class LibraryController {
|
||||
|
||||
@PostMapping("/sync")
|
||||
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
Optional<String> steamId = principal.steamIdText();
|
||||
if (steamId.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
||||
}
|
||||
try {
|
||||
syncService.forceSync(principal.getIdAsLong(), steamId.get());
|
||||
syncService.forceSync(principal.getIdAsLong(), principal.steamIdText().orElse(null));
|
||||
return ResponseEntity.accepted().body(Map.of("status", "started"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID",
|
||||
"User has no Steam ID or PlayStation link"));
|
||||
} catch (SyncCooldownException e) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()))
|
||||
@@ -493,7 +487,7 @@ public class LibraryController {
|
||||
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
|
||||
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
||||
Long userId = principal.getIdAsLong();
|
||||
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
|
||||
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, category));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package core_service.modules.library.api;
|
||||
|
||||
import core_service.modules.library.api.dto.PsnAccountDto;
|
||||
import core_service.modules.library.api.dto.PsnLinkRequest;
|
||||
import core_service.modules.library.service.LibrarySyncService;
|
||||
import core_service.modules.library.service.PsnAccountService;
|
||||
import core_service.shared.api.ErrorResponse;
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
@@ -17,10 +18,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The user's linked PlayStation account and its import. No answer repeats the request.
|
||||
* The user's linked PlayStation account; linking starts a sync. No answer repeats the request.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/library/psn-account")
|
||||
@@ -35,12 +34,16 @@ public class PsnAccountController {
|
||||
"PlayStation did not answer; try again later");
|
||||
|
||||
private final PsnAccountService accountService;
|
||||
private final LibrarySyncService syncService;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> link(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody PsnLinkRequest request) {
|
||||
return switch (accountService.link(principal.getIdAsLong(), request.npsso())) {
|
||||
case PsnAccountService.LinkResult.Linked linked -> ResponseEntity.ok(dto(linked.status()));
|
||||
case PsnAccountService.LinkResult.Linked linked -> {
|
||||
syncService.startAfterLink(principal.getIdAsLong(), principal.steamIdText().orElse(null));
|
||||
yield ResponseEntity.ok(dto(linked.status()));
|
||||
}
|
||||
case PsnAccountService.LinkResult.Rejected ignored -> error(HttpStatus.BAD_REQUEST, "NPSSO_INVALID",
|
||||
"PlayStation did not accept the NPSSO; it may have expired");
|
||||
case PsnAccountService.LinkResult.Off ignored -> PSN_OFF;
|
||||
@@ -60,28 +63,9 @@ public class PsnAccountController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/imports")
|
||||
public ResponseEntity<?> startImport(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return switch (accountService.startImport(principal.getIdAsLong())) {
|
||||
case PsnAccountService.ImportResult.Started started -> ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||
.body(Map.of("id", started.id()));
|
||||
case PsnAccountService.ImportResult.NotLinked ignored -> error(HttpStatus.NOT_FOUND, "PSN_NOT_LINKED",
|
||||
"No PlayStation account is linked");
|
||||
case PsnAccountService.ImportResult.Relink ignored -> error(HttpStatus.CONFLICT, "PSN_RELINK",
|
||||
"The PlayStation link has expired; link again with a new NPSSO");
|
||||
case PsnAccountService.ImportResult.NoTitles ignored -> ResponseEntity.noContent().build();
|
||||
case PsnAccountService.ImportResult.Off ignored -> PSN_OFF;
|
||||
case PsnAccountService.ImportResult.Unavailable ignored -> PSN_ERROR;
|
||||
case PsnAccountService.ImportResult.Busy ignored -> PSN_BUSY;
|
||||
case PsnAccountService.ImportResult.AlreadyMatching ignored -> error(HttpStatus.CONFLICT, "IMPORT_RUNNING",
|
||||
"Another import is still being matched");
|
||||
case PsnAccountService.ImportResult.SearchUnavailable ignored -> error(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"IGDB_OFF", "Importing games needs the IGDB app");
|
||||
};
|
||||
}
|
||||
|
||||
private static PsnAccountDto dto(PsnAccountService.Status status) {
|
||||
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt());
|
||||
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt(),
|
||||
status.reviewImportId());
|
||||
}
|
||||
|
||||
private static ResponseEntity<?> error(HttpStatus status, String code, String message) {
|
||||
|
||||
@@ -16,6 +16,8 @@ public record GameDetailDto(
|
||||
int playtime2Weeks,
|
||||
Instant firstSeenAt,
|
||||
Instant lastSeenAt,
|
||||
// from PlayStation; null for Steam rows
|
||||
Instant lastPlayedAt,
|
||||
GameStatus status,
|
||||
Integer rating,
|
||||
String notes,
|
||||
|
||||
@@ -19,7 +19,7 @@ public record ImportRequest(
|
||||
|
||||
public static final int MAX_ROWS = 1000;
|
||||
|
||||
@AssertTrue(message = "PSN imports start at /api/library/psn-account/imports")
|
||||
@AssertTrue(message = "PSN imports come from the sync")
|
||||
public boolean isNotPsn() {
|
||||
return source != GameImport.Source.PSN;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* The lines the user checked in the review, each with the game picked.
|
||||
*
|
||||
* @param shownLineNos the lines the user saw; a PlayStation review keeps the others open. Null: all were shown.
|
||||
*/
|
||||
public record ImportSaveRequest(@NotNull @Size(max = ImportRequest.MAX_ROWS) List<@NotNull @Valid Row> rows) {
|
||||
public record ImportSaveRequest(
|
||||
@NotNull @Size(max = ImportRequest.MAX_ROWS) List<@NotNull @Valid Row> rows,
|
||||
@Size(max = ImportRequest.MAX_ROWS) List<@NotNull Integer> shownLineNos) {
|
||||
|
||||
/**
|
||||
* @param igdbId a candidate of the line, or a game from a new search
|
||||
|
||||
@@ -5,10 +5,12 @@ import java.time.Instant;
|
||||
/**
|
||||
* @param enabled false when Core has no APP_PSN_TOKEN_KEY: the PlayStation link is off
|
||||
* @param expiresAt when the link ends and a new NPSSO is needed; null when not linked
|
||||
* @param reviewImportId the PSN import waiting for the user's review, null when none
|
||||
*/
|
||||
public record PsnAccountDto(
|
||||
boolean enabled,
|
||||
boolean linked,
|
||||
Instant linkedAt,
|
||||
Instant expiresAt
|
||||
Instant expiresAt,
|
||||
Long reviewImportId
|
||||
) {}
|
||||
@@ -3,7 +3,8 @@ package core_service.modules.library.api.dto;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
|
||||
/**
|
||||
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
|
||||
* @param playtime2Weeks Steam's playtime of the last two weeks, or the PSN playtime history of the last 14 days, in
|
||||
* minutes
|
||||
* @param imageUrl see {@link GameDto#imageUrl()}
|
||||
*/
|
||||
public record RecentGameDto(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
public record AppidMinutes(long appid, long minutes) {}
|
||||
@@ -37,10 +37,11 @@ public record GameImport(long id, long userId, Source source, Status status, int
|
||||
/**
|
||||
* One input line; every field but lineNo may be missing.
|
||||
*
|
||||
* @param psnKey the PlayStation title the line came from, null for every other import
|
||||
* @param igdbId the game the user already picked in the search (source SEARCH); then title is not searched
|
||||
*/
|
||||
public record Line(int lineNo, String title, String platform, Integer playtimeMinutes, GameStatus status,
|
||||
Long igdbId) {}
|
||||
Long igdbId, String psnKey) {}
|
||||
|
||||
/**
|
||||
* A line after the matching.
|
||||
|
||||
@@ -9,8 +9,8 @@ public enum GameSource {
|
||||
STEAM,
|
||||
MANUAL,
|
||||
/**
|
||||
* Rows of a PlayStation import. In this slice the sync treats them like MANUAL: it takes them over on Steam and
|
||||
* never removes them.
|
||||
* Rows the PSN step of the sync adds or confirms through a review; the sync updates their playtime and last
|
||||
* played. The Steam sync still takes them over and never removes them.
|
||||
*/
|
||||
PSN
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import java.time.Instant;
|
||||
* A linked PlayStation account (V38). Only the sealed refresh token is stored, never the NPSSO.
|
||||
*
|
||||
* @param refreshExpiresAt when the user must paste a new NPSSO
|
||||
* @param expiryNote the inbox note written for this link: null, PSN_EXPIRING or PSN_EXPIRED
|
||||
*/
|
||||
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt) {
|
||||
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt,
|
||||
String expiryNote) {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation
|
||||
* made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review.
|
||||
*/
|
||||
public record PsnTitle(String psnKey, State state, Long appid) {
|
||||
|
||||
public enum State { LINKED, STEAM_OWNED, DISMISSED }
|
||||
}
|
||||
@@ -91,6 +91,10 @@ public class UserGame {
|
||||
@Column(name = "import_id")
|
||||
private Long importId;
|
||||
|
||||
/** From PlayStation; null for Steam rows. */
|
||||
@Column(name = "last_played_at")
|
||||
private Instant lastPlayedAt;
|
||||
|
||||
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
|
||||
this.userId = userId;
|
||||
this.appid = appid;
|
||||
|
||||
@@ -26,7 +26,8 @@ public class UserSteamSync {
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "steam_id", nullable = false, length = 32)
|
||||
/** Null for a user with a PlayStation link and no Steam account. */
|
||||
@Column(name = "steam_id", length = 32)
|
||||
private String steamId;
|
||||
|
||||
@Column(name = "last_synced_at")
|
||||
|
||||
@@ -16,10 +16,12 @@ import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Imports and their lines (V22), the queues of the import workers, and the games an import added.
|
||||
@@ -59,15 +61,116 @@ public class GameImportRepository {
|
||||
key, new String[]{"id"});
|
||||
long id = key.getKeyAs(Long.class);
|
||||
jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, "
|
||||
+ "igdb_id) values (:id, :line, :title, :platform, :minutes, :status, :igdb)",
|
||||
lines.stream().map(line -> new MapSqlParameterSource("id", id).addValue("line", line.lineNo())
|
||||
.addValue("title", line.title()).addValue("platform", line.platform())
|
||||
.addValue("minutes", line.playtimeMinutes())
|
||||
.addValue("status", line.status() != null ? line.status().name() : null)
|
||||
.addValue("igdb", line.igdbId())).toArray(MapSqlParameterSource[]::new));
|
||||
+ "igdb_id, psn_key) values (:id, :line, :title, :platform, :minutes, :status, :igdb, :psnKey)",
|
||||
lines.stream().map(line -> lineParams(id, line.lineNo(), line)).toArray(MapSqlParameterSource[]::new));
|
||||
return id;
|
||||
}
|
||||
|
||||
private static final String OPEN_PSN = "i.user_id = :user and i.source = 'PSN' "
|
||||
+ "and i.status in ('QUEUED', 'MATCHING', 'READY', 'SAVING')";
|
||||
|
||||
/**
|
||||
* The user's newest PSN import that is not finished: the sync adds review lines to a READY one and waits for
|
||||
* the others.
|
||||
*/
|
||||
public Optional<GameImport> findOpenPsnImport(long userId) {
|
||||
return jdbc.query(SELECT_IMPORT + "where " + OPEN_PSN + " order by i.id desc limit 1",
|
||||
new MapSqlParameterSource("user", userId), IMPORT).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* The PlayStation titles that wait in an open import.
|
||||
*/
|
||||
public Set<String> findOpenPsnKeys(long userId) {
|
||||
return new HashSet<>(jdbc.queryForList("select r.psn_key from game_import_row r join game_import i "
|
||||
+ "on i.id = r.import_id where " + OPEN_PSN + " and r.psn_key is not null",
|
||||
new MapSqlParameterSource("user", userId), String.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* An import the sync matched itself: READY, with the candidates of each row.
|
||||
*/
|
||||
public long createReady(long userId, List<GameImport.Row> rows) {
|
||||
GeneratedKeyHolder key = new GeneratedKeyHolder();
|
||||
jdbc.update("insert into game_import (user_id, source, status, total) values (:user, 'PSN', 'READY', :total)",
|
||||
new MapSqlParameterSource("user", userId).addValue("total", rows.size()), key, new String[]{"id"});
|
||||
long id = key.getKeyAs(Long.class);
|
||||
insertMatchedRows(id, 1, rows);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds matched rows after the last line of an import that is still READY.
|
||||
*
|
||||
* @return false when the user saved the import meanwhile; nothing is added then
|
||||
*/
|
||||
public boolean appendReadyRows(long importId, List<GameImport.Row> rows) {
|
||||
if (jdbc.update("update game_import set total = total + :n where id = :id and status = 'READY'",
|
||||
new MapSqlParameterSource("id", importId).addValue("n", rows.size())) == 0) {
|
||||
return false;
|
||||
}
|
||||
int start = jdbc.queryForObject(
|
||||
"select coalesce(max(line_no), 0) + 1 from game_import_row where import_id = :id",
|
||||
new MapSqlParameterSource("id", importId), Integer.class);
|
||||
insertMatchedRows(importId, start, rows);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the lines without a choice that are not in {@code shown} to a new READY import of the user, so a save
|
||||
* neither adds nor dismisses them.
|
||||
*/
|
||||
public void moveUnseenToNewReady(long userId, long importId, Set<Integer> shown) {
|
||||
List<Integer> unseen = jdbc.queryForList("select line_no from game_import_row where import_id = :id "
|
||||
+ "and chosen_igdb_id is null order by line_no", new MapSqlParameterSource("id", importId),
|
||||
Integer.class).stream().filter(lineNo -> !shown.contains(lineNo)).toList();
|
||||
if (unseen.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
GeneratedKeyHolder key = new GeneratedKeyHolder();
|
||||
jdbc.update("insert into game_import (user_id, source, status, total) values (:user, 'PSN', 'READY', :total)",
|
||||
new MapSqlParameterSource("user", userId).addValue("total", unseen.size()), key, new String[]{"id"});
|
||||
long target = key.getKeyAs(Long.class);
|
||||
for (int index = 0; index < unseen.size(); index++) {
|
||||
jdbc.update("update game_import_row set import_id = :target, line_no = :new "
|
||||
+ "where import_id = :id and line_no = :old",
|
||||
new MapSqlParameterSource("target", target).addValue("new", index + 1).addValue("id", importId)
|
||||
.addValue("old", unseen.get(index)));
|
||||
}
|
||||
jdbc.update("update game_import set total = total - :n where id = :id",
|
||||
new MapSqlParameterSource("id", importId).addValue("n", unseen.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The PlayStation titles of the import's lines the user did not check.
|
||||
*/
|
||||
public List<String> findPsnKeysWithoutChoice(long importId) {
|
||||
return jdbc.queryForList("select psn_key from game_import_row where import_id = :id and psn_key is not null "
|
||||
+ "and chosen_igdb_id is null", new MapSqlParameterSource("id", importId), String.class);
|
||||
}
|
||||
|
||||
private void insertMatchedRows(long importId, int firstLineNo, List<GameImport.Row> rows) {
|
||||
MapSqlParameterSource[] params = new MapSqlParameterSource[rows.size()];
|
||||
for (int index = 0; index < params.length; index++) {
|
||||
GameImport.Row row = rows.get(index);
|
||||
params[index] = lineParams(importId, firstLineNo + index, row.line())
|
||||
.addValue("failed", row.searchFailed()).addValue("exact", row.exact())
|
||||
.addValue("candidates", jsonMapper.writeValueAsString(row.candidates()));
|
||||
}
|
||||
jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, "
|
||||
+ "igdb_id, psn_key, matched_at, search_failed, exact, candidates) values (:id, :line, :title, "
|
||||
+ ":platform, :minutes, :status, :igdb, :psnKey, now(), :failed, :exact, cast(:candidates as jsonb))",
|
||||
params);
|
||||
}
|
||||
|
||||
private static MapSqlParameterSource lineParams(long importId, int lineNo, GameImport.Line line) {
|
||||
return new MapSqlParameterSource("id", importId).addValue("line", lineNo)
|
||||
.addValue("title", line.title()).addValue("platform", line.platform())
|
||||
.addValue("minutes", line.playtimeMinutes())
|
||||
.addValue("status", line.status() != null ? line.status().name() : null)
|
||||
.addValue("igdb", line.igdbId()).addValue("psnKey", line.psnKey());
|
||||
}
|
||||
|
||||
public Optional<GameImport> find(long userId, long id) {
|
||||
return jdbc.query(SELECT_IMPORT + "where i.id = :id and i.user_id = :user",
|
||||
new MapSqlParameterSource("id", id).addValue("user", userId), IMPORT).stream().findFirst();
|
||||
@@ -279,7 +382,7 @@ public class GameImportRepository {
|
||||
String status = rs.getString("status");
|
||||
return new GameImport.Line(rs.getInt("line_no"), rs.getString("title"), rs.getString("platform"),
|
||||
rs.getObject("playtime_minutes", Integer.class), status != null ? GameStatus.valueOf(status) : null,
|
||||
rs.getObject("igdb_id", Long.class));
|
||||
rs.getObject("igdb_id", Long.class), rs.getString("psn_key"));
|
||||
}
|
||||
|
||||
private static Instant instant(Timestamp timestamp) {
|
||||
|
||||
@@ -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.PlaytimeDay;
|
||||
import core_service.modules.library.domain.PlaytimeDayId;
|
||||
@@ -10,6 +11,7 @@ import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
|
||||
@@ -28,6 +30,11 @@ public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, Playti
|
||||
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
|
||||
@Param("day") LocalDate day, @Param("minutes") int minutes);
|
||||
|
||||
@Query("select new core_service.modules.library.domain.AppidMinutes(p.appid, sum(p.minutes)) from PlaytimeDay p " +
|
||||
"where p.userId = :userId and p.appid in :appids and p.day >= :from group by p.appid")
|
||||
List<AppidMinutes> minutesPerGameSince(@Param("userId") Long userId, @Param("appids") Collection<Long> appids,
|
||||
@Param("from") LocalDate from);
|
||||
|
||||
/**
|
||||
* Only days with playtime; the caller fills the gaps. A hidden game's days never count, also those from before
|
||||
* it was hidden.
|
||||
|
||||
@@ -17,10 +17,11 @@ public class PsnAccountRepository {
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public Optional<PsnAccount> find(long userId) {
|
||||
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at from user_psn_account "
|
||||
+ "where user_id = :user", new MapSqlParameterSource("user", userId),
|
||||
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at, expiry_note "
|
||||
+ "from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId),
|
||||
(rs, i) -> new PsnAccount(rs.getLong("user_id"), rs.getBytes("refresh_token_enc"),
|
||||
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant()))
|
||||
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant(),
|
||||
rs.getString("expiry_note")))
|
||||
.stream().findFirst();
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ public class PsnAccountRepository {
|
||||
jdbc.update("""
|
||||
insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (:user, :token, :expires)
|
||||
on conflict (user_id) do update set refresh_token_enc = :token, refresh_expires_at = :expires,
|
||||
linked_at = now()""", params(userId, refreshTokenEnc, refreshExpiresAt));
|
||||
linked_at = now(), expiry_note = null""", params(userId, refreshTokenEnc, refreshExpiresAt));
|
||||
}
|
||||
|
||||
public void updateToken(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt) {
|
||||
@@ -47,6 +48,24 @@ public class PsnAccountRepository {
|
||||
new MapSqlParameterSource("user", userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the inbox note of this link once: EXPIRING while none was written, EXPIRED also after EXPIRING. The
|
||||
* library module writes the inbox table by SQL, as the follow module does.
|
||||
*
|
||||
* @param kind the inbox NoteKind name PSN_EXPIRING or PSN_EXPIRED
|
||||
* @return 1 when a note was written
|
||||
*/
|
||||
public int noteExpiry(long userId, String kind) {
|
||||
return jdbc.update("""
|
||||
with marked as (
|
||||
update user_psn_account set expiry_note = :kind
|
||||
where user_id = :user and expiry_note is distinct from :kind
|
||||
and (:kind = 'PSN_EXPIRED' or expiry_note is null)
|
||||
returning user_id)
|
||||
insert into inbox_note (user_id, kind, subject_user_id) select user_id, :kind, user_id from marked""",
|
||||
new MapSqlParameterSource("user", userId).addValue("kind", kind));
|
||||
}
|
||||
|
||||
public int delete(long userId) {
|
||||
return jdbc.update("delete from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.PsnTitle;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* What the PlayStation sync has seen per user (V39).
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class PsnTitleRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public Map<String, PsnTitle> findAll(long userId) {
|
||||
Map<String, PsnTitle> titles = new LinkedHashMap<>();
|
||||
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user",
|
||||
new MapSqlParameterSource("user", userId), rs -> {
|
||||
titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"),
|
||||
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class)));
|
||||
});
|
||||
return titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Also over a DISMISSED row: the user checked the title in a later review.
|
||||
*/
|
||||
public void link(long userId, String psnKey, long appid) {
|
||||
jdbc.update("""
|
||||
insert into user_psn_title (user_id, psn_key, state, appid) values (:user, :key, 'LINKED', :appid)
|
||||
on conflict (user_id, psn_key) do update set state = 'LINKED', appid = excluded.appid""",
|
||||
new MapSqlParameterSource("user", userId).addValue("key", psnKey).addValue("appid", appid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a title to a game the user already owns: STEAM_OWNED when that row is a Steam row (Steam proof and
|
||||
* playtime stay Steam's), else LINKED. Also over a DISMISSED row.
|
||||
*/
|
||||
public void linkOwned(long userId, String psnKey, long appid) {
|
||||
jdbc.update("""
|
||||
insert into user_psn_title (user_id, psn_key, state, appid)
|
||||
values (:user, :key, case when exists (select 1 from user_game where user_id = :user and appid = :appid
|
||||
and source = 'STEAM') then 'STEAM_OWNED' else 'LINKED' end,
|
||||
:appid)
|
||||
on conflict (user_id, psn_key) do update set state = excluded.state, appid = excluded.appid""",
|
||||
new MapSqlParameterSource("user", userId).addValue("key", psnKey).addValue("appid", appid));
|
||||
}
|
||||
|
||||
/**
|
||||
* A LINKED title stays linked.
|
||||
*/
|
||||
public void dismiss(long userId, Collection<String> psnKeys) {
|
||||
if (psnKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbc.batchUpdate("insert into user_psn_title (user_id, psn_key, state) values (:user, :key, 'DISMISSED') "
|
||||
+ "on conflict (user_id, psn_key) do nothing",
|
||||
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
|
||||
.toArray(MapSqlParameterSource[]::new));
|
||||
}
|
||||
}
|
||||
@@ -412,6 +412,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
"order by ug.appid, ug.userId")
|
||||
List<FinishedWithoutDate> findFinishedWithoutDateCandidates();
|
||||
|
||||
// For a row addGame just created outside the sync transaction.
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("update UserGame ug set ug.lastPlayedAt = :at where ug.userId = :userId and ug.appid = :appid")
|
||||
int setLastPlayedAt(@Param("userId") Long userId, @Param("appid") Long appid, @Param("at") Instant at);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = "update user_game ug set finished_at_estimate = :finishedAt " +
|
||||
|
||||
@@ -54,9 +54,9 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
|
||||
/**
|
||||
* The gate of the sync on a visit: stamps login_sync_tried_at and returns 1 only when a sync is due, so of many
|
||||
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID, or no OK sync since
|
||||
* successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets only the Steam ID.
|
||||
* steam_id is not updated: the worker claim compares the old ID to detect an account change.
|
||||
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID (null counts as a value), or
|
||||
* no OK sync since successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets
|
||||
* only the Steam ID. steam_id is not updated: the worker claim compares the old ID to detect an account change.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@@ -70,7 +70,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
WHERE NOT (user_steam_sync.last_sync_status = 'RUNNING'
|
||||
AND user_steam_sync.sync_started_at IS NOT NULL
|
||||
AND user_steam_sync.sync_started_at >= :runningStaleBefore)
|
||||
AND (user_steam_sync.steam_id <> EXCLUDED.steam_id
|
||||
AND (user_steam_sync.steam_id IS DISTINCT FROM EXCLUDED.steam_id
|
||||
OR ((user_steam_sync.last_success_at IS NULL OR user_steam_sync.last_success_at < :successBefore)
|
||||
AND (user_steam_sync.login_sync_tried_at IS NULL
|
||||
OR user_steam_sync.login_sync_tried_at < :triedBefore)))
|
||||
|
||||
@@ -217,6 +217,7 @@ public class GameDetailService {
|
||||
userGame.getPlaytime2Weeks(),
|
||||
userGame.getFirstSeenAt(),
|
||||
userGame.getLastSeenAt(),
|
||||
userGame.getLastPlayedAt(),
|
||||
userGame.getStatus(),
|
||||
userGame.getRating(),
|
||||
userGame.getNotes(),
|
||||
|
||||
@@ -59,15 +59,8 @@ public class GameImportService {
|
||||
List<GameImport.Line> lines = IntStream.range(0, rows.size()).mapToObj(index -> {
|
||||
ImportRequest.Row row = rows.get(index);
|
||||
return new GameImport.Line(index + 1, strippedOrNull(row.title()), strippedOrNull(row.platform()),
|
||||
row.playtimeMinutes(), row.status(), row.igdbId());
|
||||
row.playtimeMinutes(), row.status(), row.igdbId(), null);
|
||||
}).toList();
|
||||
return createLines(userId, source, lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #create} for lines Core built itself, e.g. from a PlayStation account.
|
||||
*/
|
||||
public CreateResult createLines(Long userId, GameImport.Source source, List<GameImport.Line> lines) {
|
||||
if (!igdbApiService.isEnabled()) {
|
||||
return new CreateResult.SearchUnavailable();
|
||||
}
|
||||
@@ -91,9 +84,12 @@ public class GameImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per import: stores the review and puts the import in the saving queue.
|
||||
* Once per import: stores the review and puts the import in the saving queue. In a PlayStation review the
|
||||
* lines the user did not see (the sync added them after the review was loaded) move to a new open review.
|
||||
*
|
||||
* @param shownLineNos the lines the user saw; null: all
|
||||
*/
|
||||
public ChangeResult save(Long userId, long id, List<ImportSaveRequest.Row> chosen) {
|
||||
public ChangeResult save(Long userId, long id, List<ImportSaveRequest.Row> chosen, List<Integer> shownLineNos) {
|
||||
if (importRepository.find(userId, id).isEmpty()) {
|
||||
return ChangeResult.NOT_FOUND;
|
||||
}
|
||||
@@ -104,6 +100,9 @@ public class GameImportService {
|
||||
importRepository.saveChoices(id, chosen.stream().map(row -> new GameImport.Choice(row.lineNo(),
|
||||
row.igdbId(), row.platform(), row.status(),
|
||||
row.playtimeMinutes() != null ? row.playtimeMinutes() : 0)).toList());
|
||||
if (shownLineNos != null && importRepository.findSource(id) == GameImport.Source.PSN) {
|
||||
importRepository.moveUnseenToNewReady(userId, id, Set.copyOf(shownLineNos));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!queued) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameImport;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.IgdbGame;
|
||||
import core_service.modules.library.repo.GameImportRepository;
|
||||
import core_service.modules.library.repo.PsnTitleRepository;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -51,6 +52,7 @@ public class GameImportWorker {
|
||||
|
||||
private final GameImportRepository importRepository;
|
||||
private final ManualGameService manualGameService;
|
||||
private final PsnTitleRepository psnTitleRepository;
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
private final MoodService moodService;
|
||||
private final ReleaseDateService releaseDateService;
|
||||
@@ -62,11 +64,13 @@ public class GameImportWorker {
|
||||
private final Lane saving;
|
||||
|
||||
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
|
||||
TimeToBeatService timeToBeatService, MoodService moodService,
|
||||
PsnTitleRepository psnTitleRepository, TimeToBeatService timeToBeatService,
|
||||
MoodService moodService,
|
||||
ReleaseDateService releaseDateService, GameCreditService gameCreditService,
|
||||
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
|
||||
this.importRepository = importRepository;
|
||||
this.manualGameService = manualGameService;
|
||||
this.psnTitleRepository = psnTitleRepository;
|
||||
this.timeToBeatService = timeToBeatService;
|
||||
this.moodService = moodService;
|
||||
this.releaseDateService = releaseDateService;
|
||||
@@ -140,10 +144,10 @@ public class GameImportWorker {
|
||||
private void save(long importId) {
|
||||
importRepository.start(importId, GameImport.Status.SAVING);
|
||||
List<GameImport.Choice> choices = importRepository.findUnsavedChoices(importId);
|
||||
long userId = importRepository.findUserId(importId);
|
||||
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
|
||||
: GameSource.MANUAL;
|
||||
if (!choices.isEmpty()) {
|
||||
long userId = importRepository.findUserId(importId);
|
||||
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
|
||||
: GameSource.MANUAL;
|
||||
Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream()
|
||||
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
|
||||
for (GameImport.Choice choice : choices) {
|
||||
@@ -156,12 +160,25 @@ public class GameImportWorker {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
boolean added = manualGameService.addGame(userId, game, choice.platform(), choice.status(),
|
||||
choice.playtimeMinutes(), source, importId) instanceof ManualGameService.AddResult.Added;
|
||||
GameImport.Line line = rows.get(choice.lineNo()).line();
|
||||
String psnKey = line.psnKey();
|
||||
// PlayStation owns the playtime of its titles: the review's value would be booked as growth later.
|
||||
int minutes = psnKey != null && line.playtimeMinutes() != null ? line.playtimeMinutes()
|
||||
: choice.playtimeMinutes();
|
||||
ManualGameService.AddResult result = manualGameService.addGame(userId, game, choice.platform(),
|
||||
choice.status(), minutes, source, importId);
|
||||
if (psnKey != null) {
|
||||
linkPsnTitle(userId, psnKey, game, result);
|
||||
}
|
||||
importRepository.saveResult(importId, choice.lineNo(),
|
||||
added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED);
|
||||
result instanceof ManualGameService.AddResult.Added ? GameImport.SaveResult.ADDED
|
||||
: GameImport.SaveResult.SKIPPED);
|
||||
}
|
||||
}
|
||||
if (source == GameSource.PSN) {
|
||||
// A title left unchecked is not offered again. Also a resumed try runs this; a LINKED title stays.
|
||||
psnTitleRepository.dismiss(userId, importRepository.findPsnKeysWithoutChoice(importId));
|
||||
}
|
||||
importRepository.setStep(importId, GameImport.Step.DETAILS);
|
||||
List<Long> appids = importRepository.findAddedAppids(importId);
|
||||
try {
|
||||
@@ -180,6 +197,14 @@ public class GameImportWorker {
|
||||
importRepository.markSaved(importId);
|
||||
}
|
||||
|
||||
private void linkPsnTitle(long userId, String psnKey, IgdbGame game, ManualGameService.AddResult result) {
|
||||
if (result instanceof ManualGameService.AddResult.Added added) {
|
||||
psnTitleRepository.link(userId, psnKey, added.appid());
|
||||
} else {
|
||||
manualGameService.ownedKey(userId, game).ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid));
|
||||
}
|
||||
}
|
||||
|
||||
private record Match(boolean searchFailed, boolean exact, List<IgdbGame> candidates) {}
|
||||
|
||||
private Match match(GameImport.Line line) {
|
||||
|
||||
@@ -3,12 +3,14 @@ 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.repo.BacklogSnapshotRepository;
|
||||
import core_service.modules.library.repo.PsnAccountRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.shared.security.RequiresRole;
|
||||
import core_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -19,6 +21,10 @@ import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
|
||||
@Service
|
||||
@RequiresRole(Role.USER)
|
||||
@@ -32,6 +38,9 @@ public class LibrarySyncService {
|
||||
private final LibrarySyncWorker worker;
|
||||
private final BacklogSnapshotRepository backlogSnapshotRepository;
|
||||
private final FinishedEstimateService finishedEstimateService;
|
||||
private final PsnAccountRepository psnAccountRepository;
|
||||
private final TaskScheduler taskScheduler;
|
||||
private final Map<Long, ScheduledFuture<?>> pendingAfterLink = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${app.library.sync-ttl-hours:20}")
|
||||
private long syncTtlHours;
|
||||
@@ -60,6 +69,10 @@ public class LibrarySyncService {
|
||||
* request that won the gate
|
||||
*/
|
||||
public boolean syncIfStale(Long userId, String steamId) {
|
||||
if (!canSync(userId, steamId)) {
|
||||
stopNightlySync(userId);
|
||||
return false;
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
||||
touchUserSeen(sync);
|
||||
@@ -94,7 +107,7 @@ public class LibrarySyncService {
|
||||
userSteamSyncRepository.findByUserSeenAtAfter(now.minus(Duration.ofDays(nightlyActiveDays)));
|
||||
List<UserSteamSync> accounts =
|
||||
active.stream().filter(s -> !syncedLateToday(s.getLastSuccessAt(), today)).toList();
|
||||
log.info("Nightly sync for {} of {} active Steam accounts", accounts.size(), active.size());
|
||||
log.info("Nightly sync for {} of {} active accounts", accounts.size(), active.size());
|
||||
// Taken before the syncs: the loop can run past midnight.
|
||||
LocalDate day = today;
|
||||
try {
|
||||
@@ -121,6 +134,13 @@ public class LibrarySyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A user syncs with a Steam ID or a PlayStation link.
|
||||
*/
|
||||
public boolean canSync(Long userId, String steamId) {
|
||||
return steamId != null || psnAccountRepository.find(userId).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* For a user whose token has no Steam ID any more (unlinked, or taken by a verified link in Auth): the nightly
|
||||
* job would otherwise keep syncing the old ID until the user is 14 days inactive.
|
||||
@@ -131,8 +151,13 @@ public class LibrarySyncService {
|
||||
|
||||
/**
|
||||
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
|
||||
*
|
||||
* @throws IllegalArgumentException when the user has no Steam ID and no PlayStation link
|
||||
*/
|
||||
public void forceSync(Long userId, String steamId) {
|
||||
if (!canSync(userId, steamId)) {
|
||||
throw new IllegalArgumentException("No Steam ID or PlayStation link");
|
||||
}
|
||||
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
||||
touchUserSeen(sync);
|
||||
if (sync != null && sync.getLastSyncedAt() != null) {
|
||||
@@ -147,6 +172,55 @@ public class LibrarySyncService {
|
||||
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync after a PlayStation link: like {@link #forceSync}, with the same cooldown, so linking again and again
|
||||
* never syncs faster than the sync button. While a sync runs or the cooldown has not ended, the sync is
|
||||
* scheduled for the end of it; there is at most one scheduled sync per user.
|
||||
*/
|
||||
public void startAfterLink(Long userId, String steamId) {
|
||||
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
||||
touchUserSeen(sync);
|
||||
Instant runAt = sync == null ? null : nextAllowedRun(sync);
|
||||
if (runAt == null) {
|
||||
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
|
||||
return;
|
||||
}
|
||||
log.info("Sync after the PlayStation link of user {} waits until {}", userId, runAt);
|
||||
pendingAfterLink.compute(userId, (id, pending) -> pending != null && !pending.isDone() ? pending
|
||||
: taskScheduler.schedule(() -> runAfterLinkLater(userId, steamId), runAt));
|
||||
}
|
||||
|
||||
private void runAfterLinkLater(Long userId, String linkedSteamId) {
|
||||
pendingAfterLink.remove(userId);
|
||||
try {
|
||||
// The Steam ID may have changed in the wait; a run with the old one would reset the Steam baseline.
|
||||
String steamId = userSteamSyncRepository.findById(userId).map(UserSteamSync::getSteamId)
|
||||
.orElse(linkedSteamId);
|
||||
// Still blocked (a manual sync came first): the same call schedules the next try.
|
||||
if (canSync(userId, steamId)) {
|
||||
startAfterLink(userId, steamId);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Scheduled sync after the PlayStation link of user {} failed", userId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Null: a sync may start now. A running sync has no known end, so the next try is one cooldown away.
|
||||
private Instant nextAllowedRun(UserSteamSync sync) {
|
||||
if (isAlreadyRunning(sync)) {
|
||||
return Instant.now().plusSeconds(forceSyncCooldownSeconds);
|
||||
}
|
||||
if (!isInCooldown(sync)) {
|
||||
return null;
|
||||
}
|
||||
return sync.getLastSyncedAt().plusSeconds(forceSyncCooldownSeconds);
|
||||
}
|
||||
|
||||
private boolean isInCooldown(UserSteamSync sync) {
|
||||
return sync.getLastSyncedAt() != null
|
||||
&& Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds() < forceSyncCooldownSeconds;
|
||||
}
|
||||
|
||||
// At most one write per user and hour; the nightly sync only needs to know "used recently".
|
||||
private void touchUserSeen(UserSteamSync sync) {
|
||||
Instant now = Instant.now();
|
||||
@@ -157,7 +231,7 @@ public class LibrarySyncService {
|
||||
|
||||
private boolean isLoginSyncDue(UserSteamSync sync, String steamId, Instant successBefore, Instant triedBefore) {
|
||||
return !isAlreadyRunning(sync)
|
||||
&& (!steamId.equals(sync.getSteamId())
|
||||
&& (!Objects.equals(steamId, sync.getSteamId())
|
||||
|| (isNullOrBefore(sync.getLastSuccessAt(), successBefore)
|
||||
&& isNullOrBefore(sync.getLoginSyncTriedAt(), triedBefore)));
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync.Status;
|
||||
import core_service.shared.background.BackgroundPools;
|
||||
import core_service.shared.event.LibraryActivityEvent;
|
||||
|
||||
/**
|
||||
* Worker bean. Runs the actual Steam fetch + DB upserts. Separate bean so the Spring proxy applies @Async
|
||||
@@ -69,6 +70,7 @@ public class LibrarySyncWorker {
|
||||
private final GameEditionService gameEditionService;
|
||||
private final GameCreditService gameCreditService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final PsnSyncStep psnSyncStep;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
@@ -77,6 +79,9 @@ public class LibrarySyncWorker {
|
||||
@Value("${app.library.zone:Europe/Berlin}")
|
||||
private ZoneId zone;
|
||||
|
||||
/**
|
||||
* @param steamId null for a user with a PlayStation link and no Steam account: only the PlayStation part runs
|
||||
*/
|
||||
@Async(BackgroundPools.DEFAULT)
|
||||
public void runSync(Long userId, String steamId, SyncTrigger trigger) {
|
||||
sync(userId, steamId, trigger);
|
||||
@@ -101,7 +106,7 @@ public class LibrarySyncWorker {
|
||||
return null;
|
||||
}
|
||||
String previousSteamId = before != null ? before.getSteamId() : null;
|
||||
if (previousSteamId != null && !previousSteamId.equals(steamId)) {
|
||||
if (steamId != null && previousSteamId != null && !previousSteamId.equals(steamId)) {
|
||||
// The old games belong to the previous Steam account, also when Steam fails now. The upserts
|
||||
// restore the games this account also owns.
|
||||
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
||||
@@ -115,37 +120,55 @@ public class LibrarySyncWorker {
|
||||
}
|
||||
|
||||
Status finalStatus = Status.FAILED;
|
||||
boolean hasSteam = steamId != null;
|
||||
try {
|
||||
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
|
||||
LocalDate playedOn = steamId.equals(claim.historySteamId()) ? LocalDate.ofInstant(syncStartedAt, zone) : null;
|
||||
LocalDate syncDay = LocalDate.ofInstant(syncStartedAt, zone);
|
||||
LocalDate playedOn = hasSteam && steamId.equals(claim.historySteamId()) ? syncDay : null;
|
||||
// Distinct: the maps in saveGames are loaded once, so a second line of a game would be a new game again.
|
||||
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId).stream()
|
||||
List<SteamOwnedGamesResponse.Game> games = !hasSteam ? List.of()
|
||||
: steamApiService.getOwnedGames(steamId).stream()
|
||||
.filter(g -> g.appid() != null)
|
||||
.collect(Collectors.toMap(SteamOwnedGamesResponse.Game::appid, g -> g, (first, second) -> first,
|
||||
LinkedHashMap::new))
|
||||
.values().stream().toList();
|
||||
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
|
||||
if (hasSteam) {
|
||||
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
|
||||
}
|
||||
// Before the empty check: an account without games can still have a public wishlist.
|
||||
Optional<Wishlist> wishlist = fetchWishlist(steamId, games);
|
||||
Optional<Wishlist> wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty();
|
||||
PsnSyncStep.Prepared psn = psnSyncStep.run(userId);
|
||||
boolean saved = transaction.execute(tx -> {
|
||||
List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
|
||||
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
|
||||
if (hasSteam) {
|
||||
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
|
||||
}
|
||||
return List.of();
|
||||
});
|
||||
if (games.isEmpty()) {
|
||||
if (hasSteam && games.isEmpty()) {
|
||||
// A private "Game details" setting ends here. A soft-delete would wipe the library.
|
||||
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
|
||||
return false;
|
||||
}
|
||||
List<SyncChange> changes = saveGames(userId, steamId, games, syncStartedAt, playedOn);
|
||||
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
|
||||
: new ArrayList<>();
|
||||
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay);
|
||||
// The first sync of an account is a baseline: a count, not a list.
|
||||
if (playedOn == null) {
|
||||
int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count();
|
||||
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of());
|
||||
int imported = 0;
|
||||
List<SyncChange> listed = new ArrayList<>();
|
||||
if (hasSteam && playedOn == null) {
|
||||
imported += countAdded(steamChanges);
|
||||
} else {
|
||||
changes.addAll(wishlistAdds);
|
||||
syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes);
|
||||
listed.addAll(steamChanges);
|
||||
listed.addAll(wishlistAdds);
|
||||
}
|
||||
if (psn.baseline()) {
|
||||
imported += countAdded(psnChanges);
|
||||
psnChanges.stream().filter(change -> change.kind() != SyncChangeKind.ADDED).forEach(listed::add);
|
||||
} else {
|
||||
listed.addAll(psnChanges);
|
||||
}
|
||||
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, listed);
|
||||
return true;
|
||||
});
|
||||
if (!saved) {
|
||||
@@ -170,12 +193,17 @@ public class LibrarySyncWorker {
|
||||
log.warn("Sync user={} ran past running-stale-minutes; the run that took over sets the status", userId);
|
||||
} else if (status == Status.OK) {
|
||||
// The listeners run after this commit.
|
||||
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||
eventPublisher.publishEvent(hasSteam ? new LibrarySyncedEvent(userId, steamId)
|
||||
: new LibraryActivityEvent(userId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static int countAdded(List<SyncChange> changes) {
|
||||
return (int) changes.stream().filter(change -> change.kind() == SyncChangeKind.ADDED).count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return what changed for the user, for the sync result
|
||||
*/
|
||||
|
||||
@@ -233,6 +233,15 @@ public class ManualGameService {
|
||||
.map(IgdbGame::igdbId).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the user owns this game under, if any (a merge included).
|
||||
*/
|
||||
public Optional<Long> ownedKey(Long userId, IgdbGame game) {
|
||||
Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
|
||||
Set<Long> owned = ownedAppids(userId, List.of(game), targets, List.of());
|
||||
return possibleKeys(game, targets).stream().filter(owned::contains).findFirst();
|
||||
}
|
||||
|
||||
private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status,
|
||||
int playtimeMinutes, GameSource source, Long importId) {
|
||||
return transaction.execute(tx -> {
|
||||
@@ -270,7 +279,10 @@ public class ManualGameService {
|
||||
public ChangeResult update(Long userId, long appid, String platform, int playtimeMinutes) {
|
||||
return transaction.execute(tx -> changeManual(userId, appid, game -> {
|
||||
game.setPlatform(platform);
|
||||
game.setPlaytimeMinutes(playtimeMinutes);
|
||||
// PlayStation owns the playtime of its rows: a typed value would be booked as growth by the next sync.
|
||||
if (game.getSource() != GameSource.PSN) {
|
||||
game.setPlaytimeMinutes(playtimeMinutes);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,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.PsnAccountRepository;
|
||||
import core_service.modules.psn_adapter.service.PsnApiService;
|
||||
import core_service.modules.psn_adapter.service.PsnAuthService;
|
||||
@@ -15,6 +16,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -22,7 +24,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* The linked PlayStation account of a user: link with an NPSSO, one import of the played titles, unlink. The
|
||||
* The linked PlayStation account of a user: link with an NPSSO, the played titles for the sync, unlink. The
|
||||
* refresh token is stored sealed; the NPSSO, the codes and every token stay out of the logs, the database and the
|
||||
* answers. PlayStation is called outside any transaction.
|
||||
*/
|
||||
@@ -33,8 +35,9 @@ public class PsnAccountService {
|
||||
|
||||
/**
|
||||
* @param enabled false when Core has no key: the link is off
|
||||
* @param reviewImportId the PSN import that waits for the user's review; null when none
|
||||
*/
|
||||
public record Status(boolean enabled, boolean linked, Instant linkedAt, Instant expiresAt) {}
|
||||
public record Status(boolean enabled, boolean linked, Instant linkedAt, Instant expiresAt, Long reviewImportId) {}
|
||||
|
||||
public sealed interface LinkResult {
|
||||
record Linked(Status status) implements LinkResult {}
|
||||
@@ -50,42 +53,43 @@ public class PsnAccountService {
|
||||
record Busy() implements LinkResult {}
|
||||
}
|
||||
|
||||
public sealed interface ImportResult {
|
||||
record Started(long id) implements ImportResult {}
|
||||
public sealed interface FetchResult {
|
||||
record Titles(List<PsnApiService.Title> titles) implements FetchResult {}
|
||||
|
||||
record NotLinked() implements ImportResult {}
|
||||
record NotLinked() implements FetchResult {}
|
||||
|
||||
/** The link ended: the user pastes a new NPSSO. */
|
||||
record Relink() implements ImportResult {}
|
||||
record Relink() implements FetchResult {}
|
||||
|
||||
record NoTitles() implements ImportResult {}
|
||||
record Off() implements FetchResult {}
|
||||
|
||||
record Off() implements ImportResult {}
|
||||
|
||||
record Unavailable() implements ImportResult {}
|
||||
|
||||
record AlreadyMatching() implements ImportResult {}
|
||||
|
||||
record SearchUnavailable() implements ImportResult {}
|
||||
record Unavailable() implements FetchResult {}
|
||||
|
||||
/** The user has another PlayStation call running. */
|
||||
record Busy() implements ImportResult {}
|
||||
record Busy() implements FetchResult {}
|
||||
}
|
||||
|
||||
// Must match the inbox NoteKind names; the slice rules keep library from using that enum.
|
||||
static final String EXPIRING_NOTE = "PSN_EXPIRING";
|
||||
static final String EXPIRED_NOTE = "PSN_EXPIRED";
|
||||
|
||||
private final PsnAccountRepository repository;
|
||||
private final PsnAuthService authService;
|
||||
private final PsnApiService apiService;
|
||||
private final GameImportService importService;
|
||||
private final GameImportRepository importRepository;
|
||||
private final Duration warningTime;
|
||||
private final SecretBox box;
|
||||
// One PlayStation call per user at a time: parallel calls of one user must not hold the shared rate limiter.
|
||||
private final Set<Long> inFlight = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public PsnAccountService(PsnAccountRepository repository, PsnAuthService authService, PsnApiService apiService,
|
||||
GameImportService importService, @Value("${app.psn.token-key:}") String tokenKey) {
|
||||
GameImportRepository importRepository, @Value("${app.psn.token-key:}") String tokenKey,
|
||||
@Value("${app.psn.expiry-warning-days:2}") long warningDays) {
|
||||
this.repository = repository;
|
||||
this.authService = authService;
|
||||
this.apiService = apiService;
|
||||
this.importService = importService;
|
||||
this.importRepository = importRepository;
|
||||
this.warningTime = Duration.ofDays(warningDays);
|
||||
// A wrong key stops Core at start; no key only switches the feature off.
|
||||
this.box = tokenKey == null || tokenKey.isBlank() ? null : SecretBox.fromBase64Key(tokenKey);
|
||||
}
|
||||
@@ -96,8 +100,10 @@ public class PsnAccountService {
|
||||
|
||||
public Status get(Long userId) {
|
||||
Optional<PsnAccount> account = box != null ? repository.find(userId) : Optional.empty();
|
||||
Long reviewImportId = box == null ? null : importRepository.findOpenPsnImport(userId)
|
||||
.filter(found -> found.status() == GameImport.Status.READY).map(GameImport::id).orElse(null);
|
||||
return new Status(isEnabled(), account.isPresent(), account.map(PsnAccount::linkedAt).orElse(null),
|
||||
account.map(PsnAccount::refreshExpiresAt).orElse(null));
|
||||
account.map(PsnAccount::refreshExpiresAt).orElse(null), reviewImportId);
|
||||
}
|
||||
|
||||
public LinkResult link(Long userId, String npsso) {
|
||||
@@ -138,30 +144,36 @@ public class PsnAccountService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the access token, reads the played titles and starts an import with them. A refresh that returns
|
||||
* the same refresh token keeps the stored expiry, a new one sets its own.
|
||||
* Refreshes the access token and reads the played titles; the inbox gets a note when the link ends soon or has
|
||||
* ended. A refresh that returns the same refresh token keeps the stored expiry, a new one sets its own.
|
||||
*/
|
||||
public ImportResult startImport(Long userId) {
|
||||
public FetchResult fetchTitles(Long userId) {
|
||||
if (box == null) {
|
||||
return new ImportResult.Off();
|
||||
return new FetchResult.Off();
|
||||
}
|
||||
if (!inFlight.add(userId)) {
|
||||
return new ImportResult.Busy();
|
||||
return new FetchResult.Busy();
|
||||
}
|
||||
try {
|
||||
return doImport(userId);
|
||||
return doFetch(userId);
|
||||
} finally {
|
||||
inFlight.remove(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private ImportResult doImport(Long userId) {
|
||||
private FetchResult doFetch(Long userId) {
|
||||
Optional<PsnAccount> account = repository.find(userId);
|
||||
if (account.isEmpty()) {
|
||||
return new ImportResult.NotLinked();
|
||||
return new FetchResult.NotLinked();
|
||||
}
|
||||
if (!account.get().refreshExpiresAt().isAfter(Instant.now())) {
|
||||
return new ImportResult.Relink();
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = account.get().refreshExpiresAt();
|
||||
if (!expiresAt.isAfter(now)) {
|
||||
repository.noteExpiry(userId, EXPIRED_NOTE);
|
||||
return new FetchResult.Relink();
|
||||
}
|
||||
if (!expiresAt.isAfter(now.plus(warningTime))) {
|
||||
repository.noteExpiry(userId, EXPIRING_NOTE);
|
||||
}
|
||||
String stored;
|
||||
try {
|
||||
@@ -169,33 +181,27 @@ public class PsnAccountService {
|
||||
} catch (IllegalStateException e) {
|
||||
// Sealed with another key: the user links again.
|
||||
log.warn("Stored PSN token of user={} cannot be opened", userId);
|
||||
repository.expireNow(userId);
|
||||
return new ImportResult.Relink();
|
||||
return expired(userId);
|
||||
}
|
||||
List<PsnApiService.Title> titles;
|
||||
try {
|
||||
Optional<PsnAuthService.Tokens> tokens = authService.refresh(stored);
|
||||
if (tokens.isEmpty()) {
|
||||
repository.expireNow(userId);
|
||||
return new ImportResult.Relink();
|
||||
return expired(userId);
|
||||
}
|
||||
if (!tokens.get().refreshToken().equals(stored)) {
|
||||
repository.updateToken(userId, box.seal(tokens.get().refreshToken()), tokens.get().refreshExpiresAt());
|
||||
}
|
||||
titles = apiService.getPlayedTitles(tokens.get().accessToken());
|
||||
return new FetchResult.Titles(apiService.getPlayedTitles(tokens.get().accessToken()));
|
||||
} catch (RestClientException | RequestNotPermitted e) {
|
||||
logFailure("PSN import failed", e);
|
||||
return new ImportResult.Unavailable();
|
||||
logFailure("PSN fetch failed", e);
|
||||
return new FetchResult.Unavailable();
|
||||
}
|
||||
List<GameImport.Line> lines = PsnTitles.toLines(titles);
|
||||
if (lines.isEmpty()) {
|
||||
return new ImportResult.NoTitles();
|
||||
}
|
||||
return switch (importService.createLines(userId, GameImport.Source.PSN, lines)) {
|
||||
case GameImportService.CreateResult.Created created -> new ImportResult.Started(created.id());
|
||||
case GameImportService.CreateResult.AlreadyMatching ignored -> new ImportResult.AlreadyMatching();
|
||||
case GameImportService.CreateResult.SearchUnavailable ignored -> new ImportResult.SearchUnavailable();
|
||||
};
|
||||
}
|
||||
|
||||
private FetchResult expired(Long userId) {
|
||||
repository.expireNow(userId);
|
||||
repository.noteExpiry(userId, EXPIRED_NOTE);
|
||||
return new FetchResult.Relink();
|
||||
}
|
||||
|
||||
// The class and the status only: the message of a RestClientException can hold the answer's body.
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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.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 lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* The PlayStation part of the library sync. {@link #run} calls PlayStation and IGDB and adds the games, outside the
|
||||
* sync transaction; {@link #apply} writes the playtime inside it. PlayStation never fails the Steam part.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PsnSyncStep {
|
||||
|
||||
private static final int TWO_WEEKS_DAYS = 14;
|
||||
|
||||
/**
|
||||
* A LINKED title's PSN values, applied in the sync transaction.
|
||||
*
|
||||
* @param lastPlayedAt may be null
|
||||
*/
|
||||
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {}
|
||||
|
||||
/**
|
||||
* What {@link #run} did outside the transaction.
|
||||
*
|
||||
* @param changes the ADDED rows of this run
|
||||
* @param baseline the user had no PSN title before this run
|
||||
*/
|
||||
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, boolean baseline) {
|
||||
public static final Prepared NONE = new Prepared(List.of(), List.of(), false);
|
||||
}
|
||||
|
||||
private final PsnAccountService accountService;
|
||||
private final PsnTitleRepository psnTitleRepository;
|
||||
private final GameImportRepository importRepository;
|
||||
private final GameMergeRepository mergeRepository;
|
||||
private final ManualGameService manualGameService;
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
public PsnSyncStep(PsnAccountService accountService, PsnTitleRepository psnTitleRepository,
|
||||
GameImportRepository importRepository, GameMergeRepository mergeRepository,
|
||||
ManualGameService manualGameService, UserGameRepository userGameRepository,
|
||||
PlaytimeDayRepository playtimeDayRepository, PlatformTransactionManager transactionManager) {
|
||||
this.accountService = accountService;
|
||||
this.psnTitleRepository = psnTitleRepository;
|
||||
this.importRepository = importRepository;
|
||||
this.mergeRepository = mergeRepository;
|
||||
this.manualGameService = manualGameService;
|
||||
this.userGameRepository = userGameRepository;
|
||||
this.playtimeDayRepository = playtimeDayRepository;
|
||||
this.transaction = new TransactionTemplate(transactionManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outside any transaction: PlayStation and IGDB calls, adds, review rows. Never throws.
|
||||
*/
|
||||
public Prepared run(Long userId) {
|
||||
List<SyncChange> changes = new ArrayList<>();
|
||||
List<Playtime> playtimes = new ArrayList<>();
|
||||
boolean baseline = false;
|
||||
try {
|
||||
Optional<List<PsnApiService.Title>> titles = fetch(userId);
|
||||
if (titles.isEmpty()) {
|
||||
return Prepared.NONE;
|
||||
}
|
||||
List<GameImport.Line> lines = PsnTitles.toLines(titles.get());
|
||||
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(titles.get());
|
||||
Map<String, PsnTitle> known = psnTitleRepository.findAll(userId);
|
||||
baseline = known.isEmpty();
|
||||
Set<String> open = importRepository.findOpenPsnKeys(userId);
|
||||
List<GameImport.Row> review = new ArrayList<>();
|
||||
for (GameImport.Line line : lines) {
|
||||
PsnTitle seen = known.get(line.psnKey());
|
||||
if (seen != null && seen.state() == PsnTitle.State.LINKED) {
|
||||
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey())));
|
||||
} else if (seen == null && !open.contains(line.psnKey())) {
|
||||
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review);
|
||||
}
|
||||
}
|
||||
saveReview(userId, review);
|
||||
log.info("PSN sync user={} added {} titles, {} wait for review", userId, changes.size(), review.size());
|
||||
return new Prepared(changes, mergedKeys(playtimes), baseline);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("PSN step failed for user {}", userId, e);
|
||||
return new Prepared(changes, playtimes, baseline);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<List<PsnApiService.Title>> fetch(Long userId) {
|
||||
switch (accountService.fetchTitles(userId)) {
|
||||
case PsnAccountService.FetchResult.Titles found -> {
|
||||
return Optional.of(found.titles());
|
||||
}
|
||||
case PsnAccountService.FetchResult.Relink _ ->
|
||||
log.info("PSN sync skipped for user={}: link expired", userId);
|
||||
case PsnAccountService.FetchResult.Unavailable _, PsnAccountService.FetchResult.Busy _ ->
|
||||
log.warn("PSN sync skipped for user={}", userId);
|
||||
case PsnAccountService.FetchResult.NotLinked _, PsnAccountService.FetchResult.Off _ -> { }
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private void matchNew(Long userId, GameImport.Line line, Instant lastPlayedAt, List<SyncChange> changes,
|
||||
List<GameImport.Row> review) {
|
||||
Optional<List<IgdbGame>> found = manualGameService.findGames(line.title());
|
||||
if (found.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<IgdbGame> candidates = GameImportWorker.rank(line, found.get());
|
||||
IgdbGame first = candidates.isEmpty() ? null : candidates.getFirst();
|
||||
boolean exact = first != null && GameImportWorker.sameName(first.name(), line.title())
|
||||
&& first.platforms().stream().anyMatch(line.platform()::equalsIgnoreCase);
|
||||
if (exact) {
|
||||
addExact(userId, line, first, lastPlayedAt, changes);
|
||||
} else if (!linkOwned(userId, line.psnKey(), first)) {
|
||||
review.add(new GameImport.Row(line, false, false, candidates));
|
||||
}
|
||||
}
|
||||
|
||||
private void addExact(Long userId, GameImport.Line line, IgdbGame game, Instant lastPlayedAt,
|
||||
List<SyncChange> changes) {
|
||||
ManualGameService.AddResult added = manualGameService.addGame(userId, game, line.platform(), line.status(),
|
||||
line.playtimeMinutes(), GameSource.PSN, null);
|
||||
if (!(added instanceof ManualGameService.AddResult.Added result)) {
|
||||
linkOwned(userId, line.psnKey(), game);
|
||||
return;
|
||||
}
|
||||
psnTitleRepository.link(userId, line.psnKey(), result.appid());
|
||||
if (lastPlayedAt != null) {
|
||||
userGameRepository.setLastPlayedAt(userId, result.appid(), lastPlayedAt);
|
||||
}
|
||||
changes.add(SyncChange.added(result.appid()));
|
||||
}
|
||||
|
||||
// Also the same game under another spelling or platform.
|
||||
private boolean linkOwned(Long userId, String psnKey, IgdbGame game) {
|
||||
Optional<Long> owned = game == null ? Optional.empty() : manualGameService.ownedKey(userId, game);
|
||||
owned.ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid));
|
||||
return owned.isPresent();
|
||||
}
|
||||
|
||||
private void saveReview(Long userId, List<GameImport.Row> review) {
|
||||
if (review.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Optional<GameImport> open = importRepository.findOpenPsnImport(userId);
|
||||
if (open.isEmpty()) {
|
||||
transaction.executeWithoutResult(tx -> importRepository.createReady(userId, review));
|
||||
} else if (open.get().status() == GameImport.Status.READY) {
|
||||
transaction.executeWithoutResult(tx -> importRepository.appendReadyRows(open.get().id(), review));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Playtime> mergedKeys(List<Playtime> playtimes) {
|
||||
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList());
|
||||
return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
|
||||
playtime.minutes(), playtime.lastPlayedAt())).toList();
|
||||
}
|
||||
|
||||
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
|
||||
// gets one value per run and the total never flips between titles.
|
||||
private static Collection<Playtime> perGame(List<Playtime> playtimes) {
|
||||
Map<Long, Playtime> byGame = new LinkedHashMap<>();
|
||||
for (Playtime playtime : playtimes) {
|
||||
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
|
||||
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
|
||||
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
|
||||
? b.lastPlayedAt() : a.lastPlayedAt()));
|
||||
}
|
||||
return byGame.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the
|
||||
* PSN rows. No status change: PSN rows have statusSetByUser.
|
||||
*
|
||||
* @return all PSN changes of the run (ADDED and PLAYTIME)
|
||||
*/
|
||||
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) {
|
||||
List<SyncChange> changes = new ArrayList<>(prepared.changes());
|
||||
Map<Long, UserGame> touched = new HashMap<>();
|
||||
for (Playtime playtime : perGame(prepared.playtimes())) {
|
||||
userGameRepository.findById(new UserGameId(userId, playtime.appid()))
|
||||
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
|
||||
.ifPresent(game -> {
|
||||
int growth = playtime.minutes() - game.getPlaytimeMinutes();
|
||||
game.setPlaytimeMinutes(playtime.minutes());
|
||||
game.setLastSeenAt(now);
|
||||
if (playtime.lastPlayedAt() != null) {
|
||||
game.setLastPlayedAt(playtime.lastPlayedAt());
|
||||
}
|
||||
if (growth > 0) {
|
||||
changes.add(SyncChange.playtime(game.getAppid(), growth));
|
||||
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth);
|
||||
}
|
||||
touched.put(game.getAppid(), game);
|
||||
});
|
||||
}
|
||||
if (!touched.isEmpty()) {
|
||||
Map<Long, Long> twoWeeks = playtimeDayRepository
|
||||
.minutesPerGameSince(userId, new HashSet<>(touched.keySet()), day.minusDays(TWO_WEEKS_DAYS - 1))
|
||||
.stream().collect(Collectors.toMap(AppidMinutes::appid, AppidMinutes::minutes));
|
||||
touched.forEach((appid, game) ->
|
||||
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import core_service.modules.library.domain.GameImport;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.psn_adapter.service.PsnApiService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -43,23 +46,37 @@ final class PsnTitles {
|
||||
}
|
||||
|
||||
static List<GameImport.Line> toLines(List<PsnApiService.Title> titles) {
|
||||
List<GameImport.Line> lines = new ArrayList<>();
|
||||
for (Map.Entry<String, List<PsnApiService.Title>> group : group(titles).entrySet()) {
|
||||
if (lines.size() >= ImportRequest.MAX_ROWS) {
|
||||
break;
|
||||
}
|
||||
PsnApiService.Title mostPlayed = group.getValue().stream()
|
||||
.max(Comparator.comparing(PsnApiService.Title::playDuration)).orElseThrow();
|
||||
long minutes = Math.min(ManualGameUpdate.MAX_MINUTES,
|
||||
group.getValue().stream().mapToLong(title -> title.playDuration().toMinutes()).sum());
|
||||
lines.add(new GameImport.Line(lines.size() + 1, cleanName(mostPlayed.name()), platform(mostPlayed.category()),
|
||||
(int) minutes, GameStatus.initialFor((int) minutes), null, group.getKey()));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest last-played time of each title group; a group without any is left out.
|
||||
*/
|
||||
static Map<String, Instant> lastPlayedByKey(List<PsnApiService.Title> titles) {
|
||||
Map<String, Instant> lastPlayed = new HashMap<>();
|
||||
group(titles).forEach((key, group) -> group.stream().map(PsnApiService.Title::lastPlayedAt)
|
||||
.filter(Objects::nonNull).max(Comparator.naturalOrder()).ifPresent(at -> lastPlayed.put(key, at)));
|
||||
return lastPlayed;
|
||||
}
|
||||
|
||||
private static Map<String, List<PsnApiService.Title>> group(List<PsnApiService.Title> titles) {
|
||||
Map<String, List<PsnApiService.Title>> groups = new LinkedHashMap<>();
|
||||
for (PsnApiService.Title title : titles) {
|
||||
String key = title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId();
|
||||
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(title);
|
||||
}
|
||||
List<GameImport.Line> lines = new ArrayList<>();
|
||||
for (List<PsnApiService.Title> group : groups.values()) {
|
||||
if (lines.size() >= ImportRequest.MAX_ROWS) {
|
||||
break;
|
||||
}
|
||||
PsnApiService.Title mostPlayed = group.stream()
|
||||
.max(Comparator.comparing(PsnApiService.Title::playDuration)).orElseThrow();
|
||||
long minutes = Math.min(ManualGameUpdate.MAX_MINUTES,
|
||||
group.stream().mapToLong(title -> title.playDuration().toMinutes()).sum());
|
||||
lines.add(new GameImport.Line(lines.size() + 1, cleanName(mostPlayed.name()), platform(mostPlayed.category()),
|
||||
(int) minutes, GameStatus.initialFor((int) minutes), null));
|
||||
}
|
||||
return lines;
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -12,7 +12,8 @@ import java.util.List;
|
||||
public record PsnGameListResponse(List<Title> titles, Integer nextOffset) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Title(String titleId, String name, String category, String playDuration, Concept concept) {}
|
||||
public record Title(String titleId, String name, String category, String playDuration, Concept concept,
|
||||
String lastPlayedDateTime) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Concept(Long id) {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -24,8 +25,10 @@ public class PsnApiService {
|
||||
|
||||
/**
|
||||
* @param conceptId PlayStation's id of the game across its PS4 and PS5 versions; may be null
|
||||
* @param lastPlayedAt null when PlayStation sends none or an unreadable value
|
||||
*/
|
||||
public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId) {}
|
||||
public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId,
|
||||
Instant lastPlayedAt) {}
|
||||
|
||||
static final int PAGE_SIZE = 200;
|
||||
static final int MAX_TITLES = 1000;
|
||||
@@ -62,7 +65,8 @@ public class PsnApiService {
|
||||
for (PsnGameListResponse.Title title : page.titles()) {
|
||||
if (title.titleId() != null && title.name() != null && titles.size() < MAX_TITLES) {
|
||||
titles.add(new Title(title.titleId(), title.name(), title.category(), duration(title.playDuration()),
|
||||
title.concept() != null ? title.concept().id() : null));
|
||||
title.concept() != null ? title.concept().id() : null,
|
||||
instant(title.lastPlayedDateTime())));
|
||||
}
|
||||
}
|
||||
if (page.nextOffset() == null || page.nextOffset() <= offset) {
|
||||
@@ -73,6 +77,17 @@ public class PsnApiService {
|
||||
return titles;
|
||||
}
|
||||
|
||||
private static Instant instant(String iso) {
|
||||
if (iso == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Instant.parse(iso);
|
||||
} catch (DateTimeParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration duration(String iso) {
|
||||
if (iso == null) {
|
||||
return Duration.ZERO;
|
||||
|
||||
@@ -20,7 +20,8 @@ import java.util.Optional;
|
||||
|
||||
/**
|
||||
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
|
||||
* library module owns the entities and a module may not depend on another.
|
||||
* library module owns the entities and a module may not depend on another. Rewards keep Steam-only proof: PlayStation
|
||||
* rows (their playtime is PlayStation's number, and a title can be linked to a game later) give no facts.
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@@ -55,7 +56,9 @@ public class RewardFactsRepository {
|
||||
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
|
||||
group by user_id, appid) c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
|
||||
and ug.hidden_at is null
|
||||
and ug.hidden_at is null and ug.source <> 'PSN'
|
||||
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
|
||||
and t.state = 'LINKED')
|
||||
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
|
||||
and l.ref = c.appid::text)
|
||||
order by c.started_at
|
||||
@@ -88,7 +91,11 @@ public class RewardFactsRepository {
|
||||
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
|
||||
group by user_id, appid) c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
|
||||
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null
|
||||
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null and ug.source <> 'PSN'
|
||||
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
|
||||
and t.state = 'LINKED')
|
||||
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
|
||||
and t.state = 'LINKED')
|
||||
left join game_time_to_beat t on t.appid = c.appid
|
||||
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
|
||||
left join game_achievement_schema_meta s on s.appid = c.appid
|
||||
@@ -117,6 +124,9 @@ public class RewardFactsRepository {
|
||||
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
|
||||
left join game_achievement_schema_meta s on s.appid = ug.appid
|
||||
where ug.user_id = :userId and ug.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
|
||||
and ug.source <> 'PSN'
|
||||
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
|
||||
and t.state = 'LINKED')
|
||||
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
|
||||
and l.ref = ug.appid::text)
|
||||
""", new MapSqlParameterSource("userId", userId),
|
||||
@@ -125,7 +135,8 @@ public class RewardFactsRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Playtime minutes per month of the Steam playtime days (day is a Berlin date). Hidden games count too: hiding
|
||||
* Playtime minutes per month of the Steam playtime days (day is a Berlin date); the days of a PlayStation row, or of
|
||||
* a game a PlayStation title is linked to, are left out. Hidden games count too: hiding
|
||||
* is free and reversible, so excluding them would let a user shape the curve for one settle.
|
||||
*/
|
||||
public Map<YearMonth, Long> minutesPerMonth(long userId) {
|
||||
@@ -134,6 +145,10 @@ public class RewardFactsRepository {
|
||||
select to_char(p.day, 'YYYY-MM') as month, sum(p.minutes) as minutes
|
||||
from user_game_playtime_day p
|
||||
where p.user_id = :userId
|
||||
and not exists (select 1 from user_game g where g.user_id = p.user_id and g.appid = p.appid
|
||||
and g.source = 'PSN')
|
||||
and not exists (select 1 from user_psn_title t where t.user_id = p.user_id and t.appid = p.appid
|
||||
and t.state = 'LINKED')
|
||||
group by 1
|
||||
""", new MapSqlParameterSource("userId", userId),
|
||||
(RowCallbackHandler) rs -> result.put(YearMonth.parse(rs.getString("month")), rs.getLong("minutes")));
|
||||
|
||||
@@ -56,6 +56,8 @@ app:
|
||||
token-key: ${APP_PSN_TOKEN_KEY:}
|
||||
auth-base-url: https://ca.account.sony.com/api/authz/v3/oauth
|
||||
api-base-url: https://m.np.playstation.com/api
|
||||
# Days before the link ends that the inbox gets its "ends soon" note.
|
||||
expiry-warning-days: 2
|
||||
# The public client of the PlayStation app, as the psn-api project ships it. Not a secret of ours.
|
||||
client-id: 09515159-7237-4370-9b40-3806e67c0891
|
||||
client-secret: ucPjka5tntB2KqsP
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- PlayStation titles the sync has seen per user (#25 slice 2), one per concept (PS4 + PS5 = one). LINKED keeps its
|
||||
-- appid also after the user removed the game, so the sync never adds it again. STEAM_OWNED: the title matched a game the user
|
||||
-- owns from Steam; kept so it is not offered again, but PlayStation gives no playtime for it. DISMISSED: left unchecked
|
||||
-- in a review.
|
||||
CREATE TABLE user_psn_title (
|
||||
user_id BIGINT NOT NULL,
|
||||
psn_key VARCHAR(40) NOT NULL, -- PsnTitles key: "c<conceptId>" or "t<titleId>"
|
||||
state VARCHAR(16) NOT NULL CHECK (state IN ('LINKED', 'STEAM_OWNED', 'DISMISSED')),
|
||||
appid BIGINT, -- LINKED: the library key
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, psn_key)
|
||||
);
|
||||
-- The title a review line came from; NULL for any other import.
|
||||
ALTER TABLE game_import_row ADD COLUMN psn_key VARCHAR(40);
|
||||
-- The inbox note written for this link: NULL, PSN_EXPIRING or PSN_EXPIRED. A new link resets it.
|
||||
ALTER TABLE user_psn_account ADD COLUMN expiry_note VARCHAR(16);
|
||||
-- A user with a PlayStation link and no Steam account syncs too (steam_id NULL).
|
||||
ALTER TABLE user_steam_sync ALTER COLUMN steam_id DROP NOT NULL;
|
||||
ALTER TABLE inbox_note DROP CONSTRAINT inbox_note_kind_check;
|
||||
ALTER TABLE inbox_note ADD CONSTRAINT inbox_note_kind_check
|
||||
CHECK (kind IN ('DIGEST', 'VISIBILITY', 'PSN_EXPIRING', 'PSN_EXPIRED'));
|
||||
|
||||
-- When the user last played the game, from PlayStation; Steam gives no such value. The two views over user_game fix
|
||||
-- their columns at creation (see V24, V28): drop them, add the column, create them again.
|
||||
DROP VIEW counted_user_game;
|
||||
DROP VIEW listed_user_game;
|
||||
ALTER TABLE user_game ADD COLUMN last_played_at TIMESTAMPTZ;
|
||||
CREATE VIEW listed_user_game AS
|
||||
SELECT * FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL;
|
||||
CREATE VIEW counted_user_game AS
|
||||
SELECT ug.* FROM listed_user_game ug
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM game_link l
|
||||
JOIN game_link o ON o.set_id = l.set_id AND o.appid <> l.appid
|
||||
JOIN listed_user_game og ON og.user_id = ug.user_id AND og.appid = o.appid
|
||||
WHERE l.appid = ug.appid
|
||||
AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
|
||||
og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid)
|
||||
> (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
|
||||
ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid));
|
||||
@@ -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