feat(stats): add owners endpoint to list users who own a game
Build, Test & Push Core Service / quality-check (push) Successful in 1m21s
Build, Test & Push Core Service / docker (push) Successful in 1m33s

This commit is contained in:
lobo committed 2026-10-06 13:19:58 +02:00
1 parent 7747bc7b8b
commit 2dba863c54
19 files changed
+478 -45

No files matched your search

+8 -7
View File
@@ -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",