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"));
+ }
}