diff --git a/readme.md b/readme.md index bbbc692..5a29f7f 100644 --- a/readme.md +++ b/readme.md @@ -29,6 +29,12 @@ To Be Played is split into three repositories: Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`). Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com. +- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most + 500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its + achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status + changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). + Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or + deleted. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. - **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id, appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then @@ -207,8 +213,13 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | +| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login | +| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login | +| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login | +| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | +| `PUT` | `/api/inbox/read` (marks all read; `204`) | login | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | -| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed}]}`, `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`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token | | `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 | @@ -238,6 +249,8 @@ flowchart LR Stats["stats"] Version["version"] Account["account"] + Follow["follow"] + Inbox["inbox"] Rewards["rewards"] Steam["steam_adapter"] Igdb["igdb_adapter"] @@ -256,8 +269,10 @@ flowchart LR 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, listed_user, account_name, game_alias"| Db + Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db + Inbox --> Db Auth -->|"service token
/internal/accounts"| Account - Account -->|"per-user rows, deleted_user, blocked_user, listed_user, account_name, user_seen_at"| Db + Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db ``` | Module | Content | @@ -265,11 +280,16 @@ 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`, 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 | +| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` | +| `inbox` | `inbox_note`: the notes of a user, `GET /api/inbox`, the 90-day purge | +| `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), public profiles and their sections in `public_user` (#27), 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 | +`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`, +`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`. + **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`, `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 @@ -321,6 +341,27 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` The frontend polls `GET /api/library/sync-status` until the run ends. +Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and +`FollowDigestService` writes one inbox note per follower from the new sync result (`ADDED`, `ACHIEVEMENTS`, `STATUS` +and `WISHLIST` rows of `sync_change`) and the hand changes since the cursor. Kinds that the profile hides (`public_user.sections`) +are left out, and the cursor moves anyway. A job every 10 minutes reports the hand changes alone once a user was quiet for 10 +minutes; it never takes a sync result, which may still wait for its achievements. + +```mermaid +sequenceDiagram + participant W as LibrarySyncWorker + participant A as AchievementSyncService + participant F as FollowDigestService + participant D as Db + W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST) + W-->>A: LibrarySyncedEvent + A->>D: sync_change (ACHIEVEMENTS) + A-->>F: LibraryActivityEvent + F->>D: lock follow_digest, count sync + hand changes + F->>D: inbox_note per follower (public, not blocked, not deleted) + F->>D: move cursor +``` + At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table `broken_image`, one row per game and kind). Reports only count for Steam games the user owns. @@ -449,12 +490,14 @@ 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 tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and +`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user. 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, 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 +took by a verified link, a display name change, a block, an unblock and a change of the owner-list opt-in (`listed`, #26), of the public profile or of the profile tiles (`publicProfile`, `profileSections`, #27); 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 5b75568..d3adbe4 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,8 @@ 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.listed())) + account.blocked(), account.roleChangedAt(), account.listed(), account.publicProfile(), + account.profileSections())) .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 44e01f8..1b6a6e4 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 @@ -13,8 +13,12 @@ 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 + * @param publicProfile null from an Auth that does not send it yet + * @param profileSections the profile parts the user shows; null = all, or an Auth that does not send them. Unknown names + * are dropped by {@code AccountState} */ public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName, - boolean blocked, Instant roleChangedAt, Boolean listed) { + boolean blocked, Instant roleChangedAt, Boolean listed, + Boolean publicProfile, List profileSections) { } } 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 9af3bd5..b225e89 100644 --- a/src/main/java/core_service/modules/account/domain/AccountState.java +++ b/src/main/java/core_service/modules/account/domain/AccountState.java @@ -1,6 +1,8 @@ package core_service.modules.account.domain; import java.time.Instant; +import java.util.List; +import java.util.Set; /** * What Core copies from Auth for one user. @@ -9,7 +11,16 @@ import java.time.Instant; * @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 + * @param publicProfile null from an Auth that does not send it; Core then keeps the stored row and its sections + * @param profileSections the profile parts the user shows; null = all shown */ public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt, - Boolean listed) { + Boolean listed, Boolean publicProfile, List profileSections) { + + // One odd entry must not fail the push of every user, so unknown names are dropped, not refused. + private static final Set SECTIONS = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS"); + + public AccountState { + profileSections = profileSections == null ? null : profileSections.stream().filter(section -> section != null && SECTIONS.contains(section)).toList(); + } } 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 4561be3..6913cf1 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", "listed_user", "deleted_user"); + "account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_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", "listed_user", "account_role_change", "deleted_user", "account_name"); + "game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note"); private final NamedParameterJdbcTemplate jdbc; @@ -36,7 +36,7 @@ public class AccountCleanupRepository { } /** - * Also takes the users off the nightly sync list. + * Also takes the users off the nightly sync list and ends their follows, both ways, without a note. */ public void addDeleted(Collection userIds) { if (userIds.isEmpty()) { @@ -45,6 +45,9 @@ public class AccountCleanupRepository { jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing", userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new)); jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds)); + jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds)); + jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds)); + jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds)); } /** @@ -88,6 +91,7 @@ public class AccountCleanupRepository { for (String table : TABLES) { counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds))); } + jdbc.update("delete from inbox_note where subject_user_id in (:ids)", ids(userIds)); // The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user. jdbc.update(""" update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}', 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 371ba07..17ae109 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,9 @@ import java.util.Collection; import java.util.List; /** - * 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. + * Blocks in blocked_user, listing in listed_user, public profiles in public_user (and the follows that end with them), + * 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 @@ -46,6 +47,7 @@ public class AccountStateRepository { } else if (Boolean.FALSE.equals(state.listed())) { jdbc.update("delete from listed_user where user_id = :userId", params); } + applyPublicProfile(state, 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); @@ -58,6 +60,26 @@ public class AccountStateRepository { } } + // A blocked user ends follows in both directions without a note; a user who goes private tells each follower. + private void applyPublicProfile(AccountState state, MapSqlParameterSource params) { + if (state.blocked()) { + jdbc.update("delete from follow where user_id = :userId or followed_user_id = :userId", params); + jdbc.update("delete from public_user where user_id = :userId", params); + } else if (Boolean.TRUE.equals(state.publicProfile())) { + String[] sections = state.profileSections() == null ? null : state.profileSections().toArray(String[]::new); + jdbc.update("insert into public_user (user_id, sections) values (:userId, cast(:sections as varchar[])) " + + "on conflict (user_id) do update set sections = excluded.sections", + new MapSqlParameterSource(params.getValues()).addValue("sections", sections)); + } else if (Boolean.FALSE.equals(state.publicProfile())) { + jdbc.update("delete from public_user where user_id = :userId", params); + jdbc.update(""" + with ended as (delete from follow where followed_user_id = :userId returning user_id) + insert into inbox_note (user_id, kind, subject_user_id, subject_name) + select e.user_id, 'VISIBILITY', :userId, (select display_name from account_name where user_id = :userId) + from ended e""", params); + } + } + public LoginState findLoginState(long userId) { return jdbc.queryForObject(""" select exists(select 1 from blocked_user where user_id = :userId) as blocked, @@ -70,24 +92,29 @@ public class AccountStateRepository { } /** - * After this, exactly the blocked users of the list are blocked, and the listed ones listed unless a state has no - * listing. + * After this, exactly the blocked users of the list are blocked, and the listed and public ones listed and public + * unless a state has no such flag. */ public void replaceAll(Collection states) { states.forEach(this::apply); - List blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList(); - if (blocked.isEmpty()) { - jdbc.update("delete from blocked_user", new MapSqlParameterSource()); - } else { - jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked)); - } + keepOnly("blocked_user", states.stream().filter(AccountState::blocked).map(AccountState::userId).toList()); 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)); - } + keepOnly("listed_user", states.stream().filter(AccountState::listed).map(AccountState::userId).toList()); + } + if (states.stream().noneMatch(state -> state.publicProfile() == null)) { + keepOnly("public_user", states.stream().filter(state -> state.publicProfile() && !state.blocked()) + .map(AccountState::userId).toList()); + // apply() already told the followers of a user who went private. + jdbc.update("delete from follow f where not exists (select 1 from public_user p where p.user_id = f.followed_user_id)", + new MapSqlParameterSource()); + } + } + + private void keepOnly(String table, List userIds) { + if (userIds.isEmpty()) { + jdbc.update("delete from " + table, new MapSqlParameterSource()); + } else { + jdbc.update("delete from " + table + " where user_id not in (:ids)", new MapSqlParameterSource("ids", userIds)); } } } diff --git a/src/main/java/core_service/modules/follow/api/FollowController.java b/src/main/java/core_service/modules/follow/api/FollowController.java new file mode 100644 index 0000000..caebec7 --- /dev/null +++ b/src/main/java/core_service/modules/follow/api/FollowController.java @@ -0,0 +1,68 @@ +package core_service.modules.follow.api; + +import core_service.modules.follow.api.dto.FollowsDto; +import core_service.modules.follow.domain.FollowEntry; +import core_service.modules.follow.domain.FollowResult; +import core_service.modules.follow.domain.Follows; +import core_service.modules.follow.service.FollowService; +import core_service.shared.api.ErrorResponse; +import core_service.shared.security.UserPrincipal; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/follows") +@RequiredArgsConstructor +public class FollowController { + + private final FollowService followService; + + @GetMapping + public ResponseEntity follows(@AuthenticationPrincipal UserPrincipal principal) { + Follows follows = followService.follows(principal.getIdAsLong()); + return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers()))); + } + + @PutMapping("/by-name/{displayName}") + public ResponseEntity follow(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("displayName") @Size(max = 100) String displayName) { + FollowResult result = followService.follow(principal.getIdAsLong(), displayName); + return switch (result) { + case FOLLOWED -> ResponseEntity.noContent().build(); + case NOT_FOUND -> ResponseEntity.status(404) + .body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")); + case SELF -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_SELF", "You cannot follow yourself")); + case LIMIT -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_LIMIT", "You follow too many people")); + }; + } + + @DeleteMapping("/by-name/{displayName}") + public ResponseEntity unfollow(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("displayName") @Size(max = 100) String displayName) { + followService.unfollow(principal.getIdAsLong(), displayName); + return ResponseEntity.noContent().build(); + } + + /** + * Ends a follow of the caller, or removes a follower of the caller; a row of someone else stays. + */ + @DeleteMapping("/{id}") + public ResponseEntity remove(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) { + followService.remove(principal.getIdAsLong(), id); + return ResponseEntity.noContent().build(); + } + + private static List toDto(List entries) { + return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList(); + } +} diff --git a/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java b/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java new file mode 100644 index 0000000..c160fc6 --- /dev/null +++ b/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java @@ -0,0 +1,16 @@ +package core_service.modules.follow.api.dto; + +import java.util.List; + +/** + * Who the caller follows and who follows the caller. Names only: a person has no user id here. + */ +public record FollowsDto(List following, List followers) { + + /** + * @param id the follow row, to end it; not a user id + * @param displayName null for a follower with a private profile + */ + public record Person(long id, String displayName) { + } +} diff --git a/src/main/java/core_service/modules/follow/domain/Cursor.java b/src/main/java/core_service/modules/follow/domain/Cursor.java new file mode 100644 index 0000000..703a8c4 --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/Cursor.java @@ -0,0 +1,12 @@ +package core_service.modules.follow.domain; + +import java.time.Instant; + +/** + * How far the followers of a user were told. + * + * @param syncStartedAt the last reported sync result, null when there was none + * @param wishlistChangeId the last reported WISHLIST row of sync_change + */ +public record Cursor(Instant syncStartedAt, long statusChangeId, long wishlistChangeId) { +} diff --git a/src/main/java/core_service/modules/follow/domain/Digest.java b/src/main/java/core_service/modules/follow/domain/Digest.java new file mode 100644 index 0000000..4b5edbc --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/Digest.java @@ -0,0 +1,37 @@ +package core_service.modules.follow.domain; + +import java.util.Collection; +import java.util.List; + +/** + * What a followed user did in one batch. The section names are those of the profile ticket; this module must not + * import the library's enum. + */ +public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) { + + private static final String SUMMARY = "SUMMARY"; + private static final List STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED"); + + public boolean isEmpty() { + return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0; + } + + public Digest plus(Digest other) { + return new Digest(newGames + other.newGames, achievements + other.achievements, + statusChanges + other.statusChanges, wishlistAdds + other.wishlistAdds); + } + + /** + * Leaves out what the profile hides. The wishlist is never on the profile, so it has no section. + * + * @param sections the profile parts the user shows, null = all + */ + public Digest visibleOn(Collection sections) { + if (sections == null) { + return this; + } + boolean summary = sections.contains(SUMMARY); + boolean status = STATUS_SECTIONS.stream().anyMatch(sections::contains); + return new Digest(summary ? newGames : 0, summary ? achievements : 0, status ? statusChanges : 0, wishlistAdds); + } +} diff --git a/src/main/java/core_service/modules/follow/domain/FollowEntry.java b/src/main/java/core_service/modules/follow/domain/FollowEntry.java new file mode 100644 index 0000000..85dc4ff --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/FollowEntry.java @@ -0,0 +1,8 @@ +package core_service.modules.follow.domain; + +/** + * @param id the follow row, not a user id + * @param displayName null for a follower with a private profile + */ +public record FollowEntry(long id, String displayName) { +} diff --git a/src/main/java/core_service/modules/follow/domain/FollowResult.java b/src/main/java/core_service/modules/follow/domain/FollowResult.java new file mode 100644 index 0000000..2b30f1b --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/FollowResult.java @@ -0,0 +1,8 @@ +package core_service.modules.follow.domain; + +public enum FollowResult { + FOLLOWED, + NOT_FOUND, + SELF, + LIMIT +} diff --git a/src/main/java/core_service/modules/follow/domain/Follows.java b/src/main/java/core_service/modules/follow/domain/Follows.java new file mode 100644 index 0000000..d6ce5c8 --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/Follows.java @@ -0,0 +1,6 @@ +package core_service.modules.follow.domain; + +import java.util.List; + +public record Follows(List following, List followers) { +} diff --git a/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java new file mode 100644 index 0000000..a69568c --- /dev/null +++ b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java @@ -0,0 +1,162 @@ +package core_service.modules.follow.repo; + +import core_service.modules.follow.domain.Cursor; +import core_service.modules.follow.domain.Digest; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Array; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * The cursor per followed user, what happened since it, and the notes. Plain SQL: sync_change, sync_result, + * user_game and the trigger log belong to the library module, account tables to the account module. + */ +@Repository +@RequiredArgsConstructor +public class FollowDigestRepository { + + private final NamedParameterJdbcTemplate jdbc; + + /** + * Locks the row until the end of the transaction, so two digests of one user never report a batch twice. + */ + public Optional lockCursor(long userId) { + return jdbc.query("select sync_started_at, status_change_id, wishlist_change_id from follow_digest where user_id = :userId for update", + user(userId), (rs, i) -> { + Timestamp startedAt = rs.getTimestamp("sync_started_at"); + return new Cursor(startedAt == null ? null : startedAt.toInstant(), rs.getLong("status_change_id"), rs.getLong("wishlist_change_id")); + }).stream().findFirst(); + } + + public Optional findSyncStartedAt(long userId) { + return jdbc.query("select sync_started_at from sync_result where user_id = :userId", user(userId), + (rs, i) -> rs.getTimestamp("sync_started_at").toInstant()).stream().findFirst(); + } + + /** + * Hidden games never count. Wishlist adds are counted apart, see {@link #countWishlist}. + */ + public Digest countSync(long userId) { + return jdbc.queryForObject(""" + select + (select count(distinct c.appid) from sync_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null) as new_games, + (select coalesce(sum(c.amount), 0) from sync_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null) as achievements, + (select count(*) from sync_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null) as status_changes""", + user(userId), + (rs, i) -> new Digest(rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), 0)); + } + + public long maxWishlistChangeId(long userId) { + Long max = jdbc.queryForObject( + "select coalesce(max(id), 0) from sync_change where user_id = :userId and kind = 'WISHLIST'", + user(userId), Long.class); + return max != null ? max : 0; + } + + /** + * By row id, not by sync result: an add can join a result that was reported already. Wishlist games are no + * library games, so a hidden game does not matter. + */ + public int countWishlist(long userId, long afterId, long upToId) { + Integer count = jdbc.queryForObject("select count(*) from sync_change where user_id = :userId " + + "and kind = 'WISHLIST' and id > :afterId and id <= :upToId", + user(userId).addValue("afterId", afterId).addValue("upToId", upToId), Integer.class); + return count != null ? count : 0; + } + + public long maxStatusChangeId(long userId) { + Long max = jdbc.queryForObject("select coalesce(max(id), 0) from user_game_status_change where user_id = :userId", + user(userId), Long.class); + return max != null ? max : 0; + } + + /** + * The changes the user made by hand: no old status = a game added by hand, else a status change. + */ + public Digest countHand(long userId, long afterId, long upToId) { + return jdbc.queryForObject(""" + select count(*) filter (where c.from_status is null) as new_games, + count(*) filter (where c.from_status is not null) as status_changes + from user_game_status_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + where c.user_id = :userId and c.by_user and c.id > :afterId and c.id <= :upToId + and ug.hidden_at is null""", + user(userId).addValue("afterId", afterId).addValue("upToId", upToId), + (rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0)); + } + + /** + * @return null when there is no row or the user did not send sections: nothing is filtered + */ + public List findPublicSections(long userId) { + return jdbc.query("select sections from public_user where user_id = :userId", user(userId), rs -> { + if (!rs.next()) { + return null; + } + Array sections = rs.getArray("sections"); + return sections == null ? null : Arrays.asList((String[]) sections.getArray()); + }); + } + + /** + * One note per follower, only while the followed user is public, not blocked and not deleted and the follower is + * not deleted. A name is stored as it is now. + * + * @return how many notes were written + */ + public int insertDigestNotes(long userId, Digest digest) { + return jdbc.update(""" + insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements, + status_changes, wishlist_adds) + select f.user_id, 'DIGEST', f.followed_user_id, + (select display_name from account_name where user_id = f.followed_user_id), + :newGames, :achievements, :statusChanges, :wishlistAdds + from follow f + where f.followed_user_id = :userId + and exists (select 1 from public_user p where p.user_id = f.followed_user_id) + and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id) + and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))""", + user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements()) + .addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds())); + } + + public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) { + jdbc.update("update follow_digest set sync_started_at = :syncStartedAt, status_change_id = :statusChangeId, " + + "wishlist_change_id = :wishlistChangeId " + + "where user_id = :userId", + user(userId).addValue("syncStartedAt", syncStartedAt == null ? null : Timestamp.from(syncStartedAt)) + .addValue("statusChangeId", statusChangeId).addValue("wishlistChangeId", wishlistChangeId)); + } + + /** + * Followed users with a pending hand change whose newest pending change is older than {@code quietBefore}. + */ + public List findQuietHandChanges(Instant quietBefore) { + return jdbc.queryForList(""" + select d.user_id from follow_digest d + where exists (select 1 from follow f where f.followed_user_id = d.user_id) + and exists (select 1 from user_game_status_change c + where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id) + and not exists (select 1 from user_game_status_change c + where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id + and c.changed_at > :quietBefore)""", + new MapSqlParameterSource("quietBefore", Timestamp.from(quietBefore)), Long.class); + } + + private static MapSqlParameterSource user(long userId) { + return new MapSqlParameterSource("userId", userId); + } +} diff --git a/src/main/java/core_service/modules/follow/repo/FollowRepository.java b/src/main/java/core_service/modules/follow/repo/FollowRepository.java new file mode 100644 index 0000000..29378c1 --- /dev/null +++ b/src/main/java/core_service/modules/follow/repo/FollowRepository.java @@ -0,0 +1,118 @@ +package core_service.modules.follow.repo; + +import core_service.modules.follow.domain.FollowEntry; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Follow rows. Plain SQL: the tables of other modules (account_name, public_user, blocked_user, deleted_user) are + * read, not mapped. + */ +@Repository +@RequiredArgsConstructor +public class FollowRepository { + + static final int LIST_LIMIT = 500; + + private static final RowMapper ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"), + rs.getString("display_name")); + + private final NamedParameterJdbcTemplate jdbc; + + /** + * The user behind a name, when the profile is public and the user is neither blocked nor deleted. + * + * @return empty when no user or more than one user has the name + */ + public Optional findFollowable(String displayName) { + List ids = jdbc.queryForList(""" + select a.user_id from account_name a + join public_user p on p.user_id = a.user_id + where lower(a.display_name) = lower(:name) + and not exists (select 1 from blocked_user b where b.user_id = a.user_id) + and not exists (select 1 from deleted_user d where d.user_id = a.user_id) + limit 2""", new MapSqlParameterSource("name", displayName), Long.class); + return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty(); + } + + /** + * Checks the visibility again in the same statement, so a user who went private in between gets no follower. + * + * @return true when a row was written + */ + public boolean insert(long userId, long followedUserId) { + return jdbc.update(""" + insert into follow (user_id, followed_user_id) + select :userId, :followedUserId + where exists (select 1 from public_user where user_id = :followedUserId) + on conflict do nothing""", pair(userId, followedUserId)) > 0; + } + + public int countFollowing(long userId) { + Integer count = jdbc.queryForObject("select count(*) from follow where user_id = :userId", + new MapSqlParameterSource("userId", userId), Integer.class); + return count != null ? count : 0; + } + + /** + * With the first follower the cursor starts at "now", so the follower gets no old activity. + */ + public void startDigestIfFirst(long followedUserId) { + jdbc.update(""" + insert into follow_digest (user_id, sync_started_at, status_change_id, wishlist_change_id) + select :id, (select sync_started_at from sync_result where user_id = :id), + coalesce((select max(id) from user_game_status_change where user_id = :id), 0), + coalesce((select max(id) from sync_change where user_id = :id and kind = 'WISHLIST'), 0) + where (select count(*) from follow where followed_user_id = :id) = 1 + on conflict (user_id) do update + set sync_started_at = excluded.sync_started_at, status_change_id = excluded.status_change_id, + wishlist_change_id = excluded.wishlist_change_id""", + new MapSqlParameterSource("id", followedUserId)); + } + + public void deleteByFollowed(long userId, long followedUserId) { + jdbc.update("delete from follow where user_id = :userId and followed_user_id = :followedUserId", + pair(userId, followedUserId)); + } + + /** + * Only a row of the caller: the caller follows, or is followed. + */ + public void delete(long userId, long followId) { + jdbc.update("delete from follow where id = :id and (user_id = :userId or followed_user_id = :userId)", + new MapSqlParameterSource("id", followId).addValue("userId", userId)); + } + + public List findFollowing(long userId) { + return jdbc.query(""" + select f.id, a.display_name from follow f + left join account_name a on a.user_id = f.followed_user_id + where f.user_id = :userId + order by lower(a.display_name), f.id + limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY); + } + + /** + * The name of a follower with a private profile is left out. + */ + public List findFollowers(long userId) { + return jdbc.query(""" + select f.id, case when p.user_id is not null then a.display_name end as display_name + from follow f + left join public_user p on p.user_id = f.user_id + left join account_name a on a.user_id = f.user_id + where f.followed_user_id = :userId + order by lower(case when p.user_id is not null then a.display_name end) nulls last, f.id + limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY); + } + + private static MapSqlParameterSource pair(long userId, long followedUserId) { + return new MapSqlParameterSource("userId", userId).addValue("followedUserId", followedUserId); + } +} diff --git a/src/main/java/core_service/modules/follow/service/FollowDigestService.java b/src/main/java/core_service/modules/follow/service/FollowDigestService.java new file mode 100644 index 0000000..f6f0966 --- /dev/null +++ b/src/main/java/core_service/modules/follow/service/FollowDigestService.java @@ -0,0 +1,91 @@ +package core_service.modules.follow.service; + +import core_service.modules.follow.domain.Cursor; +import core_service.modules.follow.domain.Digest; +import core_service.modules.follow.repo.FollowDigestRepository; +import core_service.shared.event.LibraryActivityEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the + * last batch. The cursor moves in every case, so what the profile hides is never reported later. + */ +@Service +@Slf4j +public class FollowDigestService { + + private final FollowDigestRepository digestRepository; + private final TransactionTemplate transaction; + private final Duration quiet; + + public FollowDigestService(FollowDigestRepository digestRepository, PlatformTransactionManager transactionManager, + @Value("${app.follow.quiet-minutes:10}") long quietMinutes) { + this.digestRepository = digestRepository; + this.transaction = new TransactionTemplate(transactionManager); + this.quiet = Duration.ofMinutes(quietMinutes); + } + + @EventListener + public void onLibraryActivity(LibraryActivityEvent event) { + digestOrLog(event.userId(), true); + } + + /** + * Only hand changes: it never takes a sync result, which may still wait for its achievements. + */ + @Scheduled(initialDelayString = "${app.follow.digest-ms:600000}", fixedDelayString = "${app.follow.digest-ms:600000}") + public void digestHandChanges() { + List userIds = digestRepository.findQuietHandChanges(Instant.now().minus(quiet)); + for (long userId : userIds) { + digestOrLog(userId, false); + } + } + + private void digestOrLog(long userId, boolean withSync) { + try { + digest(userId, withSync); + } catch (RuntimeException e) { + log.warn("Follower digest of user {} failed", userId, e); + } + } + + void digest(long userId, boolean withSync) { + transaction.executeWithoutResult(tx -> { + Optional locked = digestRepository.lockCursor(userId); + if (locked.isEmpty()) { + return; + } + Cursor cursor = locked.get(); + long upTo = digestRepository.maxStatusChangeId(userId); + Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo); + Instant syncStartedAt = cursor.syncStartedAt(); + long wishlistUpTo = cursor.wishlistChangeId(); + if (withSync) { + Optional current = digestRepository.findSyncStartedAt(userId); + if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) { + digest = digest.plus(digestRepository.countSync(userId)); + syncStartedAt = current.get(); + } + wishlistUpTo = digestRepository.maxWishlistChangeId(userId); + digest = digest.plus(new Digest(0, 0, 0, + digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo))); + } + digest = digest.visibleOn(digestRepository.findPublicSections(userId)); + if (!digest.isEmpty()) { + digestRepository.insertDigestNotes(userId, digest); + } + digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo); + }); + } +} diff --git a/src/main/java/core_service/modules/follow/service/FollowService.java b/src/main/java/core_service/modules/follow/service/FollowService.java new file mode 100644 index 0000000..39284fc --- /dev/null +++ b/src/main/java/core_service/modules/follow/service/FollowService.java @@ -0,0 +1,60 @@ +package core_service.modules.follow.service; + +import core_service.modules.follow.domain.FollowResult; +import core_service.modules.follow.domain.Follows; +import core_service.modules.follow.repo.FollowRepository; +import core_service.shared.security.RequiresRole; +import core_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FollowService { + + static final int MAX_FOLLOWING = 500; + + private final FollowRepository followRepository; + + /** + * Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed. + */ + @RequiresRole(Role.USER) + public FollowResult follow(long userId, String displayName) { + Optional followed = followRepository.findFollowable(displayName); + if (followed.isEmpty()) { + return FollowResult.NOT_FOUND; + } + if (followed.get() == userId) { + return FollowResult.SELF; + } + if (followRepository.countFollowing(userId) >= MAX_FOLLOWING) { + return FollowResult.LIMIT; + } + if (followRepository.insert(userId, followed.get())) { + followRepository.startDigestIfFirst(followed.get()); + } + return FollowResult.FOLLOWED; + } + + @RequiresRole(Role.USER) + public void unfollow(long userId, String displayName) { + followRepository.findFollowable(displayName) + .ifPresent(followed -> followRepository.deleteByFollowed(userId, followed)); + } + + @RequiresRole(Role.USER) + public void remove(long userId, long followId) { + followRepository.delete(userId, followId); + } + + @RequiresRole(Role.USER) + @Transactional(readOnly = true) + public Follows follows(long userId) { + return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId)); + } +} diff --git a/src/main/java/core_service/modules/inbox/api/InboxController.java b/src/main/java/core_service/modules/inbox/api/InboxController.java new file mode 100644 index 0000000..394aba9 --- /dev/null +++ b/src/main/java/core_service/modules/inbox/api/InboxController.java @@ -0,0 +1,36 @@ +package core_service.modules.inbox.api; + +import core_service.modules.inbox.api.dto.InboxDto; +import core_service.modules.inbox.domain.Inbox; +import core_service.modules.inbox.service.InboxService; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/inbox") +@RequiredArgsConstructor +public class InboxController { + + private final InboxService inboxService; + + @GetMapping + public ResponseEntity inbox(@AuthenticationPrincipal UserPrincipal principal) { + Inbox inbox = inboxService.inbox(principal.getIdAsLong()); + return ResponseEntity.ok(new InboxDto(inbox.notes().stream() + .map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(), + note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read())) + .toList(), inbox.unread())); + } + + @PutMapping("/read") + public ResponseEntity markAllRead(@AuthenticationPrincipal UserPrincipal principal) { + inboxService.markAllRead(principal.getIdAsLong()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java new file mode 100644 index 0000000..17baa91 --- /dev/null +++ b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java @@ -0,0 +1,18 @@ +package core_service.modules.inbox.api.dto; + +import java.time.Instant; +import java.util.List; + +/** + * @param unread all unread notes, also those beyond the newest 50 in {@code results} + */ +public record InboxDto(List results, int unread) { + + /** + * @param kind DIGEST or VISIBILITY + * @param displayName the name of the followed user when the note was written; never a user id + */ + public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges, + int wishlistAdds, Instant createdAt, boolean read) { + } +} diff --git a/src/main/java/core_service/modules/inbox/domain/Inbox.java b/src/main/java/core_service/modules/inbox/domain/Inbox.java new file mode 100644 index 0000000..742cb90 --- /dev/null +++ b/src/main/java/core_service/modules/inbox/domain/Inbox.java @@ -0,0 +1,9 @@ +package core_service.modules.inbox.domain; + +import java.util.List; + +/** + * @param unread all unread notes, also those beyond the list + */ +public record Inbox(List notes, int unread) { +} diff --git a/src/main/java/core_service/modules/inbox/domain/InboxNote.java b/src/main/java/core_service/modules/inbox/domain/InboxNote.java new file mode 100644 index 0000000..29feeea --- /dev/null +++ b/src/main/java/core_service/modules/inbox/domain/InboxNote.java @@ -0,0 +1,10 @@ +package core_service.modules.inbox.domain; + +import java.time.Instant; + +/** + * @param subjectName the name of the followed user when the note was written + */ +public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements, + int statusChanges, int wishlistAdds, Instant createdAt, boolean read) { +} diff --git a/src/main/java/core_service/modules/inbox/domain/NoteKind.java b/src/main/java/core_service/modules/inbox/domain/NoteKind.java new file mode 100644 index 0000000..b9fc73d --- /dev/null +++ b/src/main/java/core_service/modules/inbox/domain/NoteKind.java @@ -0,0 +1,6 @@ +package core_service.modules.inbox.domain; + +public enum NoteKind { + DIGEST, + VISIBILITY +} diff --git a/src/main/java/core_service/modules/inbox/repo/InboxRepository.java b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java new file mode 100644 index 0000000..0fa2146 --- /dev/null +++ b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java @@ -0,0 +1,52 @@ +package core_service.modules.inbox.repo; + +import core_service.modules.inbox.domain.InboxNote; +import core_service.modules.inbox.domain.NoteKind; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class InboxRepository { + + private final NamedParameterJdbcTemplate jdbc; + + public List findNewest(long userId, int limit) { + return jdbc.query(""" + select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at + from inbox_note where user_id = :userId + order by created_at desc, id desc + limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit), + (rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")), + rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"), + rs.getInt("status_changes"), rs.getInt("wishlist_adds"), + rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null)); + } + + public int countUnread(long userId) { + Integer count = jdbc.queryForObject("select count(*) from inbox_note where user_id = :userId and read_at is null", + new MapSqlParameterSource("userId", userId), Integer.class); + return count != null ? count : 0; + } + + public void markAllRead(long userId) { + jdbc.update("update inbox_note set read_at = now() where user_id = :userId and read_at is null", + new MapSqlParameterSource("userId", userId)); + } + + /** + * Read and unread notes alike. + * + * @return how many notes were deleted + */ + public int deleteBefore(Instant cutoff) { + return jdbc.update("delete from inbox_note where created_at < :cutoff", + new MapSqlParameterSource("cutoff", Timestamp.from(cutoff))); + } +} diff --git a/src/main/java/core_service/modules/inbox/service/InboxService.java b/src/main/java/core_service/modules/inbox/service/InboxService.java new file mode 100644 index 0000000..fe01123 --- /dev/null +++ b/src/main/java/core_service/modules/inbox/service/InboxService.java @@ -0,0 +1,48 @@ +package core_service.modules.inbox.service; + +import core_service.modules.inbox.domain.Inbox; +import core_service.modules.inbox.repo.InboxRepository; +import core_service.shared.security.RequiresRole; +import core_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; + +@Service +@Slf4j +@RequiredArgsConstructor +public class InboxService { + + static final int LIST_SIZE = 50; + + private final InboxRepository inboxRepository; + + @Value("${app.inbox.keep-days:90}") + private long keepDays; + + @RequiresRole(Role.USER) + @Transactional(readOnly = true) + public Inbox inbox(long userId) { + return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId)); + } + + @RequiresRole(Role.USER) + @Transactional + public void markAllRead(long userId) { + inboxRepository.markAllRead(userId); + } + + @Scheduled(cron = "${app.inbox.purge-cron:0 15 3 * * *}", zone = "${app.library.zone:Europe/Berlin}") + public void purgeOld() { + int deleted = inboxRepository.deleteBefore(Instant.now().minus(Duration.ofDays(keepDays))); + if (deleted > 0) { + log.info("Deleted {} inbox notes older than {} days", deleted, keepDays); + } + } +} diff --git a/src/main/java/core_service/modules/library/domain/SyncChange.java b/src/main/java/core_service/modules/library/domain/SyncChange.java index 540dad9..0f2bcd6 100644 --- a/src/main/java/core_service/modules/library/domain/SyncChange.java +++ b/src/main/java/core_service/modules/library/domain/SyncChange.java @@ -26,4 +26,8 @@ public record SyncChange(SyncChangeKind kind, long appid, Integer amount, GameSt public static SyncChange status(long appid, GameStatus from, GameStatus to) { return new SyncChange(SyncChangeKind.STATUS, appid, null, from, to); } + + public static SyncChange wishlist(long appid) { + return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null); + } } diff --git a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java index 4102da3..a02cc54 100644 --- a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java +++ b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java @@ -5,5 +5,7 @@ public enum SyncChangeKind { REMOVED, PLAYTIME, ACHIEVEMENTS, - STATUS + STATUS, + // Only for followers' digests: never shown in the sync banner. + WISHLIST } diff --git a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java index 66b45d6..c74c6fb 100644 --- a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java +++ b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java @@ -34,7 +34,7 @@ public class SyncChangeRepository { */ public boolean replace(long userId, Instant syncStartedAt, SyncTrigger startedBy, int importedGames, List changes) { - boolean empty = changes.isEmpty() && importedGames == 0; + boolean empty = importedGames == 0 && changes.stream().allMatch(change -> change.kind() == SyncChangeKind.WISHLIST); int written = jdbc.update(""" insert into sync_result (user_id, sync_started_at, started_by, imported_games, seen_at) select cast(:userId as bigint), :startedAt, cast(:startedBy as varchar), cast(:importedGames as integer), null @@ -43,13 +43,14 @@ public class SyncChangeRepository { set sync_started_at = excluded.sync_started_at, started_by = excluded.started_by, imported_games = excluded.imported_games, seen_at = null where not (:empty and sync_result.seen_at is null and (sync_result.imported_games > 0 - or exists (select 1 from sync_change where user_id = sync_result.user_id))) + or exists (select 1 from sync_change where user_id = sync_result.user_id and kind <> 'WISHLIST'))) """, new MapSqlParameterSource("userId", userId) .addValue("startedAt", Timestamp.from(syncStartedAt)) .addValue("startedBy", startedBy.name()) .addValue("importedGames", importedGames) .addValue("empty", empty)); if (written == 0) { + keepWishlistAdds(userId, syncStartedAt, changes); return false; } jdbc.update("delete from sync_change where user_id = :userId", new MapSqlParameterSource("userId", userId)); @@ -60,6 +61,22 @@ public class SyncChangeRepository { return true; } + // The kept result stays as it is, but a wishlist add must still reach the followers: it joins the result's rows. + private void keepWishlistAdds(long userId, Instant syncStartedAt, List changes) { + List adds = changes.stream().filter(change -> change.kind() == SyncChangeKind.WISHLIST).toList(); + if (adds.isEmpty()) { + return; + } + jdbc.batchUpdate(""" + insert into sync_change (user_id, kind, appid) + select :userId, 'WISHLIST', :appid + where exists (select 1 from user_steam_sync where user_id = :userId and sync_started_at = :startedAt) + and exists (select 1 from sync_result where user_id = :userId) + """, adds.stream().map(change -> new MapSqlParameterSource("userId", userId) + .addValue("appid", change.appid()).addValue("startedAt", Timestamp.from(syncStartedAt))) + .toArray(SqlParameterSource[]::new)); + } + /** * The moves of the sync that the trigger log wrote in this transaction. {@code now()} is the start of the * transaction, like in {@link UserSteamSyncRepository#markHistoryBaseline}: call it in the transaction that made @@ -114,7 +131,7 @@ public class SyncChangeRepository { from sync_change c join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid left join steam_game sg on sg.appid = c.appid - where c.user_id = :userId and ug.hidden_at is null + where c.user_id = :userId and ug.hidden_at is null and c.kind <> 'WISHLIST' group by c.kind, c.appid, c.from_status, c.to_status, sg.name, sg.image_url order by c.kind, amount desc nulls last, sg.name """, params, (rs, i) -> new SyncChanges.Entry( diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 6fd0583..f97a756 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -18,8 +18,10 @@ import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievemen import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.background.BackgroundPools; +import core_service.shared.event.LibraryActivityEvent; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; @@ -63,6 +65,7 @@ public class AchievementSyncService { private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final SteamApiService steamApiService; private final SyncChangeRepository syncChangeRepository; + private final ApplicationEventPublisher eventPublisher; private final TransactionTemplate transactionTemplate; private final Duration schemaTtl; private final long pauseNanos; @@ -80,6 +83,7 @@ public class AchievementSyncService { UserGameAchievementMetaRepository userAchievementMetaRepository, SteamApiService steamApiService, SyncChangeRepository syncChangeRepository, + ApplicationEventPublisher eventPublisher, PlatformTransactionManager transactionManager, @Value("${app.library.schema-ttl-days:30}") long schemaTtlDays, // Half of the Steam rate limit, so requests of users still get through. @@ -91,6 +95,7 @@ public class AchievementSyncService { this.userAchievementMetaRepository = userAchievementMetaRepository; this.steamApiService = steamApiService; this.syncChangeRepository = syncChangeRepository; + this.eventPublisher = eventPublisher; this.transactionTemplate = new TransactionTemplate(transactionManager); this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); this.schemaTtl = Duration.ofDays(schemaTtlDays); @@ -121,6 +126,8 @@ public class AchievementSyncService { } } finally { runningUsers.remove(userId); + // Also after a failed run: the sync is committed, its followers must still hear of it. + eventPublisher.publishEvent(new LibraryActivityEvent(userId)); } // A request can arrive after the last remove() and before the user was released. if (!requestedRuns.containsKey(userId)) return; diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 67a07bf..646a328 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -127,8 +127,10 @@ public class LibrarySyncWorker { // Before the empty check: an account without games can still have a public wishlist. Optional wishlist = fetchWishlist(steamId, games); boolean saved = transaction.execute(tx -> { - wishlist.ifPresentOrElse(found -> saveWishlist(userId, found), - () -> log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId)); + List wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> { + log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId); + return List.of(); + }); if (games.isEmpty()) { // A private "Game details" setting ends here. A soft-delete would wipe the library. log.warn("Sync user={} got no games for SteamID {}", userId, steamId); @@ -140,6 +142,7 @@ public class LibrarySyncWorker { int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count(); syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of()); } else { + changes.addAll(wishlistAdds); syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes); } return true; @@ -222,7 +225,10 @@ public class LibrarySyncWorker { }); } - private void saveWishlist(Long userId, Wishlist wishlist) { + /** + * @return a WISHLIST change for each appid that was not on the wishlist before + */ + private List saveWishlist(Long userId, Wishlist wishlist) { // A failed store batch leaves apps out of `store`; they keep the name and price of the last sync. Map previous = wishlistGameRepository.findForUser(userId).stream() .collect(Collectors.toMap(WishlistGame::getAppid, w -> w)); @@ -239,6 +245,8 @@ public class LibrarySyncWorker { : new WishlistGame(userId, item.appid(), old.getName(), item.addedAt(), old.getPrice(), old.getOriginalPrice(), old.getDiscountPct()); }).toList()); + return wishlist.wanted().stream().map(SteamApiService.WishlistItem::appid) + .filter(appid -> !previous.containsKey(appid)).map(SyncChange::wishlist).toList(); } private void upsertGame(SteamOwnedGamesResponse.Game g, SteamGame existing, Instant now, Set correctedNames) { diff --git a/src/main/java/core_service/shared/event/LibraryActivityEvent.java b/src/main/java/core_service/shared/event/LibraryActivityEvent.java new file mode 100644 index 0000000..1458d13 --- /dev/null +++ b/src/main/java/core_service/shared/event/LibraryActivityEvent.java @@ -0,0 +1,7 @@ +package core_service.shared.event; + +/** + * The sync of the user and its achievement run are done. + */ +public record LibraryActivityEvent(long userId) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0cc0243..1cddc4d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,13 @@ app: rewards: # Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30). settle-cron: "0 30 2 * * *" + follow: + # The hand changes of a followed user are one batch after this quiet time. + digest-ms: 600000 + quiet-minutes: 10 + inbox: + purge-cron: "0 15 3 * * *" + keep-days: 90 account-cleanup: # Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it. dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true} diff --git a/src/main/resources/db/migration/V36__Follow.sql b/src/main/resources/db/migration/V36__Follow.sql new file mode 100644 index 0000000..ad1c3a3 --- /dev/null +++ b/src/main/resources/db/migration/V36__Follow.sql @@ -0,0 +1,36 @@ +-- Users with a public profile (#27), pushed by Auth like listed_user: a row means public. sections: the parts +-- of the profile the user shows (the names of the profile ticket); NULL from an Auth that does not send them +-- means all. The digest leaves out what the profile hides. +CREATE TABLE public_user (user_id BIGINT PRIMARY KEY, sections VARCHAR(16)[]); +-- user_id follows followed_user_id. Ends for good when the followed user goes private, is blocked or deleted. +CREATE TABLE follow ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + followed_user_id BIGINT NOT NULL CHECK (followed_user_id <> user_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, followed_user_id) +); +CREATE INDEX idx_follow_followed ON follow (followed_user_id); +-- How far the followers of user_id were told: the last reported sync result, status change and wishlist add +-- (sync_change.id: an add can join a result that was reported already). +CREATE TABLE follow_digest ( + user_id BIGINT PRIMARY KEY, + sync_started_at TIMESTAMPTZ, + status_change_id BIGINT NOT NULL DEFAULT 0, + wishlist_change_id BIGINT NOT NULL DEFAULT 0 +); +CREATE TABLE inbox_note ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, -- the reader + kind VARCHAR(16) NOT NULL CHECK (kind IN ('DIGEST', 'VISIBILITY')), + subject_user_id BIGINT NOT NULL, -- never sent to the client + subject_name VARCHAR(100), -- snapshot at write time + new_games INTEGER NOT NULL DEFAULT 0, + achievements INTEGER NOT NULL DEFAULT 0, + status_changes INTEGER NOT NULL DEFAULT 0, + wishlist_adds INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + read_at TIMESTAMPTZ +); +CREATE INDEX idx_inbox_note_user ON inbox_note (user_id, created_at DESC); +CREATE INDEX idx_inbox_note_subject ON inbox_note (subject_user_id); 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 0094c7b..9df670f 100644 --- a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java +++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java @@ -47,6 +47,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { 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 public_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); } @@ -145,6 +146,59 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { assertThat(jdbc.queryForList("select user_id from listed_user where user_id = 42", Long.class)).containsExactly(42L); } + @Test + void stateCallWithPublicProfileAndSections_StoresThem() throws Exception { + JsonNode call = ContractTokens.internalCall("applyStates"); + + send(contractRequest(call), call.path("request").toString(), serviceToken()).andExpect(status().isNoContent()); + + // The sample is a blocked user: blocked wins, no public row. + assertThat(jdbc.queryForList("select user_id from public_user where user_id = 42", Long.class)).isEmpty(); + send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + + ",\"blocked\":false,\"publicProfile\":true,\"profileSections\":[\"SUMMARY\",\"STATS\"]}]}", serviceToken()) + .andExpect(status().isNoContent()); + + assertThat(jdbc.queryForObject("select array_to_string(sections, ',') from public_user where user_id = ?", + String.class, USER_ID)).isEqualTo("SUMMARY,STATS"); + } + + @Test + void aLongUnknownSectionNameOfOneUserDoesNotRejectTheStatesOfOthers() throws Exception { + send(post("/internal/accounts/states"), "{\"accounts\":[" + + "{\"userId\":42,\"blocked\":false,\"publicProfile\":true,\"profileSections\":[\"SUMMARY\",\"ABCDEFGHIJKLMNOPQ\"]}," + + "{\"userId\":" + USER_ID + ",\"blocked\":true}]}", serviceToken()) + .andExpect(status().isNoContent()); + + assertThat(jdbc.queryForList("select user_id from blocked_user where user_id = ?", Long.class, USER_ID)) + .containsExactly(USER_ID); + assertThat(jdbc.queryForObject("select array_to_string(sections, ',') from public_user where user_id = 42", + String.class)).isEqualTo("SUMMARY"); + } + + @Test + void aNullSectionNameOfOneUserDoesNotRejectTheStatesOfOthers() throws Exception { + send(post("/internal/accounts/states"), "{\"accounts\":[" + + "{\"userId\":42,\"blocked\":false,\"publicProfile\":true,\"profileSections\":[\"SUMMARY\",null]}," + + "{\"userId\":" + USER_ID + ",\"blocked\":true}]}", serviceToken()) + .andExpect(status().isNoContent()); + + assertThat(jdbc.queryForList("select user_id from blocked_user where user_id = ?", Long.class, USER_ID)) + .containsExactly(USER_ID); + assertThat(jdbc.queryForObject("select array_to_string(sections, ',') from public_user where user_id = 42", + String.class)).isEqualTo("SUMMARY"); + } + + @Test + void stateCallWithoutPublicProfile_AnOldAuth_StillWorksAndKeepsTheRow() throws Exception { + jdbc.update("insert into public_user (user_id) values (?)", USER_ID); + + send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":false}]}", + serviceToken()).andExpect(status().isNoContent()); + + assertThat(jdbc.queryForList("select user_id from public_user where user_id = ?", Long.class, USER_ID)) + .containsExactly(USER_ID); + } + @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 7bfa8b0..84f8500 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", "listed_user"); + "reward_equipped", "listed_user", "public_user", "follow", "follow_digest", "inbox_note"); @Autowired private AccountCleanupRepository repository; @@ -51,6 +51,10 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { 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 public_user (user_id) values (?)", userId); + jdbc.update("insert into follow_digest (user_id) values (?)", userId); + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", userId, userId + 100); + jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (?, 'DIGEST', ?)", userId, userId + 100); 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); @@ -76,6 +80,29 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 2", Object.class)).isNotNull(); } + @Test + void addDeleted_EndsFollowsInBothDirectionsWithoutANote() { + jdbc.update("insert into follow (user_id, followed_user_id) values (2, 1)"); + + repository.addDeleted(List.of(1L)); + + assertThat(jdbc.queryForList("select user_id from follow where user_id in (1, 2) or followed_user_id in (1, 2)", + Long.class)).containsExactly(2L); + assertThat(rows("follow_digest", 1L)).isZero(); + assertThat(rows("public_user", 1L)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'VISIBILITY'", Integer.class)).isZero(); + } + + @Test + void deleteRows_AlsoDropsTheNotesAboutTheUser() { + jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (2, 'VISIBILITY', 1)"); + + repository.deleteRows(List.of(1L)); + + assertThat(jdbc.queryForObject("select count(*) from inbox_note where subject_user_id = 1", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from inbox_note where user_id = 2", Integer.class)).isEqualTo(1); + } + @Test void replaceDeleted_KeepsOnlyTheGivenUsers() { repository.addDeleted(List.of(1L, 2L)); @@ -110,7 +137,10 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { Map deletedRows = repository.deleteRows(List.of(1L)); - USER_TABLES.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1)); + // addDeleted already ended the follows and dropped the public profile. + List endedByAddDeleted = List.of("public_user", "follow", "follow_digest"); + USER_TABLES.stream().filter(table -> !endedByAddDeleted.contains(table)) + .forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1)); assertThat(deletedRows.get("deleted_user")).isEqualTo(1); USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isZero()); USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1)); 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 d30aa60..ce44e02 100644 --- a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java @@ -32,6 +32,9 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { void seed() { jdbc.update("delete from blocked_user"); jdbc.update("delete from listed_user"); + jdbc.update("delete from public_user"); + jdbc.update("delete from follow"); + jdbc.update("delete from inbox_note"); jdbc.update("delete from account_name"); jdbc.update("delete from account_role_change"); for (long userId : List.of(1L, 2L)) { @@ -51,24 +54,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null, null)); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null, null, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, 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, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null, null, 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, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null, 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, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null)); assertThat(blocked()).isEmpty(); } @@ -76,7 +79,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, null)); + repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null, null, null, null)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("last_synced_at")).isNull(); @@ -85,14 +88,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void sameSteamId_KeepsTheLastSync() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null)); assertThat(sync(1L).get("last_synced_at")).isNotNull(); } @Test void noSteamId_StopsTheNightlySync() { - repository.apply(new AccountState(1L, null, null, false, null, null)); + repository.apply(new AccountState(1L, null, null, false, null, null, null, null)); assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); @@ -100,7 +103,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void userWithoutASyncRow_GetsOnlyTheBlock() { - repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null, null)); + repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null, null, null, null)); assertThat(blocked()).containsExactly(3L); assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); @@ -108,10 +111,10 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void replaceAll_LiftsTheBlocksAuthNoLongerSends() { - 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.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null, null, null)); + repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null, null, null, null)); - repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null, null))); + repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null, null, null, null))); assertThat(blocked()).containsExactly(2L); repository.replaceAll(List.of()); @@ -128,13 +131,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, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null, null, null)); assertThat(changedAt(1L)).isEqualTo(first); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null)); assertThat(changedAt(1L)).isEqualTo(first); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null, null, null)); assertThat(changedAt(1L)).isEqualTo(second); } @@ -143,8 +146,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, null)); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null, null, null)); assertThat(changedAt(1L)).isEqualTo(second); } @@ -152,8 +155,8 @@ 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, null)); - repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change, null, null, null)); + repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, null, null, null)); assertThat(repository.findLoginState(1L)).isEqualTo(new LoginState(true, change)); assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE); @@ -166,33 +169,169 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @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)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true, null, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null)); assertThat(listed()).containsExactly(1L); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, false)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, false, null, null)); 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.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true, null, null)); + repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, true, null, null)); - repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, true), - new AccountState(3L, OLD_STEAM_ID, null, false, null, false))); + repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, true, null, null), + new AccountState(3L, OLD_STEAM_ID, null, false, null, false, null, null))); assertThat(listed()).containsExactly(2L); - repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, false))); + repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, false, null, null))); assertThat(listed()).isEmpty(); } @Test void replaceAll_WithAStateWithoutListing_KeepsTheListing() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true, null, null)); - repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, null))); + repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, null, null, null))); assertThat(listed()).containsExactly(1L); } + + private static AccountState publicState(long userId, Boolean publicProfile, List sections) { + return new AccountState(userId, OLD_STEAM_ID, null, false, null, null, publicProfile, sections); + } + + private void follows(long follower, long followed) { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", follower, followed); + } + + private List sections(long userId) { + return jdbc.query("select sections from public_user where user_id = ?", (rs, i) -> { + java.sql.Array array = rs.getArray("sections"); + try { + return array == null ? null : java.util.Arrays.asList((String[]) array.getArray()); + } catch (java.sql.SQLException e) { + throw new IllegalStateException(e); + } + }, userId).get(0); + } + + private List> notes() { + return jdbc.queryForList("select user_id, kind, subject_user_id, subject_name from inbox_note order by user_id"); + } + + @Test + void publicProfileFalse_EndsFollowsAndWritesOneVisibilityNotePerFollower() { + repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null, null, true, null)); + follows(2L, 1L); + follows(3L, 1L); + follows(1L, 4L); + + repository.apply(publicState(1L, false, null)); + + assertThat(jdbc.queryForList("select user_id from public_user", Long.class)).isEmpty(); + assertThat(jdbc.queryForList("select user_id from follow", Long.class)).containsExactly(1L); + assertThat(notes()).extracting(n -> n.get("user_id")).containsExactly(2L, 3L); + assertThat(notes()).allSatisfy(note -> { + assertThat(note.get("kind")).isEqualTo("VISIBILITY"); + assertThat(note.get("subject_user_id")).isEqualTo(1L); + assertThat(note.get("subject_name")).isEqualTo("lobo"); + }); + } + + @Test + void publicProfileFalseAgain_WritesNoSecondNote() { + repository.apply(publicState(1L, true, null)); + follows(2L, 1L); + + repository.apply(publicState(1L, false, null)); + repository.apply(publicState(1L, false, null)); + + assertThat(notes()).hasSize(1); + } + + @Test + void blocked_EndsFollowsInBothDirectionsWithoutANote() { + repository.apply(publicState(1L, true, null)); + follows(2L, 1L); + follows(1L, 3L); + follows(2L, 3L); + + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null, true, null)); + + assertThat(jdbc.queryForList("select user_id from public_user", Long.class)).isEmpty(); + assertThat(jdbc.queryForList("select user_id from follow", Long.class)).containsExactly(2L); + assertThat(notes()).isEmpty(); + } + + @Test + void publicProfileNull_AnOldAuth_KeepsTheRowItsSectionsAndTheFollows() { + repository.apply(publicState(1L, true, List.of("STATUS", "STATS"))); + follows(2L, 1L); + + repository.apply(publicState(1L, null, null)); + + assertThat(sections(1L)).containsExactly("STATUS", "STATS"); + assertThat(jdbc.queryForList("select user_id from follow", Long.class)).containsExactly(2L); + assertThat(notes()).isEmpty(); + } + + @Test + void publicProfileTrue_StoresTheSectionsAndAnotherPushReplacesThem() { + repository.apply(publicState(1L, true, List.of("SUMMARY", "STATUS"))); + assertThat(sections(1L)).containsExactly("SUMMARY", "STATUS"); + + repository.apply(publicState(1L, true, List.of("PLAYING"))); + + assertThat(sections(1L)).containsExactly("PLAYING"); + } + + @Test + void publicProfileTrueWithoutSections_StoresNull() { + repository.apply(publicState(1L, true, List.of("SUMMARY"))); + + repository.apply(publicState(1L, true, null)); + + assertThat(sections(1L)).isNull(); + } + + @Test + void replaceAll_ReplacesPublicUsersWithSectionsAndDropsFollowsOfOthersWithoutANote() { + repository.apply(publicState(1L, true, null)); + repository.apply(publicState(2L, true, null)); + follows(3L, 1L); + follows(3L, 2L); + + repository.replaceAll(List.of(publicState(2L, true, List.of("STATUS")), publicState(1L, false, null))); + + assertThat(jdbc.queryForList("select user_id from public_user", Long.class)).containsExactly(2L); + assertThat(sections(2L)).containsExactly("STATUS"); + assertThat(jdbc.queryForList("select followed_user_id from follow", Long.class)).containsExactly(2L); + // The note of the going-private push of user 1 comes from apply(), once. + assertThat(notes()).hasSize(1); + } + + @Test + void replaceAll_FollowsOfAUserMissingFromTheListEndSilently() { + repository.apply(publicState(1L, true, null)); + follows(3L, 1L); + + repository.replaceAll(List.of(publicState(2L, true, null))); + + assertThat(jdbc.queryForList("select user_id from follow", Long.class)).isEmpty(); + assertThat(notes()).isEmpty(); + } + + @Test + void replaceAll_WithAStateWithoutTheFlag_KeepsPublicUsersAndFollows() { + repository.apply(publicState(1L, true, null)); + follows(3L, 1L); + + repository.replaceAll(List.of(publicState(2L, null, null))); + + assertThat(jdbc.queryForList("select user_id from public_user", Long.class)).containsExactly(1L); + assertThat(jdbc.queryForList("select user_id from follow", Long.class)).containsExactly(3L); + } } diff --git a/src/test/java/core_service/modules/follow/api/FollowControllerTest.java b/src/test/java/core_service/modules/follow/api/FollowControllerTest.java new file mode 100644 index 0000000..5dc392b --- /dev/null +++ b/src/test/java/core_service/modules/follow/api/FollowControllerTest.java @@ -0,0 +1,209 @@ +package core_service.modules.follow.api; + +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +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.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +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 FollowControllerTest extends AbstractIntegrationTest { + + private static final long VIEWER = 930_001L; + private static final long LOBO = 930_002L; + private static final long SECRET = 930_003L; + private static final long BLOCKED = 930_004L; + private static final long DELETED = 930_005L; + private static final long OTHER = 930_006L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + cleanUp(); + name(VIEWER, "Viewer"); + name(LOBO, "Lobo"); + name(SECRET, "SecretThree"); + name(BLOCKED, "BlockedFour"); + name(DELETED, "DeletedFive"); + name(OTHER, "OtherSix"); + jdbc.update("insert into public_user (user_id) values (?), (?), (?), (?), (?)", VIEWER, LOBO, BLOCKED, DELETED, OTHER); + jdbc.update("insert into blocked_user (user_id) values (?)", BLOCKED); + jdbc.update("insert into deleted_user (user_id) values (?)", DELETED); + } + + @AfterEach + void cleanUp() { + jdbc.update("delete from follow"); + jdbc.update("delete from follow_digest"); + jdbc.update("delete from public_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 static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) { + return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId)); + } + + private int follows() { + return jdbc.queryForObject("select count(*) from follow", Integer.class); + } + + private JsonNode list(long userId) throws Exception { + String body = mockMvc.perform(as(get("/api/follows"), userId)).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return JsonMapper.builder().build().readTree(body); + } + + @Test + void withoutLogin_Is401() throws Exception { + mockMvc.perform(get("/api/follows")).andExpect(status().isUnauthorized()); + mockMvc.perform(put("/api/follows/by-name/Lobo").header("X-TBP-Client", "web")).andExpect(status().isUnauthorized()); + mockMvc.perform(delete("/api/follows/by-name/Lobo").header("X-TBP-Client", "web")).andExpect(status().isUnauthorized()); + mockMvc.perform(delete("/api/follows/1").header("X-TBP-Client", "web")).andExpect(status().isUnauthorized()); + } + + @Test + void follow_IsStoredAndSurvivesANewRequestAndIsFoundCaseInsensitive() throws Exception { + mockMvc.perform(as(put("/api/follows/by-name/lOBO"), VIEWER)).andExpect(status().isNoContent()); + + JsonNode json = list(VIEWER); + assertThat(json.get("following")).hasSize(1); + assertThat(json.get("following").get(0).get("displayName").asString()).isEqualTo("Lobo"); + assertThat(json.get("followers")).isEmpty(); + assertThat(jdbc.queryForList("select user_id from follow_digest", Long.class)).containsExactly(LOBO); + } + + @Test + void followTwice_IsOneRow() throws Exception { + mockMvc.perform(as(put("/api/follows/by-name/Lobo"), VIEWER)).andExpect(status().isNoContent()); + mockMvc.perform(as(put("/api/follows/by-name/Lobo"), VIEWER)).andExpect(status().isNoContent()); + + assertThat(follows()).isEqualTo(1); + } + + @Test + void followYourself_Is400() throws Exception { + mockMvc.perform(as(put("/api/follows/by-name/Viewer"), VIEWER)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("FOLLOW_SELF")); + assertThat(follows()).isZero(); + } + + @Test + void privateBlockedDeletedAndUnknown_AnswerTheSame404() throws Exception { + String unknown = mockMvc.perform(as(put("/api/follows/by-name/Nobody"), VIEWER)).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PROFILE_NOT_FOUND")).andReturn().getResponse().getContentAsString(); + + for (String name : List.of("SecretThree", "BlockedFour", "DeletedFive")) { + String body = mockMvc.perform(as(put("/api/follows/by-name/" + name), VIEWER)).andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + assertThat(body).as(name).isEqualTo(unknown); + } + assertThat(follows()).isZero(); + } + + @Test + void twoUsersWithOneName_Is404() throws Exception { + name(OTHER + 1, "lobo"); + jdbc.update("insert into public_user (user_id) values (?)", OTHER + 1); + + mockMvc.perform(as(put("/api/follows/by-name/Lobo"), VIEWER)).andExpect(status().isNotFound()); + } + + @Test + void followLimit_Is400() throws Exception { + jdbc.update("insert into follow (user_id, followed_user_id) select ?, ?::bigint + g from generate_series(1, 500) g", + VIEWER, 940_000L); + + mockMvc.perform(as(put("/api/follows/by-name/Lobo"), VIEWER)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("FOLLOW_LIMIT")); + } + + @Test + void nameLongerThan100_Is400() throws Exception { + mockMvc.perform(as(put("/api/follows/by-name/" + "x".repeat(101)), VIEWER)).andExpect(status().isBadRequest()); + } + + @Test + void unfollowByName_RemovesTheFollowAndUnknownNamesAnswer204() throws Exception { + mockMvc.perform(as(put("/api/follows/by-name/Lobo"), VIEWER)).andExpect(status().isNoContent()); + + mockMvc.perform(as(delete("/api/follows/by-name/Lobo"), VIEWER)).andExpect(status().isNoContent()); + mockMvc.perform(as(delete("/api/follows/by-name/Nobody"), VIEWER)).andExpect(status().isNoContent()); + + assertThat(follows()).isZero(); + } + + @Test + void deleteById_EndsOwnFollowsAndRemovesFollowers() throws Exception { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?), (?, ?)", VIEWER, LOBO, OTHER, VIEWER); + long following = jdbc.queryForObject("select id from follow where user_id = ?", Long.class, VIEWER); + long follower = jdbc.queryForObject("select id from follow where user_id = ?", Long.class, OTHER); + + mockMvc.perform(as(delete("/api/follows/" + following), VIEWER)).andExpect(status().isNoContent()); + mockMvc.perform(as(delete("/api/follows/" + follower), VIEWER)).andExpect(status().isNoContent()); + + assertThat(follows()).isZero(); + } + + // IDOR: the id of a row of two other users changes nothing. + @Test + void deleteById_OfAForeignFollow_Is204AndTheRowStays() throws Exception { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", OTHER, LOBO); + long foreign = jdbc.queryForObject("select id from follow", Long.class); + + mockMvc.perform(as(delete("/api/follows/" + foreign), VIEWER)).andExpect(status().isNoContent()); + + assertThat(follows()).isEqualTo(1); + } + + @Test + void followers_HidePrivateNamesAndNeverShowAUserId() throws Exception { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?), (?, ?)", LOBO, VIEWER, SECRET, VIEWER); + + JsonNode json = list(VIEWER); + + JsonNode followers = json.get("followers"); + assertThat(followers).hasSize(2); + assertThat(followers.get(0).get("displayName").asString()).isEqualTo("Lobo"); + assertThat(followers.get(1).get("displayName").isNull()).isTrue(); + assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET)); + Set keys = new HashSet<>(); + followers.get(0).propertyNames().forEach(keys::add); + assertThat(keys).containsExactlyInAnyOrder("id", "displayName"); + } +} diff --git a/src/test/java/core_service/modules/follow/domain/DigestTest.java b/src/test/java/core_service/modules/follow/domain/DigestTest.java new file mode 100644 index 0000000..fd3e8d9 --- /dev/null +++ b/src/test/java/core_service/modules/follow/domain/DigestTest.java @@ -0,0 +1,44 @@ +package core_service.modules.follow.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class DigestTest extends AbstractUnitTest { + + private static final Digest ALL = new Digest(12, 40, 3, 2); + + @Test + void visibleOn_NullSectionsLeavesEverything() { + assertThat(ALL.visibleOn(null)).isEqualTo(ALL); + } + + @Test + void visibleOn_SummaryKeepsGamesAndAchievementsButNotStatusChanges() { + assertThat(ALL.visibleOn(Set.of("SUMMARY"))).isEqualTo(new Digest(12, 40, 0, 2)); + } + + @Test + void visibleOn_EachStatusSectionKeepsStatusChanges() { + for (String section : List.of("STATUS", "PLAYING", "FINISHED")) { + assertThat(ALL.visibleOn(Set.of(section))).as(section).isEqualTo(new Digest(0, 0, 3, 2)); + } + } + + @Test + void visibleOn_OnlyAStatsSectionLeavesTheWishlistOnly() { + assertThat(ALL.visibleOn(Set.of("STATS"))).isEqualTo(new Digest(0, 0, 0, 2)); + assertThat(ALL.visibleOn(Set.of())).isEqualTo(new Digest(0, 0, 0, 2)); + } + + @Test + void isEmptyAndPlus() { + assertThat(new Digest(0, 0, 0, 0).isEmpty()).isTrue(); + assertThat(new Digest(0, 0, 0, 1).isEmpty()).isFalse(); + assertThat(new Digest(1, 2, 3, 4).plus(new Digest(1, 1, 1, 1))).isEqualTo(new Digest(2, 3, 4, 5)); + } +} diff --git a/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java b/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java new file mode 100644 index 0000000..81322a0 --- /dev/null +++ b/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java @@ -0,0 +1,354 @@ +package core_service.modules.follow.service; + +import core_service.modules.account.domain.AccountState; +import core_service.modules.account.repo.AccountCleanupRepository; +import core_service.modules.account.repo.AccountStateRepository; +import core_service.shared.event.LibraryActivityEvent; +import core_service.shared.test.AbstractIntegrationTest; +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.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class FollowDigestServiceTest extends AbstractIntegrationTest { + + private static final long FOLLOWED = 910_010L; + private static final long FOLLOWER_ONE = 910_011L; + private static final long FOLLOWER_TWO = 910_012L; + private static final long FIRST_APPID = 910_000L; + private static final Instant SYNC = Instant.now().minusSeconds(600).truncatedTo(ChronoUnit.MICROS); + + @Autowired + private FollowDigestService service; + + @Autowired + private AccountStateRepository accountStateRepository; + + @Autowired + private AccountCleanupRepository accountCleanupRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + cleanUp(); + jdbc.update("insert into account_name (user_id, display_name) values (?, 'Followed')", FOLLOWED); + jdbc.update("insert into public_user (user_id) values (?)", FOLLOWED); + for (long follower : List.of(FOLLOWER_ONE, FOLLOWER_TWO)) { + jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", follower, FOLLOWED); + } + jdbc.update("insert into follow_digest (user_id) values (?)", FOLLOWED); + } + + @AfterEach + void cleanUp() { + jdbc.update("delete from inbox_note"); + jdbc.update("delete from follow"); + jdbc.update("delete from follow_digest"); + jdbc.update("delete from public_user"); + jdbc.update("delete from account_name"); + jdbc.update("delete from blocked_user"); + jdbc.update("delete from deleted_user"); + jdbc.update("delete from sync_result"); + jdbc.update("delete from user_game where user_id = ?", FOLLOWED); + } + + private void games(int count) { + jdbc.update("insert into steam_game (appid, name) select ?::bigint + g, 'Game ' || g from generate_series(1, ?) g " + + "on conflict do nothing", FIRST_APPID, count); + jdbc.update("insert into user_game (user_id, appid, status) select ?, ?::bigint + g, 'BACKLOG' " + + "from generate_series(1, ?) g on conflict do nothing", FOLLOWED, FIRST_APPID, count); + } + + private void sync() { + jdbc.update("insert into sync_result (user_id, sync_started_at, started_by) values (?, ?, 'AUTO') " + + "on conflict (user_id) do update set sync_started_at = excluded.sync_started_at", FOLLOWED, Timestamp.from(SYNC)); + } + + private void change(String kind, long appid, Integer amount) { + jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount); + } + + private void added(int count) { + games(count); + sync(); + for (int i = 1; i <= count; i++) { + change("ADDED", FIRST_APPID + i, null); + } + } + + private void sections(String... sections) { + jdbc.update("update public_user set sections = ? where user_id = ?", + ps -> { + ps.setObject(1, sections); + ps.setLong(2, FOLLOWED); + }); + } + + private void event() { + service.onLibraryActivity(new LibraryActivityEvent(FOLLOWED)); + } + + private List> notes() { + return jdbc.queryForList("select user_id, new_games, achievements, status_changes, wishlist_adds " + + "from inbox_note where kind = 'DIGEST' order by user_id"); + } + + private Timestamp cursorSync() { + return jdbc.queryForObject("select sync_started_at from follow_digest where user_id = ?", Timestamp.class, FOLLOWED); + } + + @Test + void syncBatch_WritesOneNoteForEachFollowerWithTheCounts() { + added(12); + change("ACHIEVEMENTS", FIRST_APPID + 1, 25); + change("ACHIEVEMENTS", FIRST_APPID + 2, 15); + for (int i = 1; i <= 3; i++) { + change("STATUS", FIRST_APPID + i, null); + } + change("WISHLIST", 777_001L, null); + change("WISHLIST", 777_002L, null); + + event(); + + assertThat(notes()).hasSize(2).allSatisfy(note -> { + assertThat(note.get("new_games")).isEqualTo(12); + assertThat(note.get("achievements")).isEqualTo(40); + assertThat(note.get("status_changes")).isEqualTo(3); + assertThat(note.get("wishlist_adds")).isEqualTo(2); + }); + assertThat(notes()).extracting(note -> note.get("user_id")).containsExactly(FOLLOWER_ONE, FOLLOWER_TWO); + assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed"); + } + + @Test + void noActivity_WritesNoNoteButMovesTheCursor() { + sync(); + + event(); + + assertThat(notes()).isEmpty(); + assertThat(cursorSync()).isEqualTo(Timestamp.from(SYNC)); + } + + @Test + void bigBatch_IsStillOneNotePerFollower() { + added(300); + + event(); + + assertThat(notes()).hasSize(2); + assertThat(notes().get(0).get("new_games")).isEqualTo(300); + } + + @Test + void sameBatchTwice_WritesNoSecondNote() { + added(2); + + event(); + event(); + + assertThat(notes()).hasSize(2); + } + + @Test + void hiddenGame_IsNotCounted() { + added(3); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", FOLLOWED, FIRST_APPID + 1); + + event(); + + assertThat(notes().get(0).get("new_games")).isEqualTo(2); + } + + // The result of a sync can be kept for an unseen one: its new wishlist rows join it after it was reported. + @Test + void wishlistAddJoiningAReportedResult_IsReportedOnceAndOnlyOnce() { + added(1); + change("WISHLIST", 777_001L, null); + event(); + assertThat(notes()).extracting(note -> note.get("wishlist_adds")).containsExactly(1, 1); + + change("WISHLIST", 777_002L, null); + event(); + event(); + + assertThat(notes()).hasSize(4); + assertThat(jdbc.queryForObject("select count(*) from inbox_note where wishlist_adds = 1 and new_games = 0", + Integer.class)).isEqualTo(2); + } + + // Privacy: no note when the followed user is not public, blocked or deleted, or the follower is deleted. + + @Test + void followedUserWithoutAPublicProfile_GetsNoNote() { + added(2); + jdbc.update("delete from public_user"); + + event(); + + assertThat(notes()).isEmpty(); + } + + @Test + void blockedFollowedUser_GetsNoNote() { + added(2); + jdbc.update("insert into blocked_user (user_id) values (?)", FOLLOWED); + + event(); + + assertThat(notes()).isEmpty(); + } + + @Test + void deletedFollowedUser_GetsNoNote() { + added(2); + jdbc.update("insert into deleted_user (user_id) values (?)", FOLLOWED); + + event(); + + assertThat(notes()).isEmpty(); + } + + @Test + void deletedFollower_GetsNoNoteAndTheOtherStillDoes() { + added(2); + jdbc.update("insert into deleted_user (user_id) values (?)", FOLLOWER_ONE); + + event(); + + assertThat(notes()).extracting(note -> note.get("user_id")).containsExactly(FOLLOWER_TWO); + } + + @Test + void goingPrivateAndPublicAgain_LeavesNoFollowerAndNoDigest() { + accountStateRepository.apply(new AccountState(FOLLOWED, null, null, false, null, null, false, null)); + accountStateRepository.apply(new AccountState(FOLLOWED, null, null, false, null, null, true, null)); + added(2); + + event(); + + assertThat(notes()).isEmpty(); + assertThat(jdbc.queryForObject("select count(*) from follow", Integer.class)).isZero(); + } + + @Test + void followedUserDeletedByCleanup_GetsNoNote() { + added(2); + accountCleanupRepository.addDeleted(List.of(FOLLOWED)); + + event(); + + assertThat(notes()).isEmpty(); + } + + // Decision 1: a kind that the profile hides is left out. + + @Test + void statusSectionOnly_DropsGamesAndAchievementsAndKeepsStatusChangesAndWishlist() { + added(4); + change("ACHIEVEMENTS", FIRST_APPID + 1, 9); + change("STATUS", FIRST_APPID + 2, null); + change("STATUS", FIRST_APPID + 3, null); + change("WISHLIST", 777_001L, null); + sections("STATUS"); + + event(); + + assertThat(notes()).hasSize(2).allSatisfy(note -> { + assertThat(note.get("new_games")).isEqualTo(0); + assertThat(note.get("achievements")).isEqualTo(0); + assertThat(note.get("status_changes")).isEqualTo(2); + assertThat(note.get("wishlist_adds")).isEqualTo(1); + }); + } + + @Test + void statsSectionOnly_WritesNoNoteAndTheHiddenKindIsNeverReportedLater() { + added(3); + sections("STATS"); + + event(); + + assertThat(notes()).isEmpty(); + assertThat(cursorSync()).isEqualTo(Timestamp.from(SYNC)); + + sections("SUMMARY"); + event(); + + assertThat(notes()).isEmpty(); + } + + @Test + void sectionsNull_CountsEverything() { + added(2); + change("ACHIEVEMENTS", FIRST_APPID + 1, 5); + change("STATUS", FIRST_APPID + 1, null); + + event(); + + assertThat(notes().get(0)).containsEntry("new_games", 2).containsEntry("achievements", 5) + .containsEntry("status_changes", 1); + } + + // Hand changes: one batch after a quiet time; the hand job never takes the sync result. + + private void handGame(long appid, long ageMinutes) { + jdbc.update("insert into steam_game (appid, name) values (?, 'Hand') on conflict do nothing", appid); + jdbc.update("insert into user_game (user_id, appid, status, status_set_by_user) values (?, ?, 'BACKLOG', true)", + FOLLOWED, appid); + jdbc.update("update user_game_status_change set changed_at = now() - make_interval(mins => ?) " + + "where user_id = ? and appid = ?", (int) ageMinutes, FOLLOWED, appid); + } + + @Test + void handChangeYoungerThanTheQuietTime_WritesNoNote() { + handGame(920_001L, 1); + + service.digestHandChanges(); + + assertThat(notes()).isEmpty(); + } + + @Test + void handChangeOlderThanTheQuietTime_WritesOneNotePerFollowerOnce() { + handGame(920_001L, 30); + handGame(920_002L, 30); + + service.digestHandChanges(); + service.digestHandChanges(); + + assertThat(notes()).hasSize(2).allSatisfy(note -> assertThat(note.get("new_games")).isEqualTo(2)); + } + + @Test + void handJob_LeavesAnUnreportedSyncResultForTheEvent() { + added(2); + change("ACHIEVEMENTS", FIRST_APPID + 1, 7); + handGame(920_001L, 30); + + service.digestHandChanges(); + + assertThat(notes()).hasSize(2).allSatisfy(note -> { + assertThat(note.get("new_games")).isEqualTo(1); + assertThat(note.get("achievements")).isEqualTo(0); + }); + assertThat(cursorSync()).isNull(); + + event(); + + assertThat(notes()).hasSize(4); + assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2", + Integer.class)).isEqualTo(2); + } +} diff --git a/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java b/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java new file mode 100644 index 0000000..4fbe9ca --- /dev/null +++ b/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java @@ -0,0 +1,98 @@ +package core_service.modules.inbox.api; + +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +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.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class InboxControllerTest extends AbstractIntegrationTest { + + private static final long READER = 931_001L; + private static final long OTHER = 931_002L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void cleanUp() { + jdbc.update("delete from inbox_note"); + } + + private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) { + return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId)); + } + + private void note(long userId, String name, int minutesAgo, int newGames) { + jdbc.update("insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, created_at) " + + "values (?, 'DIGEST', 7, ?, ?, now() - make_interval(mins => ?))", userId, name, newGames, minutesAgo); + } + + @Test + void withoutLogin_Is401() throws Exception { + mockMvc.perform(get("/api/inbox")).andExpect(status().isUnauthorized()); + mockMvc.perform(put("/api/inbox/read").header("X-TBP-Client", "web")) + .andExpect(status().isUnauthorized()); + } + + @Test + void inbox_ShowsOwnNotesNewestFirstWithoutAUserId() throws Exception { + note(READER, "Older", 10, 1); + note(READER, "Newer", 1, 2); + note(OTHER, "NotMine", 1, 3); + + String body = mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.results.length()").value(2)) + .andExpect(jsonPath("$.results[0].displayName").value("Newer")) + .andExpect(jsonPath("$.results[0].kind").value("DIGEST")) + .andExpect(jsonPath("$.results[0].newGames").value(2)) + .andExpect(jsonPath("$.results[0].read").value(false)) + .andExpect(jsonPath("$.results[1].displayName").value("Older")) + .andExpect(jsonPath("$.unread").value(2)) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain("NotMine", "subject", "userId"); + } + + @Test + void inbox_ListsAtMost50ButCountsAllUnread() throws Exception { + for (int i = 0; i < 55; i++) { + note(READER, "Note" + i, i, 1); + } + + mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.results.length()").value(50)) + .andExpect(jsonPath("$.results[0].displayName").value("Note0")) + .andExpect(jsonPath("$.unread").value(55)); + } + + @Test + void markRead_ClearsTheUnreadCountOfTheCallerOnly() throws Exception { + note(READER, "Mine", 1, 1); + note(OTHER, "Theirs", 1, 1); + + mockMvc.perform(as(put("/api/inbox/read"), READER)).andExpect(status().isNoContent()); + + mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.unread").value(0)) + .andExpect(jsonPath("$.results[0].read").value(true)); + mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1)); + } +} diff --git a/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java b/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java new file mode 100644 index 0000000..519d834 --- /dev/null +++ b/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java @@ -0,0 +1,46 @@ +package core_service.modules.inbox.repo; + +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(InboxRepository.class) +class InboxRepositoryTest extends AbstractPersistenceTest { + + @Autowired + private InboxRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + private void note(long id, int daysAgo, boolean read) { + jdbc.update("insert into inbox_note (id, user_id, kind, subject_user_id, created_at, read_at) " + + "values (?, 1, 'DIGEST', 2, now() - make_interval(days => ?), case when ? then now() end)", + id, daysAgo, read); + } + + private java.util.List ids() { + return jdbc.queryForList("select id from inbox_note order by id", Long.class); + } + + @Test + void deleteBefore_RemovesNotesOlderThan90DaysReadOrUnreadAndKeepsYoungerOnes() { + jdbc.update("delete from inbox_note"); + note(1, 91, true); + note(2, 91, false); + note(3, 89, false); + note(4, 89, true); + + int deleted = repository.deleteBefore(Instant.now().minus(Duration.ofDays(90))); + + assertThat(deleted).isEqualTo(2); + assertThat(ids()).containsExactly(3L, 4L); + } +} diff --git a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java index 6fc1043..70c2873 100644 --- a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java @@ -258,4 +258,71 @@ class SyncChangeRepositoryTest extends AbstractPersistenceTest { assertThat(stored().seen()).isFalse(); } + + @Test + void find_LeavesWishlistRowsOut() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, + List.of(SyncChange.added(HALF_LIFE), SyncChange.wishlist(PORTAL))); + + assertThat(rows()).isEqualTo(2); + assertThat(stored().changes()).extracting(e -> e.change()).containsExactly(SyncChange.added(HALF_LIFE)); + } + + // A manual game can also be on the Steam wishlist: the banner must never get the new kind for it. + @Test + void find_WishlistRowOfAnOwnedGameIsStillLeftOut() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.wishlist(HALF_LIFE))); + + assertThat(stored().changes()).isEmpty(); + } + + @Test + void replace_WishlistOnlyResultOverAnUnseenWishlistOnlyOne_ReplacesIt() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.wishlist(PORTAL))); + claim(USER_ID, SECOND_RUN); + + boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.wishlist(HALF_LIFE))); + + assertThat(written).isTrue(); + assertThat(stored().syncedAt()).isEqualTo(SECOND_RUN); + } + + @Test + void replace_WishlistOnlyResultOverAnUnseenOneWithChanges_KeepsThatResult() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE))); + claim(USER_ID, SECOND_RUN); + + boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.wishlist(PORTAL))); + + assertThat(written).isFalse(); + assertThat(stored().changes()).extracting(e -> e.change()).containsExactly(SyncChange.added(HALF_LIFE)); + } + + // The digest reads WISHLIST rows by id, so an add that meets a kept result must still be stored. + @Test + void replace_WishlistOnlyResultOverAnUnseenOneWithChanges_StillStoresTheWishlistAdds() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE))); + claim(USER_ID, SECOND_RUN); + + repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.wishlist(PORTAL))); + + assertThat(jdbc.queryForList("select appid from sync_change where user_id = ? and kind = 'WISHLIST'", Long.class, + USER_ID)).containsExactly(PORTAL); + } + + @Test + void replace_RunThatLostItsClaim_StoresNoWishlistAdds() { + claim(USER_ID, FIRST_RUN); + repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE))); + claim(USER_ID, SECOND_RUN); + + repository.replace(USER_ID, FIRST_RUN.minusSeconds(5), SyncTrigger.AUTO, 0, List.of(SyncChange.wishlist(PORTAL))); + + assertThat(rows()).isEqualTo(1); + } } diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index 558ea00..9617b24 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -16,11 +16,13 @@ import core_service.modules.library.repo.UserGameRepository; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.service.SteamApiService; +import core_service.shared.event.LibraryActivityEvent; import core_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.web.client.ResourceAccessException; @@ -71,6 +73,8 @@ class AchievementSyncServiceTest extends AbstractUnitTest { private SteamApiService steamApiService; @Mock private SyncChangeRepository syncChangeRepository; + @Mock + private ApplicationEventPublisher eventPublisher; private AchievementSyncService service; @@ -78,7 +82,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest { void setUp() { service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository, - mock(PlatformTransactionManager.class), 30, 0); + eventPublisher, mock(PlatformTransactionManager.class), 30, 0); } private static UserGame game(long appid, int playtime) { @@ -301,6 +305,40 @@ class AchievementSyncServiceTest extends AbstractUnitTest { verify(userAchievementRepository, never()).deleteByUserIdAndAppid(anyLong(), anyLong()); } + @Test + void syncLibrary_PublishesTheActivityEventOnceAfterTheRun() { + library(List.of(game(620L, 60)), List.of(schema(620L, 10)), List.of()); + when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(visible()); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(eventPublisher, times(1)).publishEvent(new LibraryActivityEvent(USER_ID)); + } + + @Test + void syncLibrary_PublishesTheActivityEventAlsoAfterAnEarlyStop() { + List games = LongStream.rangeClosed(1, 6).mapToObj(appid -> game(appid, 60)).toList(); + library(games, LongStream.rangeClosed(1, 6).mapToObj(appid -> schema(appid, 10)).toList(), List.of()); + when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenThrow(new ResourceAccessException("down")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(eventPublisher, times(1)).publishEvent(new LibraryActivityEvent(USER_ID)); + } + + @Test + void syncLibrary_PublishesTheActivityEventAlsoWhenTheRunThrows() { + when(userGameRepository.findListedSteamGames(USER_ID)).thenThrow(new IllegalStateException("db gone")); + + try { + service.syncLibrary(USER_ID, STEAM_ID); + } catch (IllegalStateException ignored) { + // the exception itself is pre-existing behavior; the finding is the missing event + } + + verify(eventPublisher, times(1)).publishEvent(new LibraryActivityEvent(USER_ID)); + } + @Test void syncLibrary_StopsWhenSteamHidesTheFirstGames() { List games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList(); @@ -381,7 +419,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest { long pauseMillis = 300; AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository, - mock(PlatformTransactionManager.class), 30, pauseMillis); + eventPublisher, mock(PlatformTransactionManager.class), 30, pauseMillis); when(userGameRepository.findListedSteamGames(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now()))); when(userGameRepository.findListedSteamGames(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now()))); when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 1e60c08..79376fe 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -468,6 +468,33 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { assertEquals(added, wish.getAddedAt()); } + @Test + void runSync_NewWishlistAppids_BecomeWishlistChanges() { + owned(0); + steamReturns(game(0, 0), STEAM_ID); + when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of( + new SteamApiService.WishlistItem(1257610L, null), new SteamApiService.WishlistItem(1000L, null)))); + when(steamApiService.getStoreItems(any())).thenReturn(Map.of()); + when(wishlistGameRepository.findForUser(USER_ID)).thenReturn(List.of( + new WishlistGame(USER_ID, 1000L, "Old wish", null, null, null, 0))); + + worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); + + assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactly(SyncChange.wishlist(1257610L)); + } + + @Test + void runSync_BaselineSync_WritesNoWishlistChanges() { + steamReturns(game(0, 0), null); + when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of( + new SteamApiService.WishlistItem(1257610L, null)))); + when(steamApiService.getStoreItems(any())).thenReturn(Map.of()); + + worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); + + assertThat(replacedChanges(SyncTrigger.AUTO, 1)).isEmpty(); + } + @Test void runSync_WishlistError_KeepsTheOldWishlistAndTheLibrarySyncIsOk() { steamReturns(game(0, 0), STEAM_ID); diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index 79e7874..3b24994 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference; // import watchdog runs only when a test calls it. // Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients. @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.rewards.settle-cron=-", "app.speedrun.enabled=false", + properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.inbox.purge-cron=-", "app.follow.digest-ms=86400000", "app.rewards.settle-cron=-", "app.speedrun.enabled=false", "app.library.import-watchdog-ms=86400000", "spring.datasource.hikari.maximum-pool-size=4"}) @ActiveProfiles("dev") diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index 5fd06af..07164d0 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", "listed": true}]} + "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true, "publicProfile": true, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]} }, "replaceStates": { "method": "PUT", "path": "/internal/accounts/states", - "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false}]} + "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false, "publicProfile": false, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]} }, "purge": { "method": "POST",