diff --git a/readme.md b/readme.md index b9c68f1..75dfc9e 100644 --- a/readme.md +++ b/readme.md @@ -67,7 +67,12 @@ To Be Played is split into four repositories: names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes - older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. + older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. A note also carries `profileName` (the + subject's current name while the profile is public, else null), a detail line its `appid`, and each followed person + in `GET /api/follows` `lastActivityAt` (the newest digest note the caller got about them): the Frontend links the feed + to the game and to the profile view of that game. `GET /api/library/games/{appid}/preview` gives the catalog data of + any game (for "not in your library yet"), `GET /api/public/profiles/library/games/{appid}` one listed game of a public + profile, filtered by the ticket's sections; a hidden, removed or not owned game answers the same `404`. - **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id, appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then @@ -252,6 +257,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | +| `GET` | `/api/library/games/{appid}/preview` (`{appid, name, imageUrl, genres, timeToBeat}`: catalog data of any game, a merged key answers with the kept key; `404 GAME_NOT_FOUND`) | JWT | | `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | @@ -294,15 +300,16 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket | +| `GET` | `/api/public/profiles/library/games/{appid}` (`{appid, name, imageUrl, status, playtimeMinutes, achievementsUnlocked, achievementsTotal, completionPercent}`, a field of a hidden section is null; `404 GAME_NOT_FOUND` for a hidden, removed or not owned game or when every field is hidden, `401 INVALID_TICKET`) | profile ticket | | `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | -| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login | +| `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login | | `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login | | `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login | -| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | -| `GET` | `/api/inbox/{id}/details` (`{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login | +| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | +| `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login | | `PUT` | `/api/inbox/read` (marks all read; `204`) | login | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token | @@ -359,7 +366,7 @@ flowchart LR LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")] Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db - Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db + Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game, account_name, blocked_user, deleted_user"| Db Auth -->|"service token
/internal/accounts"| Account Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db ``` diff --git a/src/main/java/core_service/modules/follow/api/FollowController.java b/src/main/java/core_service/modules/follow/api/FollowController.java index caebec7..7e1a3c7 100644 --- a/src/main/java/core_service/modules/follow/api/FollowController.java +++ b/src/main/java/core_service/modules/follow/api/FollowController.java @@ -63,6 +63,6 @@ public class FollowController { } private static List toDto(List entries) { - return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList(); + return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName(), entry.lastActivityAt())).toList(); } } diff --git a/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java b/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java index c160fc6..1ce2856 100644 --- a/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java +++ b/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java @@ -1,5 +1,6 @@ package core_service.modules.follow.api.dto; +import java.time.Instant; import java.util.List; /** @@ -8,9 +9,10 @@ import java.util.List; public record FollowsDto(List following, List followers) { /** - * @param id the follow row, to end it; not a user id - * @param displayName null for a follower with a private profile + * @param id the follow row, to end it; not a user id + * @param displayName null for a follower with a private profile + * @param lastActivityAt following only: when the newest feed message about this person arrived; null without one */ - public record Person(long id, String displayName) { + public record Person(long id, String displayName, Instant lastActivityAt) { } } diff --git a/src/main/java/core_service/modules/follow/domain/FollowEntry.java b/src/main/java/core_service/modules/follow/domain/FollowEntry.java index 85dc4ff..fed5169 100644 --- a/src/main/java/core_service/modules/follow/domain/FollowEntry.java +++ b/src/main/java/core_service/modules/follow/domain/FollowEntry.java @@ -1,8 +1,11 @@ package core_service.modules.follow.domain; +import java.time.Instant; + /** - * @param id the follow row, not a user id - * @param displayName null for a follower with a private profile + * @param id the follow row, not a user id + * @param displayName null for a follower with a private profile + * @param lastActivityAt the newest digest note the caller got about this user; null for followers and without a note */ -public record FollowEntry(long id, String displayName) { +public record FollowEntry(long id, String displayName, Instant lastActivityAt) { } diff --git a/src/main/java/core_service/modules/follow/repo/FollowRepository.java b/src/main/java/core_service/modules/follow/repo/FollowRepository.java index 29378c1..7fe5913 100644 --- a/src/main/java/core_service/modules/follow/repo/FollowRepository.java +++ b/src/main/java/core_service/modules/follow/repo/FollowRepository.java @@ -7,6 +7,7 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import java.sql.Timestamp; import java.util.List; import java.util.Optional; @@ -20,8 +21,11 @@ public class FollowRepository { static final int LIST_LIMIT = 500; - private static final RowMapper ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"), - rs.getString("display_name")); + private static final RowMapper ENTRY = (rs, i) -> { + Timestamp lastActivityAt = rs.getTimestamp("last_activity_at"); + return new FollowEntry(rs.getLong("id"), rs.getString("display_name"), + lastActivityAt == null ? null : lastActivityAt.toInstant()); + }; private final NamedParameterJdbcTemplate jdbc; @@ -91,7 +95,11 @@ public class FollowRepository { public List findFollowing(long userId) { return jdbc.query(""" - select f.id, a.display_name from follow f + select f.id, a.display_name, + (select max(n.created_at) from inbox_note n + where n.user_id = f.user_id and n.subject_user_id = f.followed_user_id + and n.kind = 'DIGEST') as last_activity_at + from follow f left join account_name a on a.user_id = f.followed_user_id where f.user_id = :userId order by lower(a.display_name), f.id @@ -103,7 +111,8 @@ public class FollowRepository { */ public List findFollowers(long userId) { return jdbc.query(""" - select f.id, case when p.user_id is not null then a.display_name end as display_name + select f.id, case when p.user_id is not null then a.display_name end as display_name, + null::timestamptz as last_activity_at from follow f left join public_user p on p.user_id = f.user_id left join account_name a on a.user_id = f.user_id diff --git a/src/main/java/core_service/modules/inbox/api/InboxController.java b/src/main/java/core_service/modules/inbox/api/InboxController.java index a2570b1..7292e47 100644 --- a/src/main/java/core_service/modules/inbox/api/InboxController.java +++ b/src/main/java/core_service/modules/inbox/api/InboxController.java @@ -27,7 +27,7 @@ public class InboxController { public ResponseEntity inbox(@AuthenticationPrincipal UserPrincipal principal) { Inbox inbox = inboxService.inbox(principal.getIdAsLong()); return ResponseEntity.ok(new InboxDto(inbox.notes().stream() - .map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(), + .map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.profileName(), note.newGames(), note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details())) .toList(), inbox.unread())); } @@ -36,7 +36,7 @@ public class InboxController { public ResponseEntity details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) { return inboxService.details(principal.getIdAsLong(), id) .>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream() - .map(detail -> new NoteDetailsDto.Detail(detail.kind().name(), detail.gameName(), + .map(detail -> new NoteDetailsDto.Detail(detail.appid(), detail.kind().name(), detail.gameName(), detail.itemName(), detail.fromStatus(), detail.toStatus())) .toList()))) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) diff --git a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java index c4aff8f..d8c8842 100644 --- a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java +++ b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java @@ -11,9 +11,10 @@ public record InboxDto(List results, int unread) { /** * @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 + * @param profileName the subject's current name while the profile is public, else null; never an id * @param details the note can be opened: it has detail rows and the subject is still followed and public */ - public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges, + public record Note(long id, String kind, String displayName, String profileName, int newGames, int achievements, int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) { } } diff --git a/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java b/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java index c9a958c..1e5a62a 100644 --- a/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java +++ b/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java @@ -5,11 +5,12 @@ import java.util.List; public record NoteDetailsDto(List results) { /** + * @param appid the game key as stored in the note; may be a merged key * @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST * @param itemName ACHIEVEMENT only: the achievement or trophy name * @param fromStatus STATUS only * @param toStatus STATUS only */ - public record Detail(String kind, String gameName, String itemName, String fromStatus, String toStatus) { + public record Detail(long appid, String kind, String gameName, String itemName, String fromStatus, String toStatus) { } } diff --git a/src/main/java/core_service/modules/inbox/domain/InboxNote.java b/src/main/java/core_service/modules/inbox/domain/InboxNote.java index d473fc3..4b14df2 100644 --- a/src/main/java/core_service/modules/inbox/domain/InboxNote.java +++ b/src/main/java/core_service/modules/inbox/domain/InboxNote.java @@ -4,8 +4,9 @@ import java.time.Instant; /** * @param subjectName the name of the followed user when the note was written + * @param profileName the current name of the subject while the profile is public, else null * @param details the note can be opened: it has detail rows and the subject is still followed and public */ -public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements, +public record InboxNote(long id, NoteKind kind, String subjectName, String profileName, int newGames, int achievements, int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) { } diff --git a/src/main/java/core_service/modules/inbox/domain/NoteDetail.java b/src/main/java/core_service/modules/inbox/domain/NoteDetail.java index 2458b0e..a079d64 100644 --- a/src/main/java/core_service/modules/inbox/domain/NoteDetail.java +++ b/src/main/java/core_service/modules/inbox/domain/NoteDetail.java @@ -3,5 +3,5 @@ package core_service.modules.inbox.domain; /** * One line behind a digest note. Names are snapshots from when the note was written. */ -public record NoteDetail(NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) { +public record NoteDetail(long appid, NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) { } diff --git a/src/main/java/core_service/modules/inbox/repo/InboxRepository.java b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java index 45f895d..b2eb8ce 100644 --- a/src/main/java/core_service/modules/inbox/repo/InboxRepository.java +++ b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java @@ -26,6 +26,10 @@ public class InboxRepository { return jdbc.query(""" select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds, n.created_at, n.read_at, + (select a.display_name from account_name a join public_user p on p.user_id = a.user_id + where a.user_id = n.subject_user_id + and not exists (select 1 from blocked_user b where b.user_id = a.user_id) + and not exists (select 1 from deleted_user d where d.user_id = a.user_id)) as profile_name, exists (select 1 from inbox_note_detail d where d.note_id = n.id) and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id) and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details @@ -33,7 +37,7 @@ public class InboxRepository { order by n.created_at desc, n.id desc limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit), (rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")), - rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"), + rs.getString("subject_name"), rs.getString("profile_name"), rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), rs.getInt("wishlist_adds"), rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null, rs.getBoolean("details"))); @@ -74,12 +78,12 @@ public class InboxRepository { */ public List findDetails(long noteId, long subjectUserId) { return jdbc.query(""" - select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d + select d.appid, d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d where d.note_id = :noteId and not exists (select 1 from user_game ug where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null) order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId), - (rs, i) -> new NoteDetail(NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"), + (rs, i) -> new NoteDetail(rs.getLong("appid"), NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"), rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status"))); } diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 9850471..4de859b 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -10,6 +10,7 @@ import core_service.modules.library.api.dto.AuditEntryDto; import core_service.modules.library.api.dto.BrokenImageReport; import core_service.modules.library.api.dto.CatalogGameDto; import core_service.modules.library.api.dto.CatalogGameDetailDto; +import core_service.modules.library.api.dto.CatalogPreviewDto; import core_service.modules.library.api.dto.CatalogHitDto; import core_service.modules.library.api.dto.CorrectionMarkDto; import core_service.modules.library.api.dto.GameDetailDto; @@ -39,6 +40,7 @@ import core_service.modules.library.domain.CatalogGameDetail; import core_service.modules.library.domain.CatalogHit; import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.PickSource; @@ -189,6 +191,14 @@ public class LibraryController { } } + @GetMapping("/games/{appid}/preview") + public ResponseEntity getGamePreview(@PathVariable("appid") Long appid) { + return gameDetailService.preview(appid) + .>map(p -> ResponseEntity.ok(new CatalogPreviewDto(p.appid(), p.name(), p.imageUrl(), + p.genres(), toDto(p.timeToBeat())))) + .orElseGet(LibraryController::unknownGame); + } + @GetMapping("/games/{appid}") public ResponseEntity getGameDetail(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("appid") Long appid, @@ -367,13 +377,16 @@ public class LibraryController { } private static CatalogGameDetailDto toDto(CatalogGameDetail d) { - var t = d.timeToBeat(); return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(), - d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), - t.getNormallyMinutes(), t.getCompletelyMinutes()), + d.genres(), toDto(d.timeToBeat()), d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()); } + private static GameDetailDto.TimeToBeatDto toDto(GameTimeToBeat t) { + return t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), + t.getCompletelyMinutes()); + } + private static CatalogHitDto toDto(CatalogHit hit) { return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms()); } diff --git a/src/main/java/core_service/modules/library/api/PublicProfileController.java b/src/main/java/core_service/modules/library/api/PublicProfileController.java index d947c9f..bdfd655 100644 --- a/src/main/java/core_service/modules/library/api/PublicProfileController.java +++ b/src/main/java/core_service/modules/library/api/PublicProfileController.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -38,6 +39,17 @@ public class PublicProfileController { .orElseGet(PublicProfileController::invalidTicket); } + @GetMapping("/library/games/{appid}") + public ResponseEntity getPublicGame(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket, + @PathVariable("appid") long appid) { + return verify(ticket) + .>map(t -> queryService.getPublicGame(t.userId(), t.sections(), appid) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("GAME_NOT_FOUND", "No such game on this profile")))) + .orElseGet(PublicProfileController::invalidTicket); + } + @GetMapping("/library/stats/games") public ResponseEntity getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket, @RequestParam StatKey key, diff --git a/src/main/java/core_service/modules/library/api/dto/CatalogPreviewDto.java b/src/main/java/core_service/modules/library/api/dto/CatalogPreviewDto.java new file mode 100644 index 0000000..6e48889 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/CatalogPreviewDto.java @@ -0,0 +1,13 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +/** + * A game the caller may not own: catalog data only, nothing per user. + * + * @param appid the kept key when the asked key was merged + * @param timeToBeat null when unknown + */ +public record CatalogPreviewDto(long appid, String name, String imageUrl, List genres, + GameDetailDto.TimeToBeatDto timeToBeat) { +} diff --git a/src/main/java/core_service/modules/library/api/dto/PublicGameDto.java b/src/main/java/core_service/modules/library/api/dto/PublicGameDto.java new file mode 100644 index 0000000..47cc093 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PublicGameDto.java @@ -0,0 +1,14 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.GameStatus; + +/** + * One game of a public profile. A field is null when a hidden section covers it or the value is unknown. + * + * @param status hidden with the STATUS, PLAYING and FINISHED sections + * @param playtimeMinutes hidden with the SUMMARY section, like the achievements + * @param achievementsUnlocked null when the game has no achievements, Steam hides them or none were fetched + */ +public record PublicGameDto(long appid, String name, String imageUrl, GameStatus status, Integer playtimeMinutes, + Integer achievementsUnlocked, Integer achievementsTotal, Double completionPercent) { +} diff --git a/src/main/java/core_service/modules/library/domain/CatalogPreview.java b/src/main/java/core_service/modules/library/domain/CatalogPreview.java new file mode 100644 index 0000000..03b41e7 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CatalogPreview.java @@ -0,0 +1,11 @@ +package core_service.modules.library.domain; + +import java.util.List; + +/** + * Catalog data of one game key, the same for every user. + * + * @param timeToBeat null when unknown + */ +public record CatalogPreview(long appid, String name, String imageUrl, List genres, GameTimeToBeat timeToBeat) { +} diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index aefb528..9ef44d6 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -264,6 +264,13 @@ public class GameMergeRepository { return targets; } + /** + * @return the key a merged key now lives under, else the key itself + */ + public long findTarget(long appid) { + return findTargets(List.of(appid)).getOrDefault(appid, appid); + } + /** * @return the saved copy of a merge, locked until the transaction ends; empty when there is none */ diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 46ce14f..3ff1a0d 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -9,6 +9,7 @@ import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.api.dto.PlatformPlayDto; import core_service.modules.library.domain.AchievementKind; import core_service.modules.library.domain.AchievementProgress; +import core_service.modules.library.domain.CatalogPreview; import core_service.modules.library.domain.ComingGame; import core_service.modules.library.domain.Edition; import core_service.modules.library.domain.GameAchievementSchema; @@ -125,6 +126,19 @@ public class GameDetailService { return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema(), refreshed.achievementsStale())); } + /** + * Catalog data of a key the caller may not own. No call to Steam or IGDB. + * + * @return empty when the catalog has no such game + */ + public Optional preview(long askedAppid) { + long appid = mergeRepository.findTarget(askedAppid); + return steamGameRepository.findById(appid).map(game -> new CatalogPreview(appid, game.getName(), + game.getImageUrl(), + gameMoodsRepository.findById(appid).map(GameMoods::getGenres).orElse(List.of()), + gameTimeToBeatRepository.findById(appid).orElse(null))); + } + /** * @param userGame the game after the refresh, re-read when the finish rule changed its status * @param achievementsStale a due Steam refresh was skipped diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 86d7af3..2c6c023 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -11,6 +11,7 @@ import core_service.modules.library.api.dto.PickedGameDto; import core_service.modules.library.api.dto.PickerCategoryDto; import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.PlatformDto; +import core_service.modules.library.api.dto.PublicGameDto; import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.WishlistDto; @@ -39,6 +40,7 @@ import core_service.modules.library.repo.FriendPlayRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameCreditRepository; import core_service.modules.library.repo.GameLinkRepository; +import core_service.modules.library.repo.GameMergeRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PsnTrophyRepository; @@ -108,6 +110,7 @@ public class LibraryQueryService { private final ReleaseDateService releaseDateService; private final SpeedrunService speedrunService; private final GameLinkRepository gameLinkRepository; + private final GameMergeRepository mergeRepository; private final GameCreditRepository gameCreditRepository; private final FriendPlayRepository friendPlayRepository; private final PsnTrophyRepository psnTrophyRepository; @@ -447,6 +450,64 @@ public class LibraryQueryService { shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); } + /** + * One listed game of a public profile. Loads the achievement rows of the whole library of the owner and keeps + * the one game. + * + * @param sectionNames see {@link ProfileSection#shown} + * @return empty when the owner does not have the game, removed or hid it, or every field is hidden: all the same + * answer, so the caller learns nothing about a hidden game + */ + @Public + public Optional getPublicGame(Long userId, Collection sectionNames, long askedAppid) { + Set sections = ProfileSection.shown(sectionNames); + boolean showStatus = sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.PLAYING) + || sections.contains(ProfileSection.FINISHED); + boolean showSummary = sections.contains(ProfileSection.SUMMARY); + if (!showStatus && !showSummary) { + return Optional.empty(); + } + return userGameRepository.findById(new UserGameId(userId, mergeRepository.findTarget(askedAppid))) + .filter(ug -> ug.getRemovedAt() == null && ug.getHiddenAt() == null) + .filter(ug -> showsEveryGame(sections) || listedBySection(sections, ug.getStatus())) + .map(userGame -> toPublicGame(userId, userGame, showStatus, showSummary)); + } + + /** STATUS and SUMMARY already expose the whole library (status lists, totals). */ + private static boolean showsEveryGame(Set sections) { + return sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.SUMMARY); + } + + /** Only PLAYING or FINISHED shown: a game is revealed only when a shown section already lists its status. */ + private static boolean listedBySection(Set sections, GameStatus status) { + return sections.contains(ProfileSection.PLAYING) && status == GameStatus.PLAYING + || sections.contains(ProfileSection.FINISHED) + && (status == GameStatus.FINISHED || status == GameStatus.COMPLETED); + } + + private PublicGameDto toPublicGame(Long userId, UserGame userGame, boolean showStatus, boolean showSummary) { + long appid = userGame.getAppid(); + SteamGame meta = steamGameRepository.findById(appid).orElse(null); + Integer playtime = showSummary + ? gameLinkRepository.entryPlaytimes(userId, List.of(appid)).getOrDefault(appid, userGame.getPlaytimeMinutes()) + : null; + AchievementProgress achievements = showSummary ? visibleAchievements(userId, appid) : null; + return new PublicGameDto(appid, meta == null ? null : meta.getName(), meta == null ? null : meta.getImageUrl(), + showStatus ? userGame.getStatus() : null, playtime, + achievements == null ? null : achievements.unlockedCapped(), + achievements == null ? null : achievements.total(), + achievements == null ? null : achievements.percent()); + } + + /** + * @return null when the game has no achievements or Steam hides them + */ + private AchievementProgress visibleAchievements(Long userId, long appid) { + return AchievementProgress.perGame(progress(userId).stream() + .filter(p -> p.appid() == appid && p.hasAchievements() && p.visible()).toList()) + .stream().findFirst().orElse(null); + } + /** * @return null for a hidden section */ diff --git a/src/test/java/core_service/modules/follow/api/FollowControllerTest.java b/src/test/java/core_service/modules/follow/api/FollowControllerTest.java index 5dc392b..8c5a66b 100644 --- a/src/test/java/core_service/modules/follow/api/FollowControllerTest.java +++ b/src/test/java/core_service/modules/follow/api/FollowControllerTest.java @@ -62,6 +62,7 @@ class FollowControllerTest extends AbstractIntegrationTest { @AfterEach void cleanUp() { jdbc.update("delete from follow"); + jdbc.update("delete from inbox_note"); jdbc.update("delete from follow_digest"); jdbc.update("delete from public_user"); jdbc.update("delete from account_name"); @@ -204,6 +205,38 @@ class FollowControllerTest extends AbstractIntegrationTest { assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET)); Set keys = new HashSet<>(); followers.get(0).propertyNames().forEach(keys::add); - assertThat(keys).containsExactlyInAnyOrder("id", "displayName"); + assertThat(keys).containsExactlyInAnyOrder("id", "displayName", "lastActivityAt"); + } + + private void digestNote(long readerId, long subjectId, String kind, int minutesAgo) { + jdbc.update("insert into inbox_note (user_id, kind, subject_user_id, subject_name, created_at) " + + "values (?, ?, ?, 'x', now() - make_interval(mins => ?))", readerId, kind, subjectId, minutesAgo); + } + + @Test + void following_LastActivityIsTheNewestDigestNoteOfTheCallerAboutThatUser() throws Exception { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?), (?, ?), (?, ?)", + VIEWER, LOBO, VIEWER, OTHER, OTHER, LOBO); + digestNote(VIEWER, LOBO, "DIGEST", 60); + digestNote(VIEWER, LOBO, "DIGEST", 10); + digestNote(VIEWER, LOBO, "VISIBILITY", 1); + digestNote(OTHER, LOBO, "DIGEST", 0); + java.time.Instant newest = jdbc.queryForObject("select max(created_at) from inbox_note where user_id = ? and kind = 'DIGEST'", + java.sql.Timestamp.class, VIEWER).toInstant(); + + JsonNode following = list(VIEWER).get("following"); + + JsonNode lobo = following.get(0).get("displayName").asString().equals("Lobo") ? following.get(0) : following.get(1); + JsonNode other = lobo == following.get(0) ? following.get(1) : following.get(0); + assertThat(java.time.Instant.parse(lobo.get("lastActivityAt").asString())).isEqualTo(newest); + assertThat(other.get("lastActivityAt").isNull()).isTrue(); + } + + @Test + void followers_HaveNoLastActivity() throws Exception { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", LOBO, VIEWER); + digestNote(VIEWER, LOBO, "DIGEST", 5); + + assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue(); } } diff --git a/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java b/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java index d835589..700009e 100644 --- a/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java +++ b/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java @@ -37,6 +37,7 @@ class InboxControllerTest extends AbstractIntegrationTest { jdbc.update("delete from inbox_note"); jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER); jdbc.update("delete from public_user where user_id = 7"); + jdbc.update("delete from account_name where user_id = 7"); jdbc.update("delete from user_game where user_id = 7"); jdbc.update("delete from steam_game where appid = 501"); } @@ -47,6 +48,7 @@ class InboxControllerTest extends AbstractIntegrationTest { long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class); jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER); jdbc.update("insert into public_user (user_id) values (7)"); + jdbc.update("insert into account_name (user_id, display_name) values (7, 'SubjectNow')"); jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values " + "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), " + "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), " @@ -82,6 +84,7 @@ class InboxControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.results[0].kind").value("DIGEST")) .andExpect(jsonPath("$.results[0].newGames").value(2)) .andExpect(jsonPath("$.results[0].read").value(false)) + .andExpect(jsonPath("$.results[0].profileName").doesNotExist()) .andExpect(jsonPath("$.results[1].displayName").value("Older")) .andExpect(jsonPath("$.unread").value(2)) .andReturn().getResponse().getContentAsString(); @@ -120,12 +123,15 @@ class InboxControllerTest extends AbstractIntegrationTest { String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk()) .andExpect(jsonPath("$.results.length()").value(3)) .andExpect(jsonPath("$.results[0].kind").value("NEW_GAME")) + .andExpect(jsonPath("$.results[0].appid").value(501)) + .andExpect(jsonPath("$.results[2].appid").value(502)) .andExpect(jsonPath("$.results[1].itemName").value("Win")) .andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG")) .andReturn().getResponse().getContentAsString(); - assertThat(body).doesNotContain("appid", "subject", "userId"); - mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true)); + assertThat(body).doesNotContain("subject", "userId"); + mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true)) + .andExpect(jsonPath("$.results[0].profileName").value("SubjectNow")); } @Test diff --git a/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java b/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java index 10027dd..03d3cf0 100644 --- a/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java +++ b/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java @@ -1,5 +1,6 @@ package core_service.modules.inbox.repo; +import core_service.modules.inbox.domain.NoteDetail; import core_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -71,4 +72,36 @@ class InboxRepositoryTest extends AbstractPersistenceTest { jdbc.update("delete from inbox_note_detail"); assertThat(repository.findNewest(1, 10).get(0).details()).isFalse(); } + + @Test + void findNewest_ProfileNameIsTheCurrentNameOfAPublicSubjectOnly() { + jdbc.update("delete from inbox_note"); + jdbc.update("delete from account_name where user_id = 2"); + jdbc.update("delete from public_user where user_id = 2"); + jdbc.update("delete from blocked_user where user_id = 2"); + jdbc.update("delete from deleted_user where user_id = 2"); + note(1, 1, false); + jdbc.update("insert into account_name (user_id, display_name) values (2, 'Renamed')"); + assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull(); + + jdbc.update("insert into public_user (user_id) values (2)"); + assertThat(repository.findNewest(1, 10).get(0).profileName()).isEqualTo("Renamed"); + + jdbc.update("insert into blocked_user (user_id) values (2)"); + assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull(); + + jdbc.update("delete from blocked_user where user_id = 2"); + jdbc.update("insert into deleted_user (user_id) values (2)"); + assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull(); + jdbc.update("delete from deleted_user where user_id = 2"); + } + + @Test + void findDetails_CarriesTheGameKey() { + jdbc.update("delete from inbox_note"); + note(1, 1, false); + jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)"); + + assertThat(repository.findDetails(1, 2)).extracting(NoteDetail::appid).containsExactly(5L); + } } diff --git a/src/test/java/core_service/modules/library/api/GamePreviewControllerTest.java b/src/test/java/core_service/modules/library/api/GamePreviewControllerTest.java new file mode 100644 index 0000000..855dac8 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/GamePreviewControllerTest.java @@ -0,0 +1,91 @@ +package core_service.modules.library.api; + +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.hamcrest.Matchers.contains; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class GamePreviewControllerTest extends AbstractIntegrationTest { + + private static final long CALLER = 6201L; + private static final long KEPT = -1_960_001L; + private static final long MERGED = -1_960_002L; + private static final long UNKNOWN = -1_960_003L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void cleanUp() { + jdbc.update("delete from game_alias where from_appid = ?", MERGED); + jdbc.update("delete from game_time_to_beat where appid = ?", KEPT); + jdbc.update("delete from game_mood where appid = ?", KEPT); + jdbc.update("delete from steam_game where appid in (?, ?)", KEPT, MERGED); + } + + private void seed() { + jdbc.update("insert into steam_game (appid, name, image_url) values (?, 'Hades', 'http://img/h.png')", KEPT); + jdbc.update("insert into game_mood (appid, moods, genres, checked_at) values (?, '{}', '{Roguelike,Action}', now())", KEPT); + jdbc.update("insert into game_time_to_beat (appid, normally_minutes, completely_minutes, fetched_at) " + + "values (?, 600, 1200, now())", KEPT); + } + + private ResultActions preview(long appid) throws Exception { + return mockMvc.perform(get("/api/library/games/" + appid + "/preview") + .header("Authorization", "Bearer " + ContractTokens.loginToken(CALLER))); + } + + @Test + void preview_OfAGameTheCallerDoesNotOwn_HasCatalogDataOnly() throws Exception { + seed(); + + preview(KEPT).andExpect(status().isOk()) + .andExpect(jsonPath("$.appid").value(KEPT)) + .andExpect(jsonPath("$.name").value("Hades")) + .andExpect(jsonPath("$.imageUrl").value("http://img/h.png")) + .andExpect(jsonPath("$.genres", contains("Roguelike", "Action"))) + .andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600)) + .andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1200)) + .andExpect(jsonPath("$.status").doesNotExist()); + } + + @Test + void preview_OfAMergedKey_AnswersWithTheKeptKey() throws Exception { + seed(); + jdbc.update("insert into steam_game (appid, name) values (?, 'Old Hades')", MERGED); + jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", MERGED, KEPT); + + preview(MERGED).andExpect(status().isOk()) + .andExpect(jsonPath("$.appid").value(KEPT)) + .andExpect(jsonPath("$.name").value("Hades")); + } + + @Test + void preview_OfAnUnknownKey_Is404() throws Exception { + preview(UNKNOWN).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + } + + @Test + void preview_WithoutLogin_Is401() throws Exception { + mockMvc.perform(get("/api/library/games/" + KEPT + "/preview")).andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index 4155230..589504d 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -416,4 +416,95 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("BAD_ARG")); } + + private ResultActions publicGame(String ticket, long appid) throws Exception { + return mockMvc.perform(get("/api/public/profiles/library/games/" + appid).header("X-Profile-Ticket", ticket)); + } + + private void achievementsOf220() { + schemaMetaRepository.save(new GameAchievementSchemaMeta(220L, 4, true, Instant.now())); + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300)); + } + + @Test + void publicGame_AllSectionsShown_HasStatusPlaytimeAndAchievements() throws Exception { + achievementsOf220(); + + publicGame(ticket(USER_ID), 220L).andExpect(status().isOk()) + .andExpect(jsonPath("$.appid").value(220)) + .andExpect(jsonPath("$.name").value("Half-Life 2")) + .andExpect(jsonPath("$.status").value("PLAYING")) + .andExpect(jsonPath("$.playtimeMinutes").value(300)) + .andExpect(jsonPath("$.achievementsUnlocked").value(1)) + .andExpect(jsonPath("$.achievementsTotal").value(4)) + .andExpect(jsonPath("$.completionPercent").value(25.0)) + .andExpect(jsonPath("$.notes").doesNotExist()) + .andExpect(jsonPath("$.rating").doesNotExist()); + } + + @Test + void publicGame_SummaryHidden_HasNoPlaytimeAndNoAchievementsButTheStatus() throws Exception { + achievementsOf220(); + + publicGame(ticket(USER_ID, List.of(), List.of("STATUS")), 220L).andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PLAYING")) + .andExpect(jsonPath("$.playtimeMinutes").value(nullValue())) + .andExpect(jsonPath("$.achievementsUnlocked").value(nullValue())) + .andExpect(jsonPath("$.achievementsTotal").value(nullValue())) + .andExpect(jsonPath("$.completionPercent").value(nullValue())); + } + + @Test + void publicGame_StatusSectionsHidden_HasNoStatusButThePlaytime() throws Exception { + publicGame(ticket(USER_ID, List.of(), List.of("SUMMARY")), 220L).andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(nullValue())) + .andExpect(jsonPath("$.playtimeMinutes").value(300)); + } + + @Test + void publicGame_OnlyStatsShown_Is404() throws Exception { + publicGame(ticket(USER_ID, List.of(), List.of("STATS")), 220L).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + } + + @Test + void publicGame_HiddenRemovedAndNotOwned_AnswerTheSame404() throws Exception { + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 700", USER_ID); + + String notOwned = publicGame(ticket(USER_ID), 12345L).andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + for (long appid : new long[]{700L, 800L, 99L}) { + String body = publicGame(ticket(USER_ID), appid).andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + org.junit.jupiter.api.Assertions.assertEquals(notOwned, body, "appid " + appid); + } + } + + @Test + void publicGame_OnlyPlayingShown_AGameThatIsNotPlayingIs404() throws Exception { + String notOwned = publicGame(ticket(USER_ID, List.of(), List.of("PLAYING")), 12345L) + .andExpect(status().isNotFound()).andReturn().getResponse().getContentAsString(); + + String completed = publicGame(ticket(USER_ID, List.of(), List.of("PLAYING")), 620L) + .andExpect(status().isNotFound()).andReturn().getResponse().getContentAsString(); + + assertEquals(notOwned, completed); + } + + @Test + void publicGame_SteamGameWithoutSchemaButWithPsnTrophies_Is200() throws Exception { + psnTrophyRepository.saveSet(USER_ID, 220L, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5", + 50, 0, 0, 0, 30, false, Instant.now())); + + publicGame(ticket(USER_ID), 220L).andExpect(status().isOk()) + .andExpect(jsonPath("$.achievementsUnlocked").value(30)) + .andExpect(jsonPath("$.achievementsTotal").value(50)); + } + + @Test + void publicGame_WithoutOrWithABadTicket_Is401() throws Exception { + mockMvc.perform(get("/api/public/profiles/library/games/220")).andExpect(status().isUnauthorized()); + publicGame("garbage", 220L).andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TICKET")); + } }