Public Access
feat(follow): add user follows with activity inbox digest
This commit is contained in:
1 parent
2dba863c54
commit
867dc4c4d1
45 files changed
+2117
-68
No files matched your search
@@ -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());
|
|
||||||
} else {
|
|
||||||
jdbc.update("delete from listed_user where user_id not in (:ids)", new MapSqlParameterSource("ids", listed));
|
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user