Public Access
feat(stats): add owners endpoint to list users who own a game
This commit is contained in:
1 parent
7747bc7b8b
commit
2dba863c54
19 files changed
+478
-45
No files matched your search
@@ -205,9 +205,10 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
|
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
|
||||||
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | 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/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/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `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}]}`, `displayName` and `roleChangedAt` optional, `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 |
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed}]}`, `displayName`, `roleChangedAt` and `listed` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it), `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 |
|
||||||
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
@@ -254,9 +255,9 @@ flowchart LR
|
|||||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||||
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user"| Db
|
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
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, account_name, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Content |
|
| Module | Content |
|
||||||
@@ -264,13 +265,13 @@ flowchart LR
|
|||||||
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
||||||
| `stats` | Public numbers for the landing page |
|
| `stats` | Public numbers for the landing page |
|
||||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||||
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
||||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||||
|
|
||||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||||
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||||
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
||||||
@@ -448,12 +449,12 @@ gets no more syncs and leaves the landing stats and the pile snapshots at once.
|
|||||||
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
|
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
|
||||||
user ids, which removes the rows of users Auth does not know (refused for more than
|
user ids, which removes the rows of users Auth does not know (refused for more than
|
||||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||||
The purge also removes the user's `account_name` row and link requests (`game_link_request`).
|
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
|
||||||
|
|
||||||
### Blocked users and Steam ID changes
|
### Blocked users and Steam ID changes
|
||||||
|
|
||||||
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
||||||
took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
|
took by a verified link, a display name change, a block, an unblock and a change of the owner-list opt-in (`listed`, #26); its nightly job sends every user (`PUT`). A blocked user is in
|
||||||
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
|
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
|
||||||
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
|
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
|
||||||
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
|
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public class InternalAccountController {
|
|||||||
private static List<AccountState> toStates(AccountStatesRequest request) {
|
private static List<AccountState> toStates(AccountStatesRequest request) {
|
||||||
return request.accounts().stream()
|
return request.accounts().stream()
|
||||||
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
|
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
|
||||||
account.blocked(), account.roleChangedAt()))
|
account.blocked(), account.roleChangedAt(), account.listed()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> account
|
|||||||
/**
|
/**
|
||||||
* @param displayName null from an Auth that does not send it yet
|
* @param displayName null from an Auth that does not send it yet
|
||||||
* @param roleChangedAt null from an Auth that does not send it yet
|
* @param roleChangedAt null from an Auth that does not send it yet
|
||||||
|
* @param listed null from an Auth that does not send it yet
|
||||||
*/
|
*/
|
||||||
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
|
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
|
||||||
boolean blocked, Instant roleChangedAt) {
|
boolean blocked, Instant roleChangedAt, Boolean listed) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ import java.time.Instant;
|
|||||||
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
||||||
* @param displayName null when Auth did not send one; Core then keeps the stored name
|
* @param displayName null when Auth did not send one; Core then keeps the stored name
|
||||||
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
|
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
|
||||||
|
* @param listed null from an Auth that does not send it; Core then keeps the stored listing
|
||||||
*/
|
*/
|
||||||
public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) {
|
public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
|
||||||
|
Boolean listed) {
|
||||||
}
|
}
|
||||||
@@ -23,11 +23,11 @@ public class AccountCleanupRepository {
|
|||||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
|
||||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
|
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
|
||||||
"account_role_change", "blocked_user", "deleted_user");
|
"account_role_change", "blocked_user", "listed_user", "deleted_user");
|
||||||
|
|
||||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||||
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||||
"game_import", "blocked_user", "account_role_change", "deleted_user", "account_name");
|
"game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name");
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import java.util.Collection;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocks in blocked_user, role changes in account_role_change, the display name in account_name, and what a Steam ID
|
* Blocks in blocked_user, listing in listed_user, role changes in account_role_change, the display name in
|
||||||
* change means for user_steam_sync.
|
* account_name, and what a Steam ID change means for user_steam_sync.
|
||||||
* Plain SQL, because the library module owns the entities.
|
* Plain SQL, because the library module owns the entities.
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@@ -41,6 +41,11 @@ public class AccountStateRepository {
|
|||||||
} else {
|
} else {
|
||||||
jdbc.update("delete from blocked_user where user_id = :userId", params);
|
jdbc.update("delete from blocked_user where user_id = :userId", params);
|
||||||
}
|
}
|
||||||
|
if (Boolean.TRUE.equals(state.listed())) {
|
||||||
|
jdbc.update("insert into listed_user (user_id) values (:userId) on conflict do nothing", params);
|
||||||
|
} else if (Boolean.FALSE.equals(state.listed())) {
|
||||||
|
jdbc.update("delete from listed_user where user_id = :userId", params);
|
||||||
|
}
|
||||||
if (state.blocked() || state.steamId() == null) {
|
if (state.blocked() || state.steamId() == null) {
|
||||||
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
|
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
|
||||||
params);
|
params);
|
||||||
@@ -65,7 +70,8 @@ public class AccountStateRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After this, exactly the blocked users of the list are blocked.
|
* After this, exactly the blocked users of the list are blocked, and the listed ones listed unless a state has no
|
||||||
|
* listing.
|
||||||
*/
|
*/
|
||||||
public void replaceAll(Collection<AccountState> states) {
|
public void replaceAll(Collection<AccountState> states) {
|
||||||
states.forEach(this::apply);
|
states.forEach(this::apply);
|
||||||
@@ -75,5 +81,13 @@ public class AccountStateRepository {
|
|||||||
} else {
|
} else {
|
||||||
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
|
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
|
||||||
}
|
}
|
||||||
|
if (states.stream().noneMatch(state -> state.listed() == null)) {
|
||||||
|
List<Long> listed = states.stream().filter(AccountState::listed).map(AccountState::userId).toList();
|
||||||
|
if (listed.isEmpty()) {
|
||||||
|
jdbc.update("delete from listed_user", new MapSqlParameterSource());
|
||||||
|
} else {
|
||||||
|
jdbc.update("delete from listed_user where user_id not in (:ids)", new MapSqlParameterSource("ids", listed));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package core_service.modules.stats.api;
|
||||||
|
|
||||||
|
import core_service.modules.stats.api.dto.PeopleDto;
|
||||||
|
import core_service.modules.stats.domain.CommunityKey;
|
||||||
|
import core_service.modules.stats.domain.OwnerList;
|
||||||
|
import core_service.modules.stats.service.OwnerListService;
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
|
import core_service.shared.security.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OwnerListController {
|
||||||
|
|
||||||
|
private final OwnerListService ownerListService;
|
||||||
|
|
||||||
|
@GetMapping("/owners")
|
||||||
|
public ResponseEntity<?> owners(@AuthenticationPrincipal UserPrincipal principal, @RequestParam long appid,
|
||||||
|
@RequestParam(defaultValue = "OWNED") CommunityKey key,
|
||||||
|
@RequestParam(required = false) String arg,
|
||||||
|
@RequestParam(defaultValue = "0") int page) {
|
||||||
|
return key.parseArg(arg)
|
||||||
|
.<ResponseEntity<?>>map(parsed -> ResponseEntity.ok(toDto(
|
||||||
|
ownerListService.owners(principal.getIdAsLong(), appid, key, parsed, page))))
|
||||||
|
.orElseGet(() -> ResponseEntity.badRequest()
|
||||||
|
.body(new ErrorResponse("BAD_ARG", "The argument does not fit the key")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PeopleDto toDto(OwnerList owners) {
|
||||||
|
return new PeopleDto(owners.listedNames().stream().map(PeopleDto.Person::new).toList(), owners.listedTotal(),
|
||||||
|
owners.privateCount(), owners.includesYou(), owners.page(), owners.totalPages());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package core_service.modules.stats.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who stands behind a number. A listed user is a display name and nothing else, never an id or an avatar (#26).
|
||||||
|
* The viewer is in neither count, only in {@code includesYou}.
|
||||||
|
*/
|
||||||
|
public record PeopleDto(List<Person> listed, long listedTotal, long privateCount, boolean includesYou, int page,
|
||||||
|
int totalPages) {
|
||||||
|
|
||||||
|
public record Person(String displayName) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package core_service.modules.stats.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the listed owners of a game. The viewer is in neither count.
|
||||||
|
*/
|
||||||
|
public record OwnerList(List<String> listedNames, long listedTotal, long privateCount, boolean includesYou, int page,
|
||||||
|
int totalPages) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package core_service.modules.stats.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param others the owners without the viewer
|
||||||
|
* @param listed how many of {@code others} are shown by name
|
||||||
|
*/
|
||||||
|
public record PeopleCounts(long others, long listed, boolean includesYou) {
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.stats.repo;
|
package core_service.modules.stats.repo;
|
||||||
|
|
||||||
import core_service.modules.stats.domain.CommunityKey;
|
import core_service.modules.stats.domain.CommunityKey;
|
||||||
|
import core_service.modules.stats.domain.PeopleCounts;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -20,24 +21,24 @@ public class StatsRepository {
|
|||||||
* behind it read the same rows.
|
* behind it read the same rows.
|
||||||
*/
|
*/
|
||||||
private static final String ENTRIES_SET =
|
private static final String ENTRIES_SET =
|
||||||
"SELECT appid, playtime_minutes FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
|
"SELECT user_id, appid, playtime_minutes FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
|
||||||
private static final String PLAYED_SET = ENTRIES_SET + " AND playtime_minutes > 0";
|
private static final String PLAYED_SET = ENTRIES_SET + " AND playtime_minutes > 0";
|
||||||
private static final String OWNED_SET =
|
private static final String OWNED_SET =
|
||||||
"SELECT appid, playtime_minutes FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
|
"SELECT user_id, appid, playtime_minutes FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
|
||||||
private static final String PLATFORM_SET = """
|
private static final String PLATFORM_SET = """
|
||||||
SELECT appid, playtime_minutes,
|
SELECT user_id, appid, playtime_minutes,
|
||||||
CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name
|
CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name
|
||||||
FROM listed_user_game WHERE %s
|
FROM listed_user_game WHERE %s
|
||||||
""".formatted(NOT_DELETED_OR_BLOCKED);
|
""".formatted(NOT_DELETED_OR_BLOCKED);
|
||||||
private static final String GENRE_SET = """
|
private static final String GENRE_SET = """
|
||||||
SELECT ug.appid, ug.playtime_minutes, tag.name AS name
|
SELECT ug.user_id, ug.appid, ug.playtime_minutes, tag.name AS name
|
||||||
FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid
|
FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid
|
||||||
CROSS JOIN UNNEST(m.genres) AS tag(name)
|
CROSS JOIN UNNEST(m.genres) AS tag(name)
|
||||||
WHERE %s
|
WHERE %s
|
||||||
""".formatted(NOT_DELETED_OR_BLOCKED);
|
""".formatted(NOT_DELETED_OR_BLOCKED);
|
||||||
/** Has one {@code ?}: the time zone. */
|
/** Has one {@code ?}: the time zone. */
|
||||||
private static final String FINISHED_SET = """
|
private static final String FINISHED_SET = """
|
||||||
SELECT ug.appid, ug.playtime_minutes,
|
SELECT ug.user_id, ug.appid, ug.playtime_minutes,
|
||||||
CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
|
CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
|
||||||
AS INTEGER) AS year
|
AS INTEGER) AS year
|
||||||
FROM counted_user_game ug
|
FROM counted_user_game ug
|
||||||
@@ -57,10 +58,10 @@ public class StatsRepository {
|
|||||||
CommunityKey.PLAYED, PLAYED_SET,
|
CommunityKey.PLAYED, PLAYED_SET,
|
||||||
CommunityKey.OWNED, OWNED_SET,
|
CommunityKey.OWNED, OWNED_SET,
|
||||||
CommunityKey.MOST_PLAYED, OWNED_SET,
|
CommunityKey.MOST_PLAYED, OWNED_SET,
|
||||||
CommunityKey.PLATFORM, "SELECT appid, playtime_minutes FROM (" + NAMED_PLATFORM
|
CommunityKey.PLATFORM, "SELECT user_id, appid, playtime_minutes FROM (" + NAMED_PLATFORM
|
||||||
+ ") g WHERE LOWER(g.name) = LOWER(TRIM(?))",
|
+ ") g WHERE LOWER(g.name) = LOWER(TRIM(?))",
|
||||||
CommunityKey.GENRE, "SELECT appid, playtime_minutes FROM (" + GENRE_SET + ") g WHERE g.name = ?",
|
CommunityKey.GENRE, "SELECT user_id, appid, playtime_minutes FROM (" + GENRE_SET + ") g WHERE g.name = ?",
|
||||||
CommunityKey.FINISHED_YEAR, "SELECT appid, playtime_minutes FROM (" + FINISHED_SET + ") g WHERE g.year = ?");
|
CommunityKey.FINISHED_YEAR, "SELECT user_id, appid, playtime_minutes FROM (" + FINISHED_SET + ") g WHERE g.year = ?");
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -203,6 +204,43 @@ public class StatsRepository {
|
|||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long keptAppid(long appid) {
|
||||||
|
Long kept = jdbc.queryForObject(
|
||||||
|
"SELECT COALESCE((SELECT to_appid FROM game_alias WHERE from_appid = ?), ?)", Long.class, appid, appid);
|
||||||
|
return kept != null ? kept : appid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owners of a game in the set behind one community number, without the viewer. Listed means a listed_user
|
||||||
|
* row and a stored name; everyone else is private.
|
||||||
|
*/
|
||||||
|
public PeopleCounts countOwners(CommunityKey key, Object arg, String zone, long appid, long viewerId) {
|
||||||
|
List<Object> params = new ArrayList<>(List.of(viewerId, viewerId, viewerId));
|
||||||
|
params.addAll(communityParams(key, arg, zone));
|
||||||
|
params.add(appid);
|
||||||
|
return jdbc.queryForObject("""
|
||||||
|
SELECT COUNT(*) FILTER (WHERE o.user_id <> ?) AS others,
|
||||||
|
COUNT(*) FILTER (WHERE o.user_id <> ? AND l.user_id IS NOT NULL AND n.user_id IS NOT NULL) AS listed,
|
||||||
|
COALESCE(BOOL_OR(o.user_id = ?), FALSE) AS you
|
||||||
|
FROM (SELECT DISTINCT user_id FROM (%s) s WHERE s.appid = ?) o
|
||||||
|
LEFT JOIN listed_user l ON l.user_id = o.user_id
|
||||||
|
LEFT JOIN account_name n ON n.user_id = o.user_id
|
||||||
|
""".formatted(COMMUNITY_SETS.get(key)),
|
||||||
|
(rs, rowNum) -> new PeopleCounts(rs.getLong("others"), rs.getLong("listed"), rs.getBoolean("you")),
|
||||||
|
params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> listedOwnerNames(CommunityKey key, Object arg, String zone, long appid, long viewerId,
|
||||||
|
int offset, int limit) {
|
||||||
|
List<Object> params = new ArrayList<>(communityParams(key, arg, zone));
|
||||||
|
params.addAll(List.of(appid, viewerId, limit, offset));
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT n.display_name FROM (SELECT DISTINCT user_id FROM (%s) s WHERE s.appid = ?) o
|
||||||
|
JOIN listed_user l ON l.user_id = o.user_id JOIN account_name n ON n.user_id = o.user_id
|
||||||
|
WHERE o.user_id <> ? ORDER BY LOWER(n.display_name), o.user_id LIMIT ? OFFSET ?
|
||||||
|
""".formatted(COMMUNITY_SETS.get(key)), String.class, params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
/** In the order of the {@code ?} of the set: FINISHED_SET has the time zone first. */
|
/** In the order of the {@code ?} of the set: FINISHED_SET has the time zone first. */
|
||||||
private static List<Object> communityParams(CommunityKey key, Object arg, String zone) {
|
private static List<Object> communityParams(CommunityKey key, Object arg, String zone) {
|
||||||
if (key == CommunityKey.FINISHED_YEAR) {
|
if (key == CommunityKey.FINISHED_YEAR) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package core_service.modules.stats.service;
|
||||||
|
|
||||||
|
import core_service.modules.stats.domain.CommunityKey;
|
||||||
|
import core_service.modules.stats.domain.OwnerList;
|
||||||
|
import core_service.modules.stats.domain.PeopleCounts;
|
||||||
|
import core_service.modules.stats.repo.StatsRepository;
|
||||||
|
import core_service.shared.security.RequiresRole;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owners of a game in the set behind a community number. Not cached, so an opt-out shows on the next load.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OwnerListService {
|
||||||
|
|
||||||
|
private final StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
|
private ZoneId zone;
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public OwnerList owners(long viewerId, long appid, CommunityKey key, Object arg, int page) {
|
||||||
|
long kept = statsRepository.keptAppid(appid);
|
||||||
|
int clamped = Math.clamp(page, 0, PublicStatsService.MAX_PAGE);
|
||||||
|
PeopleCounts counts = statsRepository.countOwners(key, arg, zone.getId(), kept, viewerId);
|
||||||
|
List<String> listed = statsRepository.listedOwnerNames(key, arg, zone.getId(), kept, viewerId,
|
||||||
|
clamped * PublicStatsService.PAGE_SIZE, PublicStatsService.PAGE_SIZE);
|
||||||
|
int totalPages = (int) ((counts.listed() + PublicStatsService.PAGE_SIZE - 1) / PublicStatsService.PAGE_SIZE);
|
||||||
|
return new OwnerList(listed, counts.listed(), counts.others() - counts.listed(), counts.includesYou(), clamped,
|
||||||
|
totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Users Auth lists by name in owner and user lists (#26). Pushed with the account state like blocked_user:
|
||||||
|
-- a row means listed, no row means private. Goes with the account.
|
||||||
|
CREATE TABLE listed_user (user_id BIGINT PRIMARY KEY);
|
||||||
@@ -46,6 +46,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
||||||
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
|
||||||
|
jdbc.update("delete from listed_user where user_id in (?, 42)", USER_ID);
|
||||||
jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID);
|
||||||
jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID);
|
||||||
}
|
}
|
||||||
@@ -134,6 +135,16 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stateCallWithListed_ListsTheUser() throws Exception {
|
||||||
|
JsonNode call = ContractTokens.internalCall("applyStates");
|
||||||
|
|
||||||
|
send(contractRequest(call), call.path("request").toString(), ContractTokens.serviceToken())
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select user_id from listed_user where user_id = 42", Long.class)).containsExactly(42L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception {
|
void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception {
|
||||||
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}",
|
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
|
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
|
||||||
"reward_equipped");
|
"reward_equipped", "listed_user");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AccountCleanupRepository repository;
|
private AccountCleanupRepository repository;
|
||||||
@@ -50,6 +50,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
jdbc.update("insert into game_merge_undo_user (merge_id, user_id, combined) values (1, ?, false)", userId);
|
jdbc.update("insert into game_merge_undo_user (merge_id, user_id, combined) values (1, ?, false)", userId);
|
||||||
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
|
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
|
||||||
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
|
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
|
||||||
|
jdbc.update("insert into listed_user (user_id) values (?)", userId);
|
||||||
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
||||||
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
|
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
|
||||||
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
|
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
jdbc.update("delete from blocked_user");
|
jdbc.update("delete from blocked_user");
|
||||||
|
jdbc.update("delete from listed_user");
|
||||||
jdbc.update("delete from account_name");
|
jdbc.update("delete from account_name");
|
||||||
jdbc.update("delete from account_role_change");
|
jdbc.update("delete from account_role_change");
|
||||||
for (long userId : List.of(1L, 2L)) {
|
for (long userId : List.of(1L, 2L)) {
|
||||||
@@ -50,24 +51,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
|
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null, null));
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
|
||||||
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo");
|
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo");
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null, null));
|
||||||
|
|
||||||
assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
|
assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
|
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null));
|
||||||
|
|
||||||
assertThat(blocked()).containsExactly(1L);
|
assertThat(blocked()).containsExactly(1L);
|
||||||
assertThat(sync(1L).get("user_seen_at")).isNull();
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
assertThat(sync(2L).get("user_seen_at")).isNotNull();
|
assertThat(sync(2L).get("user_seen_at")).isNotNull();
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
|
||||||
|
|
||||||
assertThat(blocked()).isEmpty();
|
assertThat(blocked()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
|
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
|
||||||
@Test
|
@Test
|
||||||
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
|
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
|
||||||
repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null));
|
repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null, null));
|
||||||
|
|
||||||
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
||||||
assertThat(sync(1L).get("last_synced_at")).isNull();
|
assertThat(sync(1L).get("last_synced_at")).isNull();
|
||||||
@@ -84,14 +85,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sameSteamId_KeepsTheLastSync() {
|
void sameSteamId_KeepsTheLastSync() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
|
||||||
|
|
||||||
assertThat(sync(1L).get("last_synced_at")).isNotNull();
|
assertThat(sync(1L).get("last_synced_at")).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noSteamId_StopsTheNightlySync() {
|
void noSteamId_StopsTheNightlySync() {
|
||||||
repository.apply(new AccountState(1L, null, null, false, null));
|
repository.apply(new AccountState(1L, null, null, false, null, null));
|
||||||
|
|
||||||
assertThat(sync(1L).get("user_seen_at")).isNull();
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
||||||
@@ -99,7 +100,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userWithoutASyncRow_GetsOnlyTheBlock() {
|
void userWithoutASyncRow_GetsOnlyTheBlock() {
|
||||||
repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null));
|
repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null, null));
|
||||||
|
|
||||||
assertThat(blocked()).containsExactly(3L);
|
assertThat(blocked()).containsExactly(3L);
|
||||||
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
|
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
|
||||||
@@ -107,10 +108,10 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
|
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null));
|
||||||
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null));
|
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null, null));
|
||||||
|
|
||||||
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null)));
|
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null, null)));
|
||||||
assertThat(blocked()).containsExactly(2L);
|
assertThat(blocked()).containsExactly(2L);
|
||||||
|
|
||||||
repository.replaceAll(List.of());
|
repository.replaceAll(List.of());
|
||||||
@@ -127,13 +128,13 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
Instant first = Instant.parse("2026-10-04T12:00:00Z");
|
Instant first = Instant.parse("2026-10-04T12:00:00Z");
|
||||||
Instant second = first.plusSeconds(60);
|
Instant second = first.plusSeconds(60);
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null));
|
||||||
assertThat(changedAt(1L)).isEqualTo(first);
|
assertThat(changedAt(1L)).isEqualTo(first);
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
|
||||||
assertThat(changedAt(1L)).isEqualTo(first);
|
assertThat(changedAt(1L)).isEqualTo(first);
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null));
|
||||||
assertThat(changedAt(1L)).isEqualTo(second);
|
assertThat(changedAt(1L)).isEqualTo(second);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +143,8 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
Instant first = Instant.parse("2026-10-04T12:00:01Z");
|
Instant first = Instant.parse("2026-10-04T12:00:01Z");
|
||||||
Instant second = first.plusSeconds(1);
|
Instant second = first.plusSeconds(1);
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null));
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null));
|
||||||
|
|
||||||
assertThat(changedAt(1L)).isEqualTo(second);
|
assertThat(changedAt(1L)).isEqualTo(second);
|
||||||
}
|
}
|
||||||
@@ -151,11 +152,47 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Test
|
@Test
|
||||||
void findLoginState_ReadsTheBlockAndTheRoleChange() {
|
void findLoginState_ReadsTheBlockAndTheRoleChange() {
|
||||||
Instant change = Instant.parse("2026-10-04T12:00:00Z");
|
Instant change = Instant.parse("2026-10-04T12:00:00Z");
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change, null));
|
||||||
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null));
|
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, null));
|
||||||
|
|
||||||
assertThat(repository.findLoginState(1L)).isEqualTo(new LoginState(true, change));
|
assertThat(repository.findLoginState(1L)).isEqualTo(new LoginState(true, change));
|
||||||
assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE);
|
assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE);
|
||||||
assertThat(repository.findLoginState(99L)).isEqualTo(LoginState.NONE);
|
assertThat(repository.findLoginState(99L)).isEqualTo(LoginState.NONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Long> listed() {
|
||||||
|
return jdbc.queryForList("select user_id from listed_user order by user_id", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listed_TrueInsertsFalseDeletesNullKeeps() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true));
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
|
||||||
|
assertThat(listed()).containsExactly(1L);
|
||||||
|
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, false));
|
||||||
|
assertThat(listed()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceAll_ListsExactlyTheListedUsers() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true));
|
||||||
|
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, true));
|
||||||
|
|
||||||
|
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, true),
|
||||||
|
new AccountState(3L, OLD_STEAM_ID, null, false, null, false)));
|
||||||
|
assertThat(listed()).containsExactly(2L);
|
||||||
|
|
||||||
|
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, false)));
|
||||||
|
assertThat(listed()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceAll_WithAStateWithoutListing_KeepsTheListing() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true));
|
||||||
|
|
||||||
|
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, null)));
|
||||||
|
|
||||||
|
assertThat(listed()).containsExactly(1L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package core_service.modules.stats.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
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 tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real (non-dev) filter chain, so the login requirement is the production one.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles(value = "prod", inheritProfiles = false)
|
||||||
|
class OwnerListControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long VIEWER = 1L;
|
||||||
|
private static final long SECRET = 2L;
|
||||||
|
private static final long LOBO = 3L;
|
||||||
|
private static final long BLOCKED = 4L;
|
||||||
|
private static final long DELETED = 5L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
cleanUp();
|
||||||
|
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now())));
|
||||||
|
for (long userId : List.of(SECRET, LOBO, BLOCKED, DELETED)) {
|
||||||
|
userGameRepository.saveAndFlush(new UserGame(userId, 220L, 100, Instant.now(), Instant.now()));
|
||||||
|
}
|
||||||
|
name(SECRET, "SecretTwo");
|
||||||
|
name(LOBO, "Lobo");
|
||||||
|
name(BLOCKED, "BlockedFour");
|
||||||
|
name(DELETED, "DeletedFive");
|
||||||
|
jdbc.update("insert into listed_user (user_id) values (?), (?), (?)", LOBO, BLOCKED, DELETED);
|
||||||
|
jdbc.update("insert into blocked_user (user_id) values (?)", BLOCKED);
|
||||||
|
jdbc.update("insert into deleted_user (user_id) values (?)", DELETED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanUp() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
jdbc.update("delete from listed_user");
|
||||||
|
jdbc.update("delete from account_name");
|
||||||
|
jdbc.update("delete from blocked_user");
|
||||||
|
jdbc.update("delete from deleted_user");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void name(long userId, String displayName) {
|
||||||
|
jdbc.update("insert into account_name (user_id, display_name) values (?, ?)", userId, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String owners(String... params) throws Exception {
|
||||||
|
var request = get("/api/stats/owners").header("Authorization", "Bearer " + ContractTokens.loginToken(VIEWER));
|
||||||
|
for (int i = 0; i < params.length; i += 2) {
|
||||||
|
request.param(params[i], params[i + 1]);
|
||||||
|
}
|
||||||
|
return mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutLogin_Is401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/stats/owners").param("appid", "220")).andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privateUser_NeverLeaksByNameOrIdAndOnlyCounts() throws Exception {
|
||||||
|
String body = owners("appid", "220");
|
||||||
|
JsonNode json = JsonMapper.builder().build().readTree(body);
|
||||||
|
|
||||||
|
assertThat(body).doesNotContain("SecretTwo", "BlockedFour", "DeletedFive");
|
||||||
|
assertThat(json.get("listed")).hasSize(1);
|
||||||
|
Set<String> keys = new HashSet<>();
|
||||||
|
json.get("listed").get(0).propertyNames().forEach(keys::add);
|
||||||
|
assertThat(keys).containsExactly("displayName");
|
||||||
|
assertThat(json.get("listed").get(0).get("displayName").asString()).isEqualTo("Lobo");
|
||||||
|
assertThat(json.get("listedTotal").asLong()).isEqualTo(1);
|
||||||
|
assertThat(json.get("privateCount").asLong()).isEqualTo(1);
|
||||||
|
assertThat(json.get("includesYou").asBoolean()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void viewerWhoOwnsTheGameAndIsListed_IsOnlyIncludesYou() throws Exception {
|
||||||
|
userGameRepository.saveAndFlush(new UserGame(VIEWER, 220L, 100, Instant.now(), Instant.now()));
|
||||||
|
name(VIEWER, "Viewer");
|
||||||
|
jdbc.update("insert into listed_user (user_id) values (?)", VIEWER);
|
||||||
|
|
||||||
|
String body = owners("appid", "220");
|
||||||
|
|
||||||
|
assertThat(body).doesNotContain("Viewer");
|
||||||
|
mockMvc.perform(get("/api/stats/owners").param("appid", "220")
|
||||||
|
.header("Authorization", "Bearer " + ContractTokens.loginToken(VIEWER)))
|
||||||
|
.andExpect(jsonPath("$.includesYou").value(true))
|
||||||
|
.andExpect(jsonPath("$.listedTotal").value(1))
|
||||||
|
.andExpect(jsonPath("$.privateCount").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void badArg_Is400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/stats/owners").param("appid", "220").param("key", "GENRE")
|
||||||
|
.header("Authorization", "Bearer " + ContractTokens.loginToken(VIEWER)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BAD_ARG"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import core_service.modules.library.repo.SteamGameRepository;
|
|||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.modules.stats.api.dto.PublicStatsDto;
|
import core_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
import core_service.modules.stats.domain.CommunityKey;
|
import core_service.modules.stats.domain.CommunityKey;
|
||||||
|
import core_service.modules.stats.domain.PeopleCounts;
|
||||||
import core_service.modules.stats.service.PublicStatsService;
|
import core_service.modules.stats.service.PublicStatsService;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -251,4 +252,79 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
assertEquals(1, statsRepository.biggestBacklog());
|
assertEquals(1, statsRepository.biggestBacklog());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void owner(long userId, long appid, String name, boolean listed) {
|
||||||
|
userGameRepository.saveAndFlush(new UserGame(userId, appid, 10, Instant.now(), Instant.now()));
|
||||||
|
if (name != null) {
|
||||||
|
jdbc.update("insert into account_name (user_id, display_name) values (?, ?)", userId, name);
|
||||||
|
}
|
||||||
|
if (listed) {
|
||||||
|
jdbc.update("insert into listed_user (user_id) values (?)", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countOwners_CountsHiddenGameOwnersAsNoOwnersAndListedWithoutNameAsPrivate() {
|
||||||
|
owner(10L, 220L, "Ann", true);
|
||||||
|
owner(11L, 220L, null, true);
|
||||||
|
owner(12L, 220L, "Bob", false);
|
||||||
|
|
||||||
|
PeopleCounts counts = statsRepository.countOwners(CommunityKey.OWNED, "", "Europe/Berlin", 220L, 99L);
|
||||||
|
|
||||||
|
// user 1 (seeded) + 10 + 11 + 12; user 2 owns only the hidden game 1000
|
||||||
|
assertEquals(4, counts.others());
|
||||||
|
assertEquals(1, counts.listed());
|
||||||
|
assertFalse(counts.includesYou());
|
||||||
|
assertEquals(0, statsRepository.countOwners(CommunityKey.OWNED, "", "Europe/Berlin", 1000L, 99L).others());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countOwners_ViewerIsOutOfTheCountsAndFlagged() {
|
||||||
|
owner(10L, 220L, "Ann", true);
|
||||||
|
|
||||||
|
PeopleCounts counts = statsRepository.countOwners(CommunityKey.OWNED, "", "Europe/Berlin", 220L, 10L);
|
||||||
|
|
||||||
|
assertEquals(1, counts.others());
|
||||||
|
assertEquals(0, counts.listed());
|
||||||
|
assertEquals(true, counts.includesYou());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keptAppid_ResolvesAnOldAppidThroughTheAlias() {
|
||||||
|
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(-5L, "Old", null, Instant.now())));
|
||||||
|
jdbc.update("insert into game_alias (from_appid, to_appid) values (-5, 220)");
|
||||||
|
|
||||||
|
assertEquals(220L, statsRepository.keptAppid(-5L));
|
||||||
|
assertEquals(220L, statsRepository.keptAppid(220L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void owners_MatchTheLibrariesOfTheCommunityRow() {
|
||||||
|
owner(10L, 220L, "Ann", true);
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED', finished_at_estimate = now() WHERE user_id = 10");
|
||||||
|
for (CommunityKey key : List.of(CommunityKey.OWNED, CommunityKey.FINISHED_YEAR)) {
|
||||||
|
Object arg = key == CommunityKey.OWNED ? "" : LocalDate.now().getYear();
|
||||||
|
long libraries = ((Number) statsRepository.communityGames(key, arg, "Europe/Berlin", 0, 100).stream()
|
||||||
|
.filter(row -> ((Number) row.get("appid")).longValue() == 220L).findFirst().orElseThrow()
|
||||||
|
.get("libraries")).longValue();
|
||||||
|
|
||||||
|
PeopleCounts counts = statsRepository.countOwners(key, arg, "Europe/Berlin", 220L, 1L);
|
||||||
|
|
||||||
|
assertEquals(libraries, counts.others() + (counts.includesYou() ? 1 : 0), key.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listedOwnerNames_PagesSortedByName() {
|
||||||
|
for (int i = 0; i < 41; i++) {
|
||||||
|
owner(100L + i, 220L, String.format("user%02d", 40 - i), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> first = statsRepository.listedOwnerNames(CommunityKey.OWNED, "", "Europe/Berlin", 220L, 99L, 0, 40);
|
||||||
|
List<String> second = statsRepository.listedOwnerNames(CommunityKey.OWNED, "", "Europe/Berlin", 220L, 99L, 40, 40);
|
||||||
|
|
||||||
|
assertEquals("user00", first.get(0));
|
||||||
|
assertEquals(40, first.size());
|
||||||
|
assertEquals(List.of("user40"), second);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -37,12 +37,12 @@
|
|||||||
"applyStates": {
|
"applyStates": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/internal/accounts/states",
|
"path": "/internal/accounts/states",
|
||||||
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]}
|
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true}]}
|
||||||
},
|
},
|
||||||
"replaceStates": {
|
"replaceStates": {
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/internal/accounts/states",
|
"path": "/internal/accounts/states",
|
||||||
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]}
|
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false}]}
|
||||||
},
|
},
|
||||||
"purge": {
|
"purge": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
|||||||
Reference in new issue
Block a user