feat(account): Steam ID and blocked state from Auth (V25)

A blocked user gets no sync and leaves the landing stats. A new or removed Steam ID stops the nightly sync of the old one. No token check in Core: the token lives until it expires.
This commit is contained in:
lobo committed 2026-09-30 15:00:47 +02:00
1 parent 3512159b41
commit 504c935b27
15 files changed
+312 -20

No files matched your search

+19 -7
View File
@@ -129,6 +129,7 @@ method calling another service's method that needs more.
| `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; 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 |
@@ -173,9 +174,9 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"] Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"] Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")] LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, deleted_user"| Db Stats -->|"listed_user_game, deleted_user, blocked_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user"| Db Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
``` ```
| Module | Content | | Module | Content |
@@ -183,15 +184,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` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows | | `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, Steam ID changes stop the nightly sync |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in **One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls. into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
@@ -222,7 +224,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
dates finished games by the unlock of their ending achievement dates finished games by the unlock of their ending achievement
(`FinishedEstimateService`, `user_game.finished_at_estimate`). (`FinishedEstimateService`, `user_game.finished_at_estimate`).
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for 3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
a user in `deleted_user`. a user in `deleted_user` or `blocked_user`.
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
@@ -310,6 +312,16 @@ flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned
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.
### Blocked users and Steam ID changes
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
took by a verified link, a block and an unblock; 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. The access token still works in Core until
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one
nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
### Manual games next to Steam games ### Manual games next to Steam games
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
@@ -1,11 +1,14 @@
package core_service.modules.account.api; package core_service.modules.account.api;
import core_service.modules.account.api.dto.AccountStatesRequest;
import core_service.modules.account.api.dto.CleanupResultDto; import core_service.modules.account.api.dto.CleanupResultDto;
import core_service.modules.account.api.dto.KnownUsersRequest; import core_service.modules.account.api.dto.KnownUsersRequest;
import core_service.modules.account.api.dto.PurgeRequest; import core_service.modules.account.api.dto.PurgeRequest;
import core_service.modules.account.api.dto.UserIdsRequest; import core_service.modules.account.api.dto.UserIdsRequest;
import core_service.modules.account.domain.AccountState;
import core_service.modules.account.domain.CleanupResult; import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.service.AccountCleanupService; import core_service.modules.account.service.AccountCleanupService;
import core_service.modules.account.service.AccountStateService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -15,12 +18,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RestController
@RequestMapping("/internal/accounts") @RequestMapping("/internal/accounts")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InternalAccountController { public class InternalAccountController {
private final AccountCleanupService accountCleanupService; private final AccountCleanupService accountCleanupService;
private final AccountStateService accountStateService;
@PostMapping("/deleted") @PostMapping("/deleted")
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) { public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
@@ -44,6 +50,24 @@ public class InternalAccountController {
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun()))); return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
} }
@PostMapping("/states")
public ResponseEntity<Void> applyStates(@Valid @RequestBody AccountStatesRequest request) {
accountStateService.apply(toStates(request));
return ResponseEntity.noContent().build();
}
@PutMapping("/states")
public ResponseEntity<Void> replaceStates(@Valid @RequestBody AccountStatesRequest request) {
accountStateService.replaceAll(toStates(request));
return ResponseEntity.noContent().build();
}
private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.blocked()))
.toList();
}
private static CleanupResultDto toDto(CleanupResult result) { private static CleanupResultDto toDto(CleanupResult result) {
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused()); return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
} }
@@ -0,0 +1,12 @@
package core_service.modules.account.api.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) {
}
}
@@ -0,0 +1,9 @@
package core_service.modules.account.domain;
/**
* What Core copies from Auth for one user.
*
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
*/
public record AccountState(long userId, Long steamId, boolean blocked) {
}
@@ -21,11 +21,11 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
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", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "game_import", "deleted_user"); "user_steam_sync", "user_game", "game_import", "blocked_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", "deleted_user"); "game_import", "blocked_user", "deleted_user");
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -0,0 +1,54 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/**
* Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library
* module owns the entities.
*/
@Repository
@RequiredArgsConstructor
public class AccountStateRepository {
private final NamedParameterJdbcTemplate jdbc;
public void apply(AccountState state) {
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else {
jdbc.update("delete from blocked_user where user_id = :userId", params);
}
if (state.blocked() || state.steamId() == null) {
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
params);
} else {
// A new ID: no nightly sync with the old one, and the next visit syncs at once. steam_id stays, so that
// sync sees the change and drops the old account's games.
jdbc.update("""
update user_steam_sync set user_seen_at = null, last_synced_at = null
where user_id = :userId and steam_id is distinct from :steamId""", params);
}
}
/**
* After this, exactly the blocked users of the list are blocked.
*/
public void replaceAll(Collection<AccountState> states) {
states.forEach(this::apply);
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
if (blocked.isEmpty()) {
jdbc.update("delete from blocked_user", new MapSqlParameterSource());
} else {
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
}
}
}
@@ -0,0 +1,39 @@
package core_service.modules.account.service;
import core_service.modules.account.domain.AccountState;
import core_service.modules.account.repo.AccountStateRepository;
import core_service.shared.security.Internal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Steam ID and block of each user, driven by Auth. Auth sends a change at once and the whole set nightly, so a
* missed call heals by the next night.
*/
@Service
@Internal
@RequiredArgsConstructor
@Slf4j
public class AccountStateService {
private final AccountStateRepository repository;
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void apply(List<AccountState> states) {
states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size());
}
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void replaceAll(List<AccountState> states) {
repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size());
}
}
@@ -26,7 +26,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
/** /**
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run * Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
* (row created, or existing row was not RUNNING / RUNNING marker stale). * (row created, or existing row was not RUNNING / RUNNING marker stale).
* Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip. * Returns 0 if another sync is already in flight, or the user is deleted or blocked (deleted_user,
* blocked_user) — caller must skip.
* *
* @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it * @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it
*/ */
@@ -35,6 +36,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at) INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
SELECT :userId, :steamId, 'RUNNING', :startedAt SELECT :userId, :steamId, 'RUNNING', :startedAt
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId) WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id, SET steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING', last_sync_status = 'RUNNING',
@@ -10,7 +10,8 @@ import java.util.Map;
@Repository @Repository
public class StatsRepository { public class StatsRepository {
private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)"; private static final String NOT_DELETED_OR_BLOCKED =
"user_id NOT IN (SELECT user_id FROM deleted_user UNION SELECT user_id FROM blocked_user)";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -21,31 +22,31 @@ public class StatsRepository {
public long countDistinctGames() { public long countDistinctGames() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countLibraryEntries() { public long countLibraryEntries() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countGamesPlayed() { public long countGamesPlayed() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " AND playtime_minutes > 0", Long.class); "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long sumPlaytimeMinutes() { public long sumPlaytimeMinutes() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countUsersWithLibrary() { public long countUsersWithLibrary() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -55,7 +56,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id WHERE %s GROUP BY user_id
) t ) t
""".formatted(NOT_DELETED), Long.class); """.formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -65,7 +66,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t ) t
""".formatted(NOT_DELETED), Long.class); """.formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -78,7 +79,7 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED), limit); """.formatted(NOT_DELETED_OR_BLOCKED), limit);
} }
public List<Map<String, Object>> topPlayedGames(int limit) { public List<Map<String, Object>> topPlayedGames(int limit) {
@@ -90,6 +91,6 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED), limit); """.formatted(NOT_DELETED_OR_BLOCKED), limit);
} }
} }
@@ -0,0 +1,6 @@
-- Users Auth has blocked (item 28 X4). Like deleted_user, but a block can be lifted: no nightly sync, not in the
-- stats, no new sync run. The rows stay; unblock deletes the marker.
CREATE TABLE blocked_user (
user_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -45,6 +45,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
} }
private static String serviceToken() { private static String serviceToken() {
@@ -0,0 +1,103 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
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.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountStateRepository.class)
class AccountStateRepositoryTest extends AbstractPersistenceTest {
private static final long OLD_STEAM_ID = 76561198000000001L;
private static final long NEW_STEAM_ID = 76561198000000002L;
@Autowired
private AccountStateRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from blocked_user");
for (long userId : List.of(1L, 2L)) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
}
}
private Map<String, Object> sync(long userId) {
return jdbc.queryForMap("select steam_id, last_synced_at, user_seen_at from user_steam_sync where user_id = ?",
userId);
}
private List<Long> blocked() {
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
}
@Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull();
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
assertThat(blocked()).isEmpty();
}
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
repository.apply(new AccountState(1L, NEW_STEAM_ID, false));
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull();
assertThat(sync(1L).get("user_seen_at")).isNull();
}
@Test
void sameSteamId_KeepsTheLastSync() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
assertThat(sync(1L).get("last_synced_at")).isNotNull();
}
@Test
void noSteamId_StopsTheNightlySync() {
repository.apply(new AccountState(1L, null, false));
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
}
@Test
void userWithoutASyncRow_GetsOnlyTheBlock() {
repository.apply(new AccountState(3L, NEW_STEAM_ID, true));
assertThat(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
}
@Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
repository.apply(new AccountState(2L, OLD_STEAM_ID, true));
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true)));
assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of());
assertThat(blocked()).isEmpty();
}
}
@@ -45,6 +45,14 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(claim(3L)).isZero(); assertThat(claim(3L)).isZero();
} }
@Test
void tryClaimRun_RefusesABlockedUser() {
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
jdbc.update("insert into blocked_user (user_id) values (3)");
assertThat(claim(3L)).isZero();
}
@Test @Test
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() { void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " + jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
@@ -76,4 +76,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists(); hiddenGames_LeaveTheTopLists();
} }
@Test
void blockedUsers_LeaveEveryNumberAndTheTopLists() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists();
}
} }
@@ -55,6 +55,16 @@
"path": "/internal/accounts/deleted", "path": "/internal/accounts/deleted",
"request": {"userIds": []} "request": {"userIds": []}
}, },
"applyStates": {
"method": "POST",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
},
"replaceStates": {
"method": "PUT",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
},
"purge": { "purge": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/purge", "path": "/internal/accounts/purge",