feat(account): hide deleted accounts and clean up their rows
Build, Test & Push Core Service / quality-check (push) Successful in 1m2s
Build, Test & Push Core Service / docker (push) Successful in 1m20s

Auth marks a deleted user in deleted_user (no sync, not in landing stats or pile snapshots) and later purges the rows through /internal/accounts with a service token. Prune removes rows of user ids Auth does not know. Dry run by default.
This commit is contained in:
lobo committed 2026-09-27 11:46:22 +02:00
1 parent 88aae0101f
commit 351cda411d
28 files changed
+941 -16

No files matched your search

+19 -1
View File
@@ -75,6 +75,9 @@ until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load wor
frontend origin. frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header. accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
`/internal/**` has its own filter chain: it accepts only a service token of Auth (`typ=service`, with `exp`, sent
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
only guard.
## API ## API
@@ -93,6 +96,8 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `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` | `/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 |
## Tech stack ## Tech stack
@@ -120,6 +125,7 @@ flowchart LR
end end
Stats["stats"] Stats["stats"]
Version["version"] Version["version"]
Account["account"]
Steam["steam_adapter"] Steam["steam_adapter"]
Igdb["igdb_adapter"] Igdb["igdb_adapter"]
Speedrun["speedrun_adapter"] Speedrun["speedrun_adapter"]
@@ -136,6 +142,8 @@ flowchart LR
Speedrun --> SpeedrunApi["speedrun.com API"] Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")] LibRepo --> Db[("PostgreSQL tbp_core")]
Stats --> Db Stats --> Db
Auth -->|"service token<br/>/internal/accounts"| Account
Account --> Db
``` ```
| Module | Content | | Module | Content |
@@ -143,6 +151,7 @@ flowchart LR
| `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data | | `library` | Sync, backlog, manual games, 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 |
| `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 |
@@ -169,7 +178,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and
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 in the background. `user_steam_sync` lets only one run per user start. 3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start, and none for
a user in `deleted_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
@@ -186,6 +196,14 @@ The frontend polls `GET /api/library/sync-status` until the run ends.
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.
### Deleted accounts
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
gets no more syncs and leaves the landing stats and the pile snapshots at once. Auth's nightly job sends the whole
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
user ids, which removes the rows of users Auth does not know (refused for more than
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
### 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
@@ -34,7 +34,7 @@ final class AccessTokens {
.findFirst(); .findFirst();
} }
private static Optional<String> bearer(HttpServletRequest request) { static Optional<String> bearer(HttpServletRequest request) {
String header = request.getHeader("Authorization"); String header = request.getHeader("Authorization");
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty(); return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
} }
@@ -22,6 +22,7 @@ public class JwtService implements ProfileTicketVerifier {
// Only non-login tokens carry this claim; parseLoginToken rejects them. // Only non-login tokens carry this claim; parseLoginToken rejects them.
private static final String TYPE_CLAIM = "typ"; private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile"; private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service";
private final JwtParser parser; private final JwtParser parser;
@@ -50,6 +51,18 @@ public class JwtService implements ProfileTicketVerifier {
} }
} }
/**
* A token Auth signs for its calls to /internal. jjwt accepts a token without exp, so exp is required here.
*/
public boolean isServiceToken(String token) {
try {
Claims claims = claims(token);
return SERVICE_TOKEN_TYPE.equals(claims.get(TYPE_CLAIM, String.class)) && claims.getExpiration() != null;
} catch (Exception e) {
return false;
}
}
@Override @Override
public Optional<ProfileTicket> verifyProfileTicket(String ticket) { public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try { try {
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -37,9 +38,11 @@ public class SecurityConfig {
}; };
private final OncePerRequestFilter jwtAuthFilter; private final OncePerRequestFilter jwtAuthFilter;
private final JwtService jwtService;
public SecurityConfig(@Qualifier("jwtAuthenticationFilter") OncePerRequestFilter jwtAuthFilter) { public SecurityConfig(@Qualifier("jwtAuthenticationFilter") OncePerRequestFilter jwtAuthFilter, JwtService jwtService) {
this.jwtAuthFilter = jwtAuthFilter; this.jwtAuthFilter = jwtAuthFilter;
this.jwtService = jwtService;
} }
@Value("${app.cors.allowed-origins}") @Value("${app.cors.allowed-origins}")
@@ -61,7 +64,26 @@ public class SecurityConfig {
return source; return source;
} }
/**
* Calls of Auth only (service token), never a login. The path is public through Traefik, so the token is the
* only guard.
*/
@Bean @Bean
@Order(1)
public SecurityFilterChain internalFilterChain(HttpSecurity http) {
http
.securityMatcher("/internal/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth.anyRequest().hasAuthority(ServiceTokenFilter.SERVICE_AUTHORITY))
.addFilterBefore(new ServiceTokenFilter(jwtService), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain securityFilterChain(HttpSecurity http) { public SecurityFilterChain securityFilterChain(HttpSecurity http) {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
@@ -0,0 +1,39 @@
package core_service.configuration.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
/**
* Authenticates Auth's calls to /internal by a service token in the Bearer header. Not a bean on purpose: as a
* bean, Spring Boot would also run it for every other path.
*/
class ServiceTokenFilter extends OncePerRequestFilter {
static final String SERVICE_AUTHORITY = "SERVICE";
private final JwtService jwtService;
ServiceTokenFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
if (AccessTokens.bearer(request).filter(jwtService::isServiceToken).isPresent()) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
"tbp-auth", null, List.of(new SimpleGrantedAuthority(SERVICE_AUTHORITY))));
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,50 @@
package core_service.modules.account.api;
import core_service.modules.account.api.dto.CleanupResultDto;
import core_service.modules.account.api.dto.KnownUsersRequest;
import core_service.modules.account.api.dto.PurgeRequest;
import core_service.modules.account.api.dto.UserIdsRequest;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.service.AccountCleanupService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/accounts")
@RequiredArgsConstructor
public class InternalAccountController {
private final AccountCleanupService accountCleanupService;
@PostMapping("/deleted")
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
accountCleanupService.markDeleted(request.userIds());
return ResponseEntity.noContent().build();
}
@PutMapping("/deleted")
public ResponseEntity<Void> replaceDeleted(@Valid @RequestBody UserIdsRequest request) {
accountCleanupService.replaceDeleted(request.userIds());
return ResponseEntity.noContent().build();
}
@PostMapping("/purge")
public ResponseEntity<CleanupResultDto> purge(@Valid @RequestBody PurgeRequest request) {
return ResponseEntity.ok(toDto(accountCleanupService.purge(request.userIds(), request.dryRun())));
}
@PostMapping("/prune")
public ResponseEntity<CleanupResultDto> prune(@Valid @RequestBody KnownUsersRequest request) {
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
}
private static CleanupResultDto toDto(CleanupResult result) {
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
}
}
@@ -0,0 +1,7 @@
package core_service.modules.account.api.dto;
import java.util.List;
import java.util.Map;
public record CleanupResultDto(boolean dryRun, List<Long> users, Map<String, Integer> rows, boolean refused) {
}
@@ -0,0 +1,12 @@
package core_service.modules.account.api.dto;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* @param knownUserIds every user Auth has, also the ones flagged as deleted
* @param dryRun see PurgeRequest
*/
public record KnownUsersRequest(@NotEmpty List<Long> knownUserIds, boolean dryRun) {
}
@@ -0,0 +1,11 @@
package core_service.modules.account.api.dto;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* @param dryRun Auth's dry run; Core also counts only when its own dry run is on
*/
public record PurgeRequest(@NotNull List<Long> userIds, boolean dryRun) {
}
@@ -0,0 +1,8 @@
package core_service.modules.account.api.dto;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record UserIdsRequest(@NotNull List<Long> userIds) {
}
@@ -0,0 +1,12 @@
package core_service.modules.account.domain;
import java.util.List;
import java.util.Map;
/**
* @param users the users the run touched, or would touch in a dry run
* @param rows deleted rows per table, or the rows a dry run would delete
* @param refused true when a prune found more orphans than allowed and did nothing
*/
public record CleanupResult(boolean dryRun, List<Long> users, Map<String, Integer> rows, boolean refused) {
}
@@ -0,0 +1,102 @@
package core_service.modules.account.repo;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Tombstones in deleted_user and the per-user rows of the library tables. Plain SQL, because the library module
* owns the entities.
*/
@Repository
@RequiredArgsConstructor
public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
private 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_steam_sync", "user_game", "deleted_user");
// 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",
"deleted_user");
private final NamedParameterJdbcTemplate jdbc;
private static MapSqlParameterSource ids(Collection<Long> userIds) {
return new MapSqlParameterSource("ids", userIds);
}
/**
* Also takes the users off the nightly sync list.
*/
public void addDeleted(Collection<Long> userIds) {
if (userIds.isEmpty()) {
return;
}
jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing",
userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new));
jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds));
}
/**
* After this, exactly the given users are tombstoned.
*/
public void replaceDeleted(Collection<Long> userIds) {
addDeleted(userIds);
if (userIds.isEmpty()) {
jdbc.update("delete from deleted_user", new MapSqlParameterSource());
} else {
jdbc.update("delete from deleted_user where user_id not in (:ids)", ids(userIds));
}
}
public List<Long> findDeleted(Collection<Long> userIds) {
if (userIds.isEmpty()) {
return List.of();
}
return jdbc.queryForList("select user_id from deleted_user where user_id in (:ids) order by user_id",
ids(userIds), Long.class);
}
/**
* @param userIds not empty
*/
public Map<String, Integer> countRows(Collection<Long> userIds) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (String table : TABLES) {
Integer count = jdbc.queryForObject("select count(*) from " + table + " where user_id in (:ids)",
ids(userIds), Integer.class);
counts.put(table, count != null ? count : 0);
}
return counts;
}
/**
* @param userIds not empty
*/
public Map<String, Integer> deleteRows(Collection<Long> userIds) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (String table : TABLES) {
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
}
return counts;
}
/**
* Users that have rows in Core but are not in the list.
*
* @param knownUserIds not empty
*/
public List<Long> findOrphans(Collection<Long> knownUserIds) {
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id",
ids(knownUserIds), Long.class);
}
}
@@ -0,0 +1,91 @@
package core_service.modules.account.service;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.repo.AccountCleanupRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
/**
* Deleted accounts, driven by Auth: a tombstone hides the user at once, the purge after 30 days removes the rows.
* Auth decides who is deleted and when; Core only checks that a purged user is tombstoned.
*/
@Service
@Slf4j
public class AccountCleanupService {
private final AccountCleanupRepository repository;
private final boolean dryRun;
private final int maxOrphans;
public AccountCleanupService(AccountCleanupRepository repository,
@Value("${app.account-cleanup.dry-run}") boolean dryRun,
@Value("${app.account-cleanup.max-orphans}") int maxOrphans) {
this.repository = repository;
this.dryRun = dryRun;
this.maxOrphans = maxOrphans;
}
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void markDeleted(List<Long> userIds) {
repository.addDeleted(userIds);
log.info("Account cleanup: marked users {} as deleted", userIds);
}
/**
* The whole flagged set of Auth. Also lifts the tombstone of a user whose delete was undone in Auth.
*/
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void replaceDeleted(List<Long> userIds) {
repository.replaceDeleted(userIds);
log.info("Account cleanup: {} users marked as deleted", userIds.size());
}
/**
* @param requestDryRun Auth's dry run; either side can ask for one
*/
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public CleanupResult purge(List<Long> userIds, boolean requestDryRun) {
List<Long> tombstoned = repository.findDeleted(userIds);
if (tombstoned.size() < userIds.size()) {
log.warn("Account cleanup: purge skips users without a tombstone: {}",
userIds.stream().filter(id -> !tombstoned.contains(id)).toList());
}
return removeRows("purge", tombstoned, dryRun || requestDryRun);
}
/**
* Removes the rows of users Auth does not know. Refuses an empty list, and more orphans than allowed, so a
* broken call can never wipe the library tables.
*/
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public CleanupResult prune(List<Long> knownUserIds, boolean requestDryRun) {
if (knownUserIds.isEmpty()) {
throw new IllegalArgumentException("knownUserIds must not be empty");
}
List<Long> orphans = repository.findOrphans(knownUserIds);
if (orphans.size() > maxOrphans) {
log.warn("Account cleanup: prune refused, {} orphans (max {}): {}", orphans.size(), maxOrphans, orphans);
return new CleanupResult(dryRun || requestDryRun, orphans, Map.of(), true);
}
return removeRows("prune", orphans, dryRun || requestDryRun);
}
private CleanupResult removeRows(String run, List<Long> userIds, boolean countOnly) {
if (userIds.isEmpty()) {
return new CleanupResult(countOnly, List.of(), Map.of(), false);
}
Map<String, Integer> rows = countOnly ? repository.countRows(userIds) : repository.deleteRows(userIds);
log.info("Account cleanup: {} {} users {}, rows {}", run, countOnly ? "would delete" : "deleted", userIds, rows);
return new CleanupResult(countOnly, userIds, rows, false);
}
}
@@ -36,7 +36,9 @@ public class BacklogSnapshotRepository {
public int writeAll(LocalDate day) { public int writeAll(LocalDate day) {
return jdbc.update(""" return jdbc.update("""
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games) insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
select ug.user_id, :day, %s group by ug.user_id select ug.user_id, :day, %s
and ug.user_id not in (select user_id from deleted_user)
group by ug.user_id
on conflict (user_id, day) do update set backlog_games = excluded.backlog_games, on conflict (user_id, day) do update set backlog_games = excluded.backlog_games,
never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes, never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes,
listed_games = excluded.listed_games listed_games = excluded.listed_games
@@ -15,12 +15,13 @@ 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 — caller must skip. * Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip.
*/ */
@Modifying @Modifying
@Query(value = """ @Query(value = """
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status) INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status)
VALUES (:userId, :steamId, 'RUNNING') SELECT :userId, :steamId, 'RUNNING'
WHERE NOT EXISTS (SELECT 1 FROM deleted_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'
@@ -83,7 +83,7 @@ public class LibrarySyncWorker {
String historySteamId = before != null ? before.getHistorySteamId() : null; String historySteamId = before != null ? before.getHistorySteamId() : null;
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore); int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
if (claimed == 0) { if (claimed == 0) {
log.info("Sync skipped for user {} — another run already in flight", userId); log.info("Sync skipped for user {} — another run already in flight, or the account is deleted", userId);
return; return;
} }
@@ -10,6 +10,9 @@ import java.util.Map;
@Repository @Repository
public class StatsRepository { public class StatsRepository {
private static final String LISTED =
"removed_at IS NULL AND hidden_at IS NULL AND user_id NOT IN (SELECT user_id FROM deleted_user)";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@Autowired @Autowired
@@ -19,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 user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class); "SELECT COUNT(DISTINCT appid) FROM user_game WHERE " + LISTED, 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 user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class); "SELECT COUNT(*) FROM user_game WHERE " + LISTED, 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 user_game WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes > 0", Long.class); "SELECT COUNT(*) FROM user_game WHERE " + LISTED + " 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 user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class); "SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE " + LISTED, 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 user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class); "SELECT COUNT(DISTINCT user_id) FROM user_game WHERE " + LISTED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -51,9 +54,9 @@ public class StatsRepository {
Long v = jdbc.queryForObject(""" Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM ( SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM user_game SELECT COUNT(*) AS c FROM user_game
WHERE removed_at IS NULL AND hidden_at IS NULL GROUP BY user_id WHERE %s GROUP BY user_id
) t ) t
""", Long.class); """.formatted(LISTED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -61,9 +64,9 @@ public class StatsRepository {
Long v = jdbc.queryForObject(""" Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM ( SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM user_game SELECT COUNT(*) AS c FROM user_game
WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes = 0 GROUP BY user_id WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t ) t
""", Long.class); """.formatted(LISTED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -73,6 +76,7 @@ public class StatsRepository {
FROM user_game ug FROM user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
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 ?
@@ -85,6 +89,7 @@ public class StatsRepository {
FROM user_game ug FROM user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
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 ?
+5
View File
@@ -70,6 +70,11 @@ app:
image-repair-batch: 200 image-repair-batch: 200
image-repair-max-attempts: 3 image-repair-max-attempts: 3
image-repair-retry-days: 30 image-repair-retry-days: 30
account-cleanup:
# Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
# A prune refuses more orphans than this per run; raise it once for a big cleanup.
max-orphans: 5
resilience4j: resilience4j:
retry: retry:
@@ -0,0 +1,6 @@
-- Users Auth has flagged as deleted. Core hides them at once: no sync, not in the landing stats or the pile
-- snapshots. The nightly cleanup of Auth removes the row together with all rows of the user.
CREATE TABLE deleted_user (
user_id BIGINT PRIMARY KEY,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -136,4 +136,34 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ProfileTicketIsNoLogin() { void parseLoginToken_ProfileTicketIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000))); assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
} }
private String signServiceToken(PrivateKey key, String type, Long expiresInMs) {
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date());
if (expiresInMs != null) {
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
}
return builder.signWith(key, Jwts.SIG.EdDSA).compact();
}
@Test
void isServiceToken_AcceptsAServiceTokenOfAuth() {
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L)));
}
@Test
void isServiceToken_RejectsEverythingElse() throws Exception {
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken("garbage"));
}
@Test
void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
}
} }
@@ -0,0 +1,53 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractUnitTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
class ServiceTokenFilterTest extends AbstractUnitTest {
@Mock
private JwtService jwtService;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private Authentication authenticationAfter(MockHttpServletRequest request) throws Exception {
lenient().when(jwtService.isServiceToken("good")).thenReturn(true);
new ServiceTokenFilter(jwtService).doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
return SecurityContextHolder.getContext().getAuthentication();
}
@Test
void bearerServiceTokenGivesTheServiceAuthority() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer good");
assertThat(authenticationAfter(request).getAuthorities()).extracting(GrantedAuthority::getAuthority)
.containsExactly(ServiceTokenFilter.SERVICE_AUTHORITY);
}
@Test
void cookieAndInvalidTokensAuthenticateNothing() throws Exception {
MockHttpServletRequest cookie = new MockHttpServletRequest();
cookie.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertThat(authenticationAfter(cookie)).isNull();
MockHttpServletRequest invalid = new MockHttpServletRequest();
invalid.addHeader("Authorization", "Bearer bad");
assertThat(authenticationAfter(invalid)).isNull();
}
}
@@ -0,0 +1,112 @@
package core_service.modules.account.api;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
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.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.util.Date;
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.post;
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, like ExpiredAccessTokenTest, so both share one Spring context.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class InternalAccountControllerTest extends AbstractIntegrationTest {
private static final long USER_ID = 910_001L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@AfterEach
void cleanUp() {
jdbc.update("delete from deleted_user where user_id = ?", USER_ID);
}
private static String token(JwtBuilder builder) {
return builder.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private static String serviceToken() {
return token(Jwts.builder().subject("tbp-auth").claim("typ", "service"));
}
private static String loginToken() {
return token(Jwts.builder().subject("a@b.de").claim("userId", String.valueOf(USER_ID)));
}
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
request.contentType(MediaType.APPLICATION_JSON).content(body);
if (token != null) {
request.header("Authorization", "Bearer " + token);
}
return mockMvc.perform(request);
}
@Test
void withoutAServiceToken_Is401() throws Exception {
String body = "{\"userIds\":[" + USER_ID + "]}";
send(post("/internal/accounts/deleted"), body, null).andExpect(status().isUnauthorized());
send(post("/internal/accounts/deleted"), body, loginToken()).andExpect(status().isUnauthorized());
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID)).isEmpty();
}
@Test
void serviceToken_IsNoLoginForTheApi() throws Exception {
mockMvc.perform(get("/api/library/summary").header("Authorization", "Bearer " + serviceToken()))
.andExpect(status().isUnauthorized());
}
@Test
void markThenPurge_InDryRunCountsAndKeepsTheTombstone() throws Exception {
String body = "{\"userIds\":[" + USER_ID + "]}";
send(post("/internal/accounts/deleted"), body, serviceToken()).andExpect(status().isNoContent());
send(post("/internal/accounts/purge"), "{\"userIds\":[" + USER_ID + "],\"dryRun\":false}", serviceToken())
.andExpect(status().isOk())
.andExpect(jsonPath("$.dryRun").value(true))
.andExpect(jsonPath("$.users[0]").value(USER_ID))
.andExpect(jsonPath("$.rows.user_game").value(0))
.andExpect(jsonPath("$.refused").value(false));
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID))
.containsExactly(USER_ID);
}
@Test
void replaceDeleted_RemovesTombstonesAuthNoLongerFlags() throws Exception {
send(post("/internal/accounts/deleted"), "{\"userIds\":[" + USER_ID + "]}", serviceToken())
.andExpect(status().isNoContent());
send(put("/internal/accounts/deleted"), "{\"userIds\":[]}", serviceToken()).andExpect(status().isNoContent());
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID)).isEmpty();
}
@Test
void prune_WithAnEmptyListIs400() throws Exception {
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken()).andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,27 @@
package core_service.modules.account.api;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every API request as user 1; /internal must still need a service token.
*/
@AutoConfigureMockMvc
class InternalAccountDevProfileTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void withoutAServiceToken_Is401() throws Exception {
mockMvc.perform(post("/internal/accounts/deleted").contentType(MediaType.APPLICATION_JSON).content("{\"userIds\":[1]}"))
.andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,116 @@
package core_service.modules.account.repo;
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(AccountCleanupRepository.class)
class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
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_steam_sync", "user_game");
@Autowired
private AccountCleanupRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from backlog_snapshot");
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row.
jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, 220, 30)", userId);
jdbc.update("insert into wishlist_game (user_id, appid) values (?, 620)", userId);
jdbc.update("""
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
values (?, current_date, 1, 1, 0, 1)""", userId);
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId);
}
}
private int rows(String table, long userId) {
Integer count = jdbc.queryForObject("select count(*) from " + table + " where user_id = ?", Integer.class, userId);
return count != null ? count : 0;
}
private List<Long> deleted() {
return jdbc.queryForList("select user_id from deleted_user order by user_id", Long.class);
}
@Test
void addDeleted_IsIdempotentAndStopsTheNightlySync() {
repository.addDeleted(List.of(1L));
repository.addDeleted(List.of(1L));
assertThat(deleted()).containsExactly(1L);
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 1", Object.class)).isNull();
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 2", Object.class)).isNotNull();
}
@Test
void replaceDeleted_KeepsOnlyTheGivenUsers() {
repository.addDeleted(List.of(1L, 2L));
repository.replaceDeleted(List.of(2L, 3L));
assertThat(deleted()).containsExactly(2L, 3L);
repository.replaceDeleted(List.of());
assertThat(deleted()).isEmpty();
}
@Test
void findDeleted_ReturnsOnlyTombstonedUsers() {
repository.addDeleted(List.of(2L));
assertThat(repository.findDeleted(List.of(1L, 2L))).containsExactly(2L);
assertThat(repository.findDeleted(List.of())).isEmpty();
}
@Test
void countRows_CountsPerTableAndDeletesNothing() {
Map<String, Integer> counts = repository.countRows(List.of(1L));
assertThat(counts.keySet()).containsAll(USER_TABLES);
USER_TABLES.forEach(table -> assertThat(counts.get(table)).as(table).isEqualTo(1));
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isEqualTo(1));
}
@Test
void deleteRows_RemovesEveryRowOfTheUserAndKeepsTheOthers() {
repository.addDeleted(List.of(1L));
Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L));
USER_TABLES.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
assertThat(deletedRows.get("deleted_user")).isEqualTo(1);
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isZero());
USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1));
assertThat(deleted()).isEmpty();
assertThat(jdbc.queryForObject("select count(*) from steam_game where appid = 220", Integer.class)).isEqualTo(1);
}
@Test
void findOrphans_ReturnsUsersNotInTheList() {
repository.addDeleted(List.of(7L));
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L);
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
}
}
@@ -0,0 +1,110 @@
package core_service.modules.account.service;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.repo.AccountCleanupRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest {
private static final Map<String, Integer> ROWS = Map.of("user_game", 3);
@Mock
private AccountCleanupRepository repository;
private AccountCleanupService service(boolean dryRun) {
return new AccountCleanupService(repository, dryRun, 2);
}
@Test
void purge_OnlyTouchesTombstonedUsers() {
when(repository.findDeleted(List.of(1L, 2L))).thenReturn(List.of(2L));
when(repository.deleteRows(List.of(2L))).thenReturn(ROWS);
CleanupResult result = service(false).purge(List.of(1L, 2L), false);
assertThat(result).isEqualTo(new CleanupResult(false, List.of(2L), ROWS, false));
}
@Test
void purge_InDryRunOnlyCounts() {
when(repository.findDeleted(List.of(2L))).thenReturn(List.of(2L));
when(repository.countRows(List.of(2L))).thenReturn(ROWS);
CleanupResult result = service(true).purge(List.of(2L), false);
assertThat(result).isEqualTo(new CleanupResult(true, List.of(2L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
@Test
void prune_RefusesAnEmptyList() {
assertThatThrownBy(() -> service(false).prune(List.of(), false)).isInstanceOf(IllegalArgumentException.class);
verify(repository, never()).findOrphans(any());
}
@Test
void prune_RefusesMoreOrphansThanAllowed() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L, 2L, 3L));
CleanupResult result = service(false).prune(List.of(9L), false);
assertThat(result).isEqualTo(new CleanupResult(false, List.of(1L, 2L, 3L), Map.of(), true));
verify(repository, never()).deleteRows(any());
verify(repository, never()).countRows(any());
}
@Test
void prune_DeletesTheOrphans() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L, 2L));
when(repository.deleteRows(List.of(1L, 2L))).thenReturn(ROWS);
assertThat(service(false).prune(List.of(9L), false)).isEqualTo(new CleanupResult(false, List.of(1L, 2L), ROWS, false));
}
@Test
void prune_InDryRunOnlyCounts() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L));
when(repository.countRows(List.of(1L))).thenReturn(ROWS);
assertThat(service(true).prune(List.of(9L), false)).isEqualTo(new CleanupResult(true, List.of(1L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
@Test
void prune_WithoutOrphansDoesNothing() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of());
assertThat(service(false).prune(List.of(9L), false)).isEqualTo(new CleanupResult(false, List.of(), Map.of(), false));
verify(repository, never()).deleteRows(any());
}
@Test
void purge_AuthsDryRunOnlyCounts() {
when(repository.findDeleted(List.of(2L))).thenReturn(List.of(2L));
when(repository.countRows(List.of(2L))).thenReturn(ROWS);
assertThat(service(false).purge(List.of(2L), true)).isEqualTo(new CleanupResult(true, List.of(2L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
@Test
void prune_AuthsDryRunOnlyCounts() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L));
when(repository.countRows(List.of(1L))).thenReturn(ROWS);
assertThat(service(false).prune(List.of(9L), true)).isEqualTo(new CleanupResult(true, List.of(1L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
}
@@ -103,4 +103,15 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600)); assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600));
assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0)); assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0));
} }
@Test
void writeAll_SkipsDeletedUsers() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG),
game(2L, 220L, 0, GameStatus.BACKLOG)));
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(repository.writeAll(DAY)).isEqualTo(1);
assertThat(repository.findSince(2L, DAY)).isEmpty();
}
} }
@@ -0,0 +1,47 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserSteamSync;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
private static final String STEAM_ID = "76561198000000001";
@Autowired
private UserSteamSyncRepository repository;
@Autowired
private JdbcTemplate jdbc;
private int claim(long userId) {
return repository.tryClaimRun(userId, STEAM_ID, Instant.now().minusSeconds(600));
}
@Test
void tryClaimRun_ClaimsForALiveUser() {
assertThat(claim(1L)).isEqualTo(1);
}
@Test
void tryClaimRun_RefusesADeletedUserWithoutASyncRow() {
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(claim(2L)).isZero();
assertThat(repository.findById(2L)).isEmpty();
}
@Test
void tryClaimRun_RefusesADeletedUserWithAnIdleSyncRow() {
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
jdbc.update("insert into deleted_user (user_id) values (3)");
assertThat(claim(3L)).isZero();
}
}
@@ -8,6 +8,7 @@ import core_service.shared.test.AbstractPersistenceTest;
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.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import java.time.Instant; import java.time.Instant;
@@ -28,6 +29,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach @BeforeEach
void seed() { void seed() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -61,4 +65,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
private static List<Long> appids(List<Map<String, Object>> rows) { private static List<Long> appids(List<Map<String, Object>> rows) {
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList(); return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
} }
@Test
void deletedUsers_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 deleted_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists();
}
} }