feat(follow): add user follows with activity inbox digest
Build, Test & Push Core Service / quality-check (push) Successful in 1m26s
Build, Test & Push Core Service / docker (push) Successful in 1m31s

This commit is contained in:
lobo committed 2026-10-06 16:22:22 +02:00
1 parent 2dba863c54
commit 867dc4c4d1
45 files changed
+2117 -68

No files matched your search

+47 -4
View File
@@ -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`). 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`, 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. `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, - **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 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 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/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/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login |
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
| `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/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 | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -238,6 +249,8 @@ flowchart LR
Stats["stats"] Stats["stats"]
Version["version"] Version["version"]
Account["account"] Account["account"]
Follow["follow"]
Inbox["inbox"]
Rewards["rewards"] Rewards["rewards"]
Steam["steam_adapter"] Steam["steam_adapter"]
Igdb["igdb_adapter"] Igdb["igdb_adapter"]
@@ -256,8 +269,10 @@ flowchart LR
Speedrun --> SpeedrunApi["speedrun.com API"] Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")] LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
Inbox --> Db
Auth -->|"service token<br/>/internal/accounts"| Account Auth -->|"service token<br/>/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 | | Module | Content |
@@ -265,11 +280,16 @@ flowchart LR
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `version` | `GET /api/version`: commit and build time of the running image |
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, 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 | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
`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 **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` SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
@@ -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. 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 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. `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 flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
user ids, which removes the rows of users Auth does not know (refused for more than user ids, which removes the rows of users Auth does not know (refused for more than
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. `app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
The 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`). The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
### Blocked users and Steam ID changes ### Blocked users and Steam ID changes
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
took by a verified link, a display name change, a block, 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 `blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`), user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
@@ -65,7 +65,8 @@ public class InternalAccountController {
private static List<AccountState> toStates(AccountStatesRequest request) { private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream() return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), .map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
account.blocked(), account.roleChangedAt(), account.listed())) account.blocked(), account.roleChangedAt(), account.listed(), account.publicProfile(),
account.profileSections()))
.toList(); .toList();
} }
@@ -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 displayName null from an Auth that does not send it yet
* @param roleChangedAt null from an Auth that does not send it yet * @param roleChangedAt null from an Auth that does not send it yet
* @param listed null from an Auth that does not send it yet * @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, 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<String> profileSections) {
} }
} }
@@ -1,6 +1,8 @@
package core_service.modules.account.domain; package core_service.modules.account.domain;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Set;
/** /**
* What Core copies from Auth for one user. * 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 displayName null when Auth did not send one; Core then keeps the stored name
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value * @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
* @param listed null from an Auth that does not send it; Core then keeps the stored listing * @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, public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
Boolean listed) { Boolean listed, Boolean publicProfile, List<String> profileSections) {
// One odd entry must not fail the push of every user, so unknown names are dropped, not refused.
private static final Set<String> SECTIONS = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
public AccountState {
profileSections = profileSections == null ? null : profileSections.stream().filter(section -> section != null && SECTIONS.contains(section)).toList();
}
} }
@@ -23,11 +23,11 @@ public class AccountCleanupRepository {
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement", static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "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. // Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
"game_import", "blocked_user", "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; 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<Long> userIds) { public void addDeleted(Collection<Long> userIds) {
if (userIds.isEmpty()) { if (userIds.isEmpty()) {
@@ -45,6 +45,9 @@ public class AccountCleanupRepository {
jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing", 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)); 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("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) { for (String table : TABLES) {
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds))); 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. // The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user.
jdbc.update(""" jdbc.update("""
update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}', update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}',
@@ -12,8 +12,9 @@ import java.util.Collection;
import java.util.List; import java.util.List;
/** /**
* Blocks in blocked_user, listing in listed_user, role changes in account_role_change, the display name in * Blocks in blocked_user, listing in listed_user, public profiles in public_user (and the follows that end with them),
* account_name, and what a Steam ID change means for user_steam_sync. * 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. * Plain SQL, because the library module owns the entities.
*/ */
@Repository @Repository
@@ -46,6 +47,7 @@ public class AccountStateRepository {
} else if (Boolean.FALSE.equals(state.listed())) { } else if (Boolean.FALSE.equals(state.listed())) {
jdbc.update("delete from listed_user where user_id = :userId", params); jdbc.update("delete from listed_user where user_id = :userId", params);
} }
applyPublicProfile(state, params);
if (state.blocked() || state.steamId() == null) { if (state.blocked() || state.steamId() == null) {
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null", jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
params); params);
@@ -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) { public LoginState findLoginState(long userId) {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select exists(select 1 from blocked_user where user_id = :userId) as blocked, 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 * After this, exactly the blocked users of the list are blocked, and the listed and public ones listed and public
* listing. * unless a state has no such flag.
*/ */
public void replaceAll(Collection<AccountState> states) { public void replaceAll(Collection<AccountState> states) {
states.forEach(this::apply); states.forEach(this::apply);
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList(); keepOnly("blocked_user", 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));
}
if (states.stream().noneMatch(state -> state.listed() == null)) { if (states.stream().noneMatch(state -> state.listed() == null)) {
List<Long> listed = states.stream().filter(AccountState::listed).map(AccountState::userId).toList(); keepOnly("listed_user", states.stream().filter(AccountState::listed).map(AccountState::userId).toList());
if (listed.isEmpty()) { }
jdbc.update("delete from listed_user", new MapSqlParameterSource()); 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<Long> userIds) {
if (userIds.isEmpty()) {
jdbc.update("delete from " + table, new MapSqlParameterSource());
} else { } else {
jdbc.update("delete from listed_user where user_id not in (:ids)", new MapSqlParameterSource("ids", listed)); jdbc.update("delete from " + table + " where user_id not in (:ids)", new MapSqlParameterSource("ids", userIds));
}
} }
} }
} }
@@ -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<FollowsDto> 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<Void> 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<Void> remove(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
followService.remove(principal.getIdAsLong(), id);
return ResponseEntity.noContent().build();
}
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) {
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList();
}
}
@@ -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<Person> following, List<Person> followers) {
/**
* @param id the follow row, to end it; not a user id
* @param displayName null for a follower with a private profile
*/
public record Person(long id, String displayName) {
}
}
@@ -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) {
}
@@ -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<String> 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<String> 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);
}
}
@@ -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) {
}
@@ -0,0 +1,8 @@
package core_service.modules.follow.domain;
public enum FollowResult {
FOLLOWED,
NOT_FOUND,
SELF,
LIMIT
}
@@ -0,0 +1,6 @@
package core_service.modules.follow.domain;
import java.util.List;
public record Follows(List<FollowEntry> following, List<FollowEntry> followers) {
}
@@ -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<Cursor> 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<Instant> 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<String> 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<Long> 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);
}
}
@@ -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<FollowEntry> 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<Long> findFollowable(String displayName) {
List<Long> 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<FollowEntry> 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<FollowEntry> findFollowers(long userId) {
return jdbc.query("""
select f.id, case when p.user_id is not null then a.display_name end as display_name
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);
}
}
@@ -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<Long> 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<Cursor> 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<Instant> 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);
});
}
}
@@ -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<Long> 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));
}
}
@@ -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<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) {
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(),
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read()))
.toList(), inbox.unread()));
}
@PutMapping("/read")
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
inboxService.markAllRead(principal.getIdAsLong());
return ResponseEntity.noContent().build();
}
}
@@ -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<Note> 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) {
}
}
@@ -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<InboxNote> notes, int unread) {
}
@@ -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) {
}
@@ -0,0 +1,6 @@
package core_service.modules.inbox.domain;
public enum NoteKind {
DIGEST,
VISIBILITY
}
@@ -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<InboxNote> 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)));
}
}
@@ -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);
}
}
}
@@ -26,4 +26,8 @@ public record SyncChange(SyncChangeKind kind, long appid, Integer amount, GameSt
public static SyncChange status(long appid, GameStatus from, GameStatus to) { public static SyncChange status(long appid, GameStatus from, GameStatus to) {
return new SyncChange(SyncChangeKind.STATUS, appid, null, from, 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);
}
} }
@@ -5,5 +5,7 @@ public enum SyncChangeKind {
REMOVED, REMOVED,
PLAYTIME, PLAYTIME,
ACHIEVEMENTS, ACHIEVEMENTS,
STATUS STATUS,
// Only for followers' digests: never shown in the sync banner.
WISHLIST
} }
@@ -34,7 +34,7 @@ public class SyncChangeRepository {
*/ */
public boolean replace(long userId, Instant syncStartedAt, SyncTrigger startedBy, int importedGames, public boolean replace(long userId, Instant syncStartedAt, SyncTrigger startedBy, int importedGames,
List<SyncChange> changes) { List<SyncChange> changes) {
boolean empty = changes.isEmpty() && importedGames == 0; boolean empty = importedGames == 0 && changes.stream().allMatch(change -> change.kind() == SyncChangeKind.WISHLIST);
int written = jdbc.update(""" int written = jdbc.update("""
insert into sync_result (user_id, sync_started_at, started_by, imported_games, seen_at) 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 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, set sync_started_at = excluded.sync_started_at, started_by = excluded.started_by,
imported_games = excluded.imported_games, seen_at = null imported_games = excluded.imported_games, seen_at = null
where not (:empty and sync_result.seen_at is null and (sync_result.imported_games > 0 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) """, new MapSqlParameterSource("userId", userId)
.addValue("startedAt", Timestamp.from(syncStartedAt)) .addValue("startedAt", Timestamp.from(syncStartedAt))
.addValue("startedBy", startedBy.name()) .addValue("startedBy", startedBy.name())
.addValue("importedGames", importedGames) .addValue("importedGames", importedGames)
.addValue("empty", empty)); .addValue("empty", empty));
if (written == 0) { if (written == 0) {
keepWishlistAdds(userId, syncStartedAt, changes);
return false; return false;
} }
jdbc.update("delete from sync_change where user_id = :userId", new MapSqlParameterSource("userId", userId)); jdbc.update("delete from sync_change where user_id = :userId", new MapSqlParameterSource("userId", userId));
@@ -60,6 +61,22 @@ public class SyncChangeRepository {
return true; 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<SyncChange> changes) {
List<SyncChange> 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 * 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 * transaction, like in {@link UserSteamSyncRepository#markHistoryBaseline}: call it in the transaction that made
@@ -114,7 +131,7 @@ public class SyncChangeRepository {
from sync_change c from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid 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 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 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 order by c.kind, amount desc nulls last, sg.name
""", params, (rs, i) -> new SyncChanges.Entry( """, params, (rs, i) -> new SyncChanges.Entry(
@@ -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.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.background.BackgroundPools; import core_service.shared.background.BackgroundPools;
import core_service.shared.event.LibraryActivityEvent;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
@@ -63,6 +65,7 @@ public class AchievementSyncService {
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final SteamApiService steamApiService; private final SteamApiService steamApiService;
private final SyncChangeRepository syncChangeRepository; private final SyncChangeRepository syncChangeRepository;
private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final Duration schemaTtl; private final Duration schemaTtl;
private final long pauseNanos; private final long pauseNanos;
@@ -80,6 +83,7 @@ public class AchievementSyncService {
UserGameAchievementMetaRepository userAchievementMetaRepository, UserGameAchievementMetaRepository userAchievementMetaRepository,
SteamApiService steamApiService, SteamApiService steamApiService,
SyncChangeRepository syncChangeRepository, SyncChangeRepository syncChangeRepository,
ApplicationEventPublisher eventPublisher,
PlatformTransactionManager transactionManager, PlatformTransactionManager transactionManager,
@Value("${app.library.schema-ttl-days:30}") long schemaTtlDays, @Value("${app.library.schema-ttl-days:30}") long schemaTtlDays,
// Half of the Steam rate limit, so requests of users still get through. // Half of the Steam rate limit, so requests of users still get through.
@@ -91,6 +95,7 @@ public class AchievementSyncService {
this.userAchievementMetaRepository = userAchievementMetaRepository; this.userAchievementMetaRepository = userAchievementMetaRepository;
this.steamApiService = steamApiService; this.steamApiService = steamApiService;
this.syncChangeRepository = syncChangeRepository; this.syncChangeRepository = syncChangeRepository;
this.eventPublisher = eventPublisher;
this.transactionTemplate = new TransactionTemplate(transactionManager); this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.schemaTtl = Duration.ofDays(schemaTtlDays); this.schemaTtl = Duration.ofDays(schemaTtlDays);
@@ -121,6 +126,8 @@ public class AchievementSyncService {
} }
} finally { } finally {
runningUsers.remove(userId); 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. // A request can arrive after the last remove() and before the user was released.
if (!requestedRuns.containsKey(userId)) return; if (!requestedRuns.containsKey(userId)) return;
@@ -127,8 +127,10 @@ public class LibrarySyncWorker {
// Before the empty check: an account without games can still have a public wishlist. // Before the empty check: an account without games can still have a public wishlist.
Optional<Wishlist> wishlist = fetchWishlist(steamId, games); Optional<Wishlist> wishlist = fetchWishlist(steamId, games);
boolean saved = transaction.execute(tx -> { boolean saved = transaction.execute(tx -> {
wishlist.ifPresentOrElse(found -> saveWishlist(userId, found), List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
() -> log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId)); log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
return List.of();
});
if (games.isEmpty()) { if (games.isEmpty()) {
// A private "Game details" setting ends here. A soft-delete would wipe the library. // 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); 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(); int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count();
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of()); syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of());
} else { } else {
changes.addAll(wishlistAdds);
syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes); syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes);
} }
return true; 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<SyncChange> saveWishlist(Long userId, Wishlist wishlist) {
// A failed store batch leaves apps out of `store`; they keep the name and price of the last sync. // A failed store batch leaves apps out of `store`; they keep the name and price of the last sync.
Map<Long, WishlistGame> previous = wishlistGameRepository.findForUser(userId).stream() Map<Long, WishlistGame> previous = wishlistGameRepository.findForUser(userId).stream()
.collect(Collectors.toMap(WishlistGame::getAppid, w -> w)); .collect(Collectors.toMap(WishlistGame::getAppid, w -> w));
@@ -239,6 +245,8 @@ public class LibrarySyncWorker {
: new WishlistGame(userId, item.appid(), old.getName(), item.addedAt(), : new WishlistGame(userId, item.appid(), old.getName(), item.addedAt(),
old.getPrice(), old.getOriginalPrice(), old.getDiscountPct()); old.getPrice(), old.getOriginalPrice(), old.getDiscountPct());
}).toList()); }).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<Long> correctedNames) { private void upsertGame(SteamOwnedGamesResponse.Game g, SteamGame existing, Instant now, Set<Long> correctedNames) {
@@ -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) {
}
+7
View File
@@ -75,6 +75,13 @@ app:
rewards: rewards:
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30). # Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
settle-cron: "0 30 2 * * *" 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: account-cleanup:
# Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it. # 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} dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
@@ -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);
@@ -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 deleted_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from listed_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from 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_name where user_id in (?, 42)", USER_ID);
jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID); jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID);
} }
@@ -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); 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 @Test
void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception { void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception {
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}", send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}",
@@ -18,7 +18,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement", private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "listed_user"); "reward_equipped", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired @Autowired
private AccountCleanupRepository repository; 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 game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId); jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
jdbc.update("insert into listed_user (user_id) values (?)", userId); jdbc.update("insert into 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 game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId); jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId); jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
@@ -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(); 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 @Test
void replaceDeleted_KeepsOnlyTheGivenUsers() { void replaceDeleted_KeepsOnlyTheGivenUsers() {
repository.addDeleted(List.of(1L, 2L)); repository.addDeleted(List.of(1L, 2L));
@@ -110,7 +137,10 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L)); Map<String, Integer> 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<String> 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); 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, 1L)).as(table).isZero());
USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1)); USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1));
@@ -32,6 +32,9 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
void seed() { void seed() {
jdbc.update("delete from blocked_user"); jdbc.update("delete from blocked_user");
jdbc.update("delete from listed_user"); jdbc.update("delete from 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_name");
jdbc.update("delete from account_role_change"); jdbc.update("delete from account_role_change");
for (long userId : List.of(1L, 2L)) { for (long userId : List.of(1L, 2L)) {
@@ -51,24 +54,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() { void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", 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)); 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"); 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"); assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
} }
@Test @Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() { void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null, null, null));
assertThat(blocked()).containsExactly(1L); assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull(); assertThat(sync(2L).get("user_seen_at")).isNotNull();
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(blocked()).isEmpty(); 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. // steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test @Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() { void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null, 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("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull(); assertThat(sync(1L).get("last_synced_at")).isNull();
@@ -85,14 +88,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void sameSteamId_KeepsTheLastSync() { 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(); assertThat(sync(1L).get("last_synced_at")).isNotNull();
} }
@Test @Test
void noSteamId_StopsTheNightlySync() { void noSteamId_StopsTheNightlySync() {
repository.apply(new AccountState(1L, null, null, false, null, 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("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
@@ -100,7 +103,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void userWithoutASyncRow_GetsOnlyTheBlock() { 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(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
@@ -108,10 +111,10 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() { void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
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));
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, 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); assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of()); repository.replaceAll(List.of());
@@ -128,13 +131,13 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
Instant first = Instant.parse("2026-10-04T12:00:00Z"); Instant first = Instant.parse("2026-10-04T12:00:00Z");
Instant second = first.plusSeconds(60); Instant second = first.plusSeconds(60);
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null, null, null));
assertThat(changedAt(1L)).isEqualTo(first); 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); 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); assertThat(changedAt(1L)).isEqualTo(second);
} }
@@ -143,8 +146,8 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
Instant first = Instant.parse("2026-10-04T12:00:01Z"); Instant first = Instant.parse("2026-10-04T12:00:01Z");
Instant second = first.plusSeconds(1); Instant second = first.plusSeconds(1);
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, 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)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null, null, null));
assertThat(changedAt(1L)).isEqualTo(second); assertThat(changedAt(1L)).isEqualTo(second);
} }
@@ -152,8 +155,8 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void findLoginState_ReadsTheBlockAndTheRoleChange() { void findLoginState_ReadsTheBlockAndTheRoleChange() {
Instant change = Instant.parse("2026-10-04T12:00:00Z"); Instant change = Instant.parse("2026-10-04T12:00:00Z");
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change, 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)); 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(1L)).isEqualTo(new LoginState(true, change));
assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE); assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE);
@@ -166,33 +169,169 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void listed_TrueInsertsFalseDeletesNullKeeps() { 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, true, null, null));
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(listed()).containsExactly(1L); 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(); assertThat(listed()).isEmpty();
} }
@Test @Test
void replaceAll_ListsExactlyTheListedUsers() { void replaceAll_ListsExactlyTheListedUsers() {
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.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, true)); 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), 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))); new AccountState(3L, OLD_STEAM_ID, null, false, null, false, null, null)));
assertThat(listed()).containsExactly(2L); 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(); assertThat(listed()).isEmpty();
} }
@Test @Test
void replaceAll_WithAStateWithoutListing_KeepsTheListing() { 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); assertThat(listed()).containsExactly(1L);
} }
private static AccountState publicState(long userId, Boolean publicProfile, List<String> 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<String> 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<Map<String, Object>> 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);
}
} }
@@ -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<String> keys = new HashSet<>();
followers.get(0).propertyNames().forEach(keys::add);
assertThat(keys).containsExactlyInAnyOrder("id", "displayName");
}
}
@@ -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));
}
}
@@ -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<Map<String, Object>> 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);
}
}
@@ -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));
}
}
@@ -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<Long> 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);
}
}
@@ -258,4 +258,71 @@ class SyncChangeRepositoryTest extends AbstractPersistenceTest {
assertThat(stored().seen()).isFalse(); 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);
}
} }
@@ -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.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.event.LibraryActivityEvent;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.ResourceAccessException;
@@ -71,6 +73,8 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
private SteamApiService steamApiService; private SteamApiService steamApiService;
@Mock @Mock
private SyncChangeRepository syncChangeRepository; private SyncChangeRepository syncChangeRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
private AchievementSyncService service; private AchievementSyncService service;
@@ -78,7 +82,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
void setUp() { void setUp() {
service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository, userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository,
mock(PlatformTransactionManager.class), 30, 0); eventPublisher, mock(PlatformTransactionManager.class), 30, 0);
} }
private static UserGame game(long appid, int playtime) { private static UserGame game(long appid, int playtime) {
@@ -301,6 +305,40 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(userAchievementRepository, never()).deleteByUserIdAndAppid(anyLong(), anyLong()); 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<UserGame> 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 @Test
void syncLibrary_StopsWhenSteamHidesTheFirstGames() { void syncLibrary_StopsWhenSteamHidesTheFirstGames() {
List<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList(); List<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList();
@@ -381,7 +419,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
long pauseMillis = 300; long pauseMillis = 300;
AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository, 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(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(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))); when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
@@ -468,6 +468,33 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertEquals(added, wish.getAddedAt()); 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 @Test
void runSync_WishlistError_KeepsTheOldWishlistAndTheLibrarySyncIsOk() { void runSync_WishlistError_KeepsTheOldWishlistAndTheLibrarySyncIsOk() {
steamReturns(game(0, 0), STEAM_ID); steamReturns(game(0, 0), STEAM_ID);
@@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference;
// import watchdog runs only when a test calls it. // 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. // Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @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", "app.library.import-watchdog-ms=86400000",
"spring.datasource.hikari.maximum-pool-size=4"}) "spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev") @ActiveProfiles("dev")
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "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": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false}]} "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false, "publicProfile": false, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
}, },
"purge": { "purge": {
"method": "POST", "method": "POST",