diff --git a/readme.md b/readme.md index 9793950..bbbc692 100644 --- a/readme.md +++ b/readme.md @@ -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/rewards` (`{equipped}`, the worn looks) | profile ticket | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | +| `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | | `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 | | `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"] Speedrun --> SpeedrunApi["speedrun.com API"] 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
/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 | @@ -264,13 +265,13 @@ flowchart LR | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `stats` | Public numbers for the landing page | | `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 | | `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 | **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 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. @@ -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 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. -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 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 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 diff --git a/src/main/java/core_service/modules/account/api/InternalAccountController.java b/src/main/java/core_service/modules/account/api/InternalAccountController.java index 7a8ba4e..5b75568 100644 --- a/src/main/java/core_service/modules/account/api/InternalAccountController.java +++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java @@ -65,7 +65,7 @@ public class InternalAccountController { private static List toStates(AccountStatesRequest request) { return request.accounts().stream() .map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), - account.blocked(), account.roleChangedAt())) + account.blocked(), account.roleChangedAt(), account.listed())) .toList(); } diff --git a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java index b152ce2..44e01f8 100644 --- a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java +++ b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java @@ -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 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, - boolean blocked, Instant roleChangedAt) { + boolean blocked, Instant roleChangedAt, Boolean listed) { } } diff --git a/src/main/java/core_service/modules/account/domain/AccountState.java b/src/main/java/core_service/modules/account/domain/AccountState.java index cb5d402..9af3bd5 100644 --- a/src/main/java/core_service/modules/account/domain/AccountState.java +++ b/src/main/java/core_service/modules/account/domain/AccountState.java @@ -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 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 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) { } diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 71d0237..4561be3 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -23,11 +23,11 @@ public class AccountCleanupRepository { static final List TABLES = List.of("user_game_status_change", "user_game_achievement", "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", - "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. private static final List 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; diff --git a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java index da9ef3a..371ba07 100644 --- a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java @@ -12,8 +12,8 @@ import java.util.Collection; 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 - * change means for user_steam_sync. + * Blocks in blocked_user, listing in listed_user, role changes in account_role_change, the display name in + * account_name, and what a Steam ID change means for user_steam_sync. * Plain SQL, because the library module owns the entities. */ @Repository @@ -41,6 +41,11 @@ public class AccountStateRepository { } else { 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) { jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null", 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 states) { states.forEach(this::apply); @@ -75,5 +81,13 @@ public class AccountStateRepository { } else { 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 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)); + } + } } } diff --git a/src/main/java/core_service/modules/stats/api/OwnerListController.java b/src/main/java/core_service/modules/stats/api/OwnerListController.java new file mode 100644 index 0000000..e3314cf --- /dev/null +++ b/src/main/java/core_service/modules/stats/api/OwnerListController.java @@ -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) + .>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()); + } +} diff --git a/src/main/java/core_service/modules/stats/api/dto/PeopleDto.java b/src/main/java/core_service/modules/stats/api/dto/PeopleDto.java new file mode 100644 index 0000000..7c018ee --- /dev/null +++ b/src/main/java/core_service/modules/stats/api/dto/PeopleDto.java @@ -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 listed, long listedTotal, long privateCount, boolean includesYou, int page, + int totalPages) { + + public record Person(String displayName) { + } +} diff --git a/src/main/java/core_service/modules/stats/domain/OwnerList.java b/src/main/java/core_service/modules/stats/domain/OwnerList.java new file mode 100644 index 0000000..d1712d9 --- /dev/null +++ b/src/main/java/core_service/modules/stats/domain/OwnerList.java @@ -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 listedNames, long listedTotal, long privateCount, boolean includesYou, int page, + int totalPages) { +} diff --git a/src/main/java/core_service/modules/stats/domain/PeopleCounts.java b/src/main/java/core_service/modules/stats/domain/PeopleCounts.java new file mode 100644 index 0000000..eab4fe6 --- /dev/null +++ b/src/main/java/core_service/modules/stats/domain/PeopleCounts.java @@ -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) { +} diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index bf50931..c14ef65 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -1,6 +1,7 @@ package core_service.modules.stats.repo; import core_service.modules.stats.domain.CommunityKey; +import core_service.modules.stats.domain.PeopleCounts; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -20,24 +21,24 @@ public class StatsRepository { * behind it read the same rows. */ 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 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 = """ - SELECT appid, playtime_minutes, + SELECT user_id, appid, playtime_minutes, CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name FROM listed_user_game WHERE %s """.formatted(NOT_DELETED_OR_BLOCKED); 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 CROSS JOIN UNNEST(m.genres) AS tag(name) WHERE %s """.formatted(NOT_DELETED_OR_BLOCKED); /** Has one {@code ?}: the time zone. */ 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 ?) AS INTEGER) AS year FROM counted_user_game ug @@ -57,10 +58,10 @@ public class StatsRepository { CommunityKey.PLAYED, PLAYED_SET, CommunityKey.OWNED, 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(?))", - CommunityKey.GENRE, "SELECT 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.GENRE, "SELECT user_id, appid, playtime_minutes FROM (" + GENRE_SET + ") g WHERE g.name = ?", + CommunityKey.FINISHED_YEAR, "SELECT user_id, appid, playtime_minutes FROM (" + FINISHED_SET + ") g WHERE g.year = ?"); private final JdbcTemplate jdbc; @@ -203,6 +204,43 @@ public class StatsRepository { 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 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 listedOwnerNames(CommunityKey key, Object arg, String zone, long appid, long viewerId, + int offset, int limit) { + List 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. */ private static List communityParams(CommunityKey key, Object arg, String zone) { if (key == CommunityKey.FINISHED_YEAR) { diff --git a/src/main/java/core_service/modules/stats/service/OwnerListService.java b/src/main/java/core_service/modules/stats/service/OwnerListService.java new file mode 100644 index 0000000..b050186 --- /dev/null +++ b/src/main/java/core_service/modules/stats/service/OwnerListService.java @@ -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 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); + } +} diff --git a/src/main/resources/db/migration/V35__Listed_User.sql b/src/main/resources/db/migration/V35__Listed_User.sql new file mode 100644 index 0000000..236e074 --- /dev/null +++ b/src/main/resources/db/migration/V35__Listed_User.sql @@ -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); diff --git a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java index 02271b6..0094c7b 100644 --- a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java +++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java @@ -46,6 +46,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { void cleanUp() { 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 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_role_change where user_id in (?, 42)", USER_ID); } @@ -134,6 +135,16 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { .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 void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception { send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}", diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index 72e3c37..7bfa8b0 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -18,7 +18,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List USER_TABLES = List.of("user_game_status_change", "user_game_achievement", "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", - "reward_equipped"); + "reward_equipped", "listed_user"); @Autowired 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_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 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 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); diff --git a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java index dcd9ab0..d30aa60 100644 --- a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java @@ -31,6 +31,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @BeforeEach void seed() { jdbc.update("delete from blocked_user"); + jdbc.update("delete from listed_user"); jdbc.update("delete from account_name"); jdbc.update("delete from account_role_change"); for (long userId : List.of(1L, 2L)) { @@ -50,24 +51,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null)); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, 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, null)); 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"); } @Test 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(sync(1L).get("user_seen_at")).isNull(); 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(); } @@ -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. @Test 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("last_synced_at")).isNull(); @@ -84,14 +85,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test 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(); } @Test 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("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); @@ -99,7 +100,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test 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(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 void replaceAll_LiftsTheBlocksAuthNoLongerSends() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null)); - repository.apply(new AccountState(2L, 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, 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); repository.replaceAll(List.of()); @@ -127,13 +128,13 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { Instant first = Instant.parse("2026-10-04T12:00:00Z"); 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); - 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); - 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); } @@ -142,8 +143,8 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { Instant first = Instant.parse("2026-10-04T12:00:01Z"); 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, first)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null)); assertThat(changedAt(1L)).isEqualTo(second); } @@ -151,11 +152,47 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void findLoginState_ReadsTheBlockAndTheRoleChange() { Instant change = Instant.parse("2026-10-04T12:00:00Z"); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change)); - repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change, 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(2L)).isEqualTo(LoginState.NONE); assertThat(repository.findLoginState(99L)).isEqualTo(LoginState.NONE); } + + private List 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); + } } diff --git a/src/test/java/core_service/modules/stats/api/OwnerListControllerTest.java b/src/test/java/core_service/modules/stats/api/OwnerListControllerTest.java new file mode 100644 index 0000000..b67c365 --- /dev/null +++ b/src/test/java/core_service/modules/stats/api/OwnerListControllerTest.java @@ -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 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")); + } +} diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index 72fc99d..0656623 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -8,6 +8,7 @@ import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.stats.api.dto.PublicStatsDto; import core_service.modules.stats.domain.CommunityKey; +import core_service.modules.stats.domain.PeopleCounts; import core_service.modules.stats.service.PublicStatsService; import core_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.BeforeEach; @@ -251,4 +252,79 @@ class StatsRepositoryTest extends AbstractPersistenceTest { 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 first = statsRepository.listedOwnerNames(CommunityKey.OWNED, "", "Europe/Berlin", 220L, 99L, 0, 40); + List 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); + } } diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index c24e2a8..5fd06af 100644 --- a/src/test/resources/contract/auth-core-contract.json +++ b/src/test/resources/contract/auth-core-contract.json @@ -37,12 +37,12 @@ "applyStates": { "method": "POST", "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": { "method": "PUT", "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": { "method": "POST",