Public Access
Feed notes get profileName and appid; follows get lastActivityAt.
New GET /api/library/games/{appid}/preview: catalog data, any game.
New GET /api/public/profiles/library/games/{appid}: one profile game.
This commit is contained in:
1 parent
ec66bd5ead
commit
d3c9c7558c
24 files changed
+454
-27
No files matched your search
@@ -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<br/>/internal/accounts"| Account
|
||||
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
|
||||
```
|
||||
|
||||
@@ -63,6 +63,6 @@ public class FollowController {
|
||||
}
|
||||
|
||||
private static List<FollowsDto.Person> toDto(List<FollowEntry> 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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.follow.api.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -10,7 +11,8 @@ public record FollowsDto(List<Person> following, List<Person> followers) {
|
||||
/**
|
||||
* @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) {
|
||||
}
|
||||
}
|
||||
@@ -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 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) {
|
||||
}
|
||||
@@ -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<FollowEntry> ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"),
|
||||
rs.getString("display_name"));
|
||||
private static final RowMapper<FollowEntry> 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<FollowEntry> 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<FollowEntry> 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
|
||||
|
||||
@@ -27,7 +27,7 @@ public class InboxController {
|
||||
public ResponseEntity<InboxDto> 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)
|
||||
.<ResponseEntity<?>>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)
|
||||
|
||||
@@ -11,9 +11,10 @@ public record InboxDto(List<Note> 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) {
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import java.util.List;
|
||||
public record NoteDetailsDto(List<Detail> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<NoteDetail> 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")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
.<ResponseEntity<?>>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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
.<ResponseEntity<?>>map(t -> queryService.getPublicGame(t.userId(), t.sections(), appid)
|
||||
.<ResponseEntity<?>>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,
|
||||
|
||||
@@ -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<String> genres,
|
||||
GameDetailDto.TimeToBeatDto timeToBeat) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<String> genres, GameTimeToBeat timeToBeat) {
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<CatalogPreview> 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
|
||||
|
||||
@@ -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<PublicGameDto> getPublicGame(Long userId, Collection<String> sectionNames, long askedAppid) {
|
||||
Set<ProfileSection> 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<ProfileSection> 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<ProfileSection> 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
|
||||
*/
|
||||
|
||||
@@ -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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user