Compare commits

..
3 Commits
Author SHA1 Message Date
lobo 349fa40272 feat(rewards): shop, equipped looks and public looks
Build, Test & Push Core Service / docker (push) Successful in 1m35s
Build, Test & Push Core Service / quality-check (push) Successful in 1m5s
Add endpoints to buy items, equip looks by slot and unequip. The shop has
8 whitelisted looks: avatar borders, badges, accents and banner frames.
Ownership is a BUY ledger row; PLATFORM_ADMIN owns every catalog item
without buying. The public profile endpoint shares only the equipped looks
filtered by ownership.
2026-10-01 13:17:04 +02:00
lobo 837dad8a6f feat(rewards): ledger, earning rules and nightly settle
New module rewards tracks Pile Points in an append-only ledger. A settle
derives rows from the status log, playtime days and achievements, once per
user, kind and game. The balance is the sum of all rows. Settle runs on
every wallet read and nightly at 02:30. The unique key (user_id, kind, ref)
makes every run idempotent. V26 also pays a one-time starter grant for
finishes from before the status log.
2026-10-01 13:15:04 +02:00
lobo 732970697d feat(stats): add PLATFORMS stat group and landing page statistics
Add PLATFORMS stat group to user stats: games and playtime per platform.
Add platforms, genres, and finished-per-year statistics to public stats
for the landing page.
2026-10-01 11:10:59 +02:00
51 changed files with 2108 additions and 24 deletions

No files matched your search

+20 -5
View File
@@ -51,12 +51,21 @@ To Be Played is split into three repositories:
groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of
40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres 40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the
status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
`listed_user_game`, V24). `listed_user_game`, V24).
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known. changes are not known.
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and
nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 8 looks (`ShopItem`); a `BUY` row proves ownership,
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
claim of the profile ticket for visitors).
- **Wishlist**: synced from Steam with store price and discount. - **Wishlist**: synced from Steam with store price and discount.
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30 - **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed (Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
@@ -114,6 +123,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| Method | Path | Auth | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
@@ -129,11 +141,12 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT | | `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; none = all) | JWT | | `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/achievements/summary` | JWT |
| `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/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | 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`, `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 |
@@ -165,6 +178,7 @@ flowchart LR
Stats["stats"] Stats["stats"]
Version["version"] Version["version"]
Account["account"] Account["account"]
Rewards["rewards"]
Steam["steam_adapter"] Steam["steam_adapter"]
Igdb["igdb_adapter"] Igdb["igdb_adapter"]
Speedrun["speedrun_adapter"] Speedrun["speedrun_adapter"]
@@ -173,6 +187,7 @@ flowchart LR
LibService --> Speedrun LibService --> Speedrun
end end
Browser -->|"tbp_access cookie"| Security Browser -->|"tbp_access cookie"| Security
Rewards --> Db
Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"] Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"]
Steam --> SteamApi["Steam Web API"] Steam --> SteamApi["Steam Web API"]
Steam -->|"HEAD"| SteamCdn["Steam CDN"] Steam -->|"HEAD"| SteamCdn["Steam CDN"]
@@ -180,7 +195,7 @@ 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, blocked_user"| Db Stats -->|"listed_user_game, game_mood, user_game_status_change, 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, blocked_user, user_seen_at"| Db Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
``` ```
@@ -196,7 +211,7 @@ flowchart LR
| `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`, `deleted_user` and `blocked_user`; `account` SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `game_mood`, `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
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` 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. reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
@@ -58,8 +58,7 @@ public class JwtService implements ProfileTicketVerifier {
if (userId == null) { if (userId == null) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims)));
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -77,6 +76,10 @@ public class JwtService implements ProfileTicketVerifier {
} }
} }
private static Role role(Claims claims) {
return Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null);
}
private static Set<String> names(List<?> claim) { private static Set<String> names(List<?> claim) {
return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()); return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet());
} }
@@ -92,7 +95,7 @@ public class JwtService implements ProfileTicketVerifier {
// A ticket of an Auth from before the claim has none: then the profile shows no stats. // A ticket of an Auth from before the claim has none: then the profile shows no stats.
Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of(); Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null; Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null;
return Optional.of(new ProfileTicket(userId, profileStats, sections)); return Optional.of(new ProfileTicket(userId, role(claims), profileStats, sections));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -22,7 +22,7 @@ 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", "blocked_user", "deleted_user"); "user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped", "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",
@@ -19,7 +19,8 @@ public record UserStatsDto(
Taste taste, Taste taste,
Habits habits, Habits habits,
Finished finished, Finished finished,
Trend trend Trend trend,
Platforms platforms
) { ) {
/** /**
@@ -73,4 +74,10 @@ public record UserStatsDto(
*/ */
public record Trend(List<UserStats.PileDay> days, UserStats.MonthMoves thisMonth) {} public record Trend(List<UserStats.PileDay> days, UserStats.MonthMoves thisMonth) {}
/**
* @param perPlatform Steam games as "Steam", manual games by their platform (the spelling of the most games wins),
* most games first
*/
public record Platforms(List<UserStats.TagCount> perPlatform) {}
} }
@@ -9,7 +9,7 @@ import java.util.Set;
* the user shares. * the user shares.
*/ */
public enum StatGroup { public enum StatGroup {
OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND; OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND, PLATFORMS;
/** /**
* The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile * The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile
@@ -87,6 +87,13 @@ public class GameMergeRepository {
jdbc.update("delete from user_game f where f.appid = :from and exists " jdbc.update("delete from user_game f where f.appid = :from and exists "
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys); + "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys); int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
// Paid games stay paid under the kept key; a user who earned both keys keeps both rows.
jdbc.update("""
update reward_ledger l set ref = cast(:to as text)
where l.ref = cast(:from as text) and l.kind in ('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')
and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind
and o.ref = cast(:to as text))
""", keys);
for (String table : CATALOG_TABLES) { for (String table : CATALOG_TABLES) {
jdbc.update("delete from " + table + " where appid = :from", keys); jdbc.update("delete from " + table + " where appid = :from", keys);
} }
@@ -3,6 +3,7 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.UserStats; import core_service.modules.library.domain.UserStats;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -40,6 +41,9 @@ public class UserStatsRepository {
and ug.appid = p.appid and ug.hidden_at is not null) and ug.appid = p.appid and ug.hidden_at is not null)
"""; """;
private static final RowMapper<UserStats.TagCount> TAG_COUNT =
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
public UserStats.PileTimes pileTimes(long userId) { public UserStats.PileTimes pileTimes(long userId) {
@@ -113,8 +117,24 @@ public class UserStatsRepository {
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s where %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit group by tag.name order by minutes desc, games desc, tag.name limit :limit
""".formatted(column, OF_USER), user(userId).addValue("limit", limit), """.formatted(column, OF_USER), user(userId).addValue("limit", limit), TAG_COUNT);
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"))); }
/**
* Games and playtime per platform: the name is trimmed and compared ignoring case, the label is the most used
* spelling (a tie: the first in alphabetical order). Steam games are "Steam", so a manual game with the platform
* "Steam" joins them. Most games first.
*/
public List<UserStats.TagCount> gamesPerPlatform(long userId) {
return jdbc.query("""
select mode() within group (order by g.name) as name, count(*) as games,
coalesce(sum(g.playtime_minutes), 0) as minutes
from (select case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name,
ug.playtime_minutes
from listed_user_game ug where %s) g
where g.name <> ''
group by lower(g.name) order by games desc, minutes desc, name
""".formatted(OF_USER), user(userId), TAG_COUNT);
} }
public Optional<DayMinutes> longestDay(long userId) { public Optional<DayMinutes> longestDay(long userId) {
@@ -67,7 +67,8 @@ public class UserStatsService {
groups.contains(StatGroup.TASTE) ? taste(userId) : null, groups.contains(StatGroup.TASTE) ? taste(userId) : null,
groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null, groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null,
groups.contains(StatGroup.FINISHED) ? finished(userId) : null, groups.contains(StatGroup.FINISHED) ? finished(userId) : null,
groups.contains(StatGroup.TREND) ? trend(userId) : null); groups.contains(StatGroup.TREND) ? trend(userId) : null,
groups.contains(StatGroup.PLATFORMS) ? platforms(userId) : null);
} }
private UserStatsDto.Overview overview(long userId, Map<GameStatus, Long> byStatus, long playtime) { private UserStatsDto.Overview overview(long userId, Map<GameStatus, Long> byStatus, long playtime) {
@@ -107,6 +108,10 @@ public class UserStatsService {
userStatsRepository.finishedBeforeTracking(userId)); userStatsRepository.finishedBeforeTracking(userId));
} }
private UserStatsDto.Platforms platforms(long userId) {
return new UserStatsDto.Platforms(userStatsRepository.gamesPerPlatform(userId));
}
private UserStatsDto.Trend trend(long userId) { private UserStatsDto.Trend trend(long userId) {
LocalDate today = LocalDate.now(zone); LocalDate today = LocalDate.now(zone);
List<UserStats.PileDay> days = new ArrayList<>( List<UserStats.PileDay> days = new ArrayList<>(
@@ -0,0 +1,36 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.api.dto.PublicLooksDto;
import core_service.modules.rewards.service.RewardService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* Public, without login, like the public library: the profile ticket names the user and the role.
*/
@RestController
@RequestMapping("/api/public/profiles")
@RequiredArgsConstructor
public class PublicRewardController {
private final RewardService rewardService;
private final ProfileTicketVerifier profileTicketVerifier;
@GetMapping("/rewards")
public ResponseEntity<?> getPublicLooks(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(new PublicLooksDto(rewardService.publicLooks(t.userId(), t.role()))))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket")));
}
}
@@ -0,0 +1,62 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.api.dto.EquipRequest;
import core_service.modules.rewards.api.dto.PurchaseRequest;
import core_service.modules.rewards.api.dto.WalletDto;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.service.RewardService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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.PathVariable;
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("/api/rewards")
@RequiredArgsConstructor
public class RewardController {
private final RewardService rewardService;
/**
* Settles first, so the wallet is current.
*/
@GetMapping("/wallet")
public ResponseEntity<WalletDto> getWallet(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role())));
}
@PostMapping("/purchases")
public ResponseEntity<?> buy(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody PurchaseRequest request) {
RewardService.BuyResult result = rewardService.buy(principal.getIdAsLong(), principal.role(), request.item());
return switch (result) {
case BOUGHT -> getWallet(principal);
case ALREADY_OWNED -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("ALREADY_OWNED", "The item is already owned"));
case NOT_ENOUGH_POINTS -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("NOT_ENOUGH_POINTS", "Not enough Pile Points"));
};
}
@PutMapping("/equipped/{slot}")
public ResponseEntity<?> equip(@AuthenticationPrincipal UserPrincipal principal, @PathVariable LookSlot slot,
@RequestBody EquipRequest request) {
RewardService.EquipResult result = rewardService.equip(principal.getIdAsLong(), principal.role(), slot, request.item());
return switch (result) {
case DONE -> getWallet(principal);
case NOT_OWNED -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("NOT_OWNED", "The item is not owned"));
case WRONG_SLOT -> ResponseEntity.badRequest()
.body(new ErrorResponse("WRONG_SLOT", "The item does not fit this slot"));
};
}
}
@@ -0,0 +1,8 @@
package core_service.modules.rewards.api.dto;
import core_service.modules.rewards.domain.ShopItem;
/**
* @param item null takes the slot's look off
*/
public record EquipRequest(ShopItem item) {}
@@ -0,0 +1,7 @@
package core_service.modules.rewards.api.dto;
import core_service.modules.rewards.domain.LookSlot;
import java.util.Map;
public record PublicLooksDto(Map<LookSlot, String> equipped) {}
@@ -0,0 +1,6 @@
package core_service.modules.rewards.api.dto;
import core_service.modules.rewards.domain.ShopItem;
import jakarta.validation.constraints.NotNull;
public record PurchaseRequest(@NotNull ShopItem item) {}
@@ -0,0 +1,35 @@
package core_service.modules.rewards.api.dto;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.ShopItem;
import core_service.modules.rewards.service.RewardService;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @param owned the item codes the user may wear
* @param equipped the worn item code per slot
*/
public record WalletDto(long balance, List<ItemDto> catalog, Set<String> owned, Map<LookSlot, String> equipped,
List<EntryDto> history) {
public record ItemDto(String code, LookSlot slot, int price) {}
/**
* @param gameName null when the ref is no game
*/
public record EntryDto(RewardKind kind, String ref, String gameName, int points, Instant eventAt) {}
public static WalletDto of(RewardService.Wallet wallet) {
return new WalletDto(wallet.balance(),
Arrays.stream(ShopItem.values()).map(item -> new ItemDto(item.name(), item.slot(), item.price())).toList(),
wallet.owned(), wallet.equipped(),
wallet.history().stream()
.map(h -> new EntryDto(h.kind(), h.ref(), h.gameName(), h.points(), h.eventAt())).toList());
}
}
@@ -0,0 +1,16 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* Raw facts of one finished game; {@link RewardRules} decides.
*
* @param playedSinceMinutes minutes of the playtime days on or after the finish day (playtimeMinutes has the old play too)
* @param normallyMinutes IGDB normal time to beat, null when unknown
* @param endingUnlocked the ending achievement is unlocked, and not earlier than 30 days before the finish
* @param playedAround a playtime day within 30 days before the finish or any time after
* @param allAchievements all achievements of a game with some are unlocked
* @param finishedAt the first status change to FINISHED or COMPLETED
*/
public record FinishFacts(long appid, boolean manual, boolean completed, int playtimeMinutes, int playedSinceMinutes, Integer normallyMinutes,
boolean endingUnlocked, boolean playedAround, boolean allAchievements, Instant finishedAt) {}
@@ -0,0 +1,8 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* @param gameName null when the ref is no game
*/
public record HistoryEntry(RewardKind kind, String ref, String gameName, int points, Instant eventAt) {}
@@ -0,0 +1,5 @@
package core_service.modules.rewards.domain;
public enum LookSlot {
AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME
}
@@ -0,0 +1,34 @@
package core_service.modules.rewards.domain;
import core_service.shared.security.Role;
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
public final class Looks {
private Looks() {
}
/**
* A platform admin owns every item of the catalog, so later items need no extra work.
*/
public static Set<String> owned(Set<String> bought, Role role) {
return role == Role.PLATFORM_ADMIN ? ShopItem.codes() : bought;
}
/**
* The worn looks the user may show now: a code that is not owned or not in the catalog drops out, e.g. the free
* items of an admin who is no admin any more.
*/
public static Map<LookSlot, String> wearable(Map<LookSlot, String> equipped, Set<String> owned) {
Map<LookSlot, String> result = new EnumMap<>(LookSlot.class);
equipped.forEach((slot, code) -> {
if (owned.contains(code) && ShopItem.codes().contains(code)) {
result.put(slot, code);
}
});
return result;
}
}
@@ -0,0 +1,11 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* A ledger row to write.
*
* @param ref see V26
* @param eventAt when the game event happened, not when it is booked
*/
public record RewardEntry(RewardKind kind, String ref, int points, Instant eventAt) {}
@@ -0,0 +1,17 @@
package core_service.modules.rewards.domain;
import java.util.List;
public enum RewardKind {
STARTED, FINISHED, FINISHED_MANUAL, COMPLETED, MONTH, STREAK, STARTER, BUY;
/**
* The kinds whose ref is a game (an appid).
*/
public static final List<RewardKind> GAME_KINDS = List.of(STARTED, FINISHED, FINISHED_MANUAL, COMPLETED);
/**
* The kinds that count as "finished a game" for the weekly streak.
*/
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL);
}
@@ -0,0 +1,88 @@
package core_service.modules.rewards.domain;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.IsoFields;
import java.util.Optional;
/**
* The numbers of Pile Points and who earns what. Pure rules, no database. The ledger is append-only, so a change here
* never touches paid rows. The starter grant (20 per finished game, cap 250) is in V26.
*/
public final class RewardRules {
public static final int START = 5;
public static final int FINISH_SHORT = 30;
public static final int FINISH_MID = 50;
public static final int FINISH_LONG = 80;
public static final int MID_HOURS = 10;
public static final int LONG_HOURS = 30;
public static final int COMPLETED_BONUS = 40;
public static final int MANUAL_FINISH = 15;
public static final int MANUAL_PER_WEEK = 3;
public static final int MONTH_BONUS = 40;
public static final int STREAK_BONUS = 20;
public static final double PROOF_SHARE = 0.5;
public static final int PLAYED_AROUND_DAYS = 30;
public static final int MONTHS_BACK = 2;
public static final int STREAK_WEEKS = 12;
private RewardRules() {
}
/**
* Steam games only: a game added by hand has no playtime to prove the start.
*/
public static Optional<RewardEntry> start(StartFacts f) {
if (f.manual() || !f.playedAfter()) {
return Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.STARTED, String.valueOf(f.appid()), START, f.startedAt()));
}
/**
* A game added by hand pays {@link #MANUAL_FINISH} (the weekly cap is the caller's job). A Steam game pays by its
* length and needs playtime plus one proof: the ending achievement, playtime around the finish, or playtime of
* at least {@link #PROOF_SHARE} of the normal time.
*/
public static Optional<RewardEntry> finish(FinishFacts f) {
String ref = String.valueOf(f.appid());
if (f.manual()) {
return Optional.of(new RewardEntry(RewardKind.FINISHED_MANUAL, ref, MANUAL_FINISH, f.finishedAt()));
}
if (f.playtimeMinutes() <= 0 || !hasProof(f)) {
return Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.FINISHED, ref, finishPoints(f.normallyMinutes()), f.finishedAt()));
}
private static boolean hasProof(FinishFacts f) {
return f.endingUnlocked() || f.playedAround()
|| (f.normallyMinutes() != null && f.playedSinceMinutes() >= PROOF_SHARE * f.normallyMinutes());
}
private static int finishPoints(Integer normallyMinutes) {
if (normallyMinutes == null || normallyMinutes < MID_HOURS * 60) {
return FINISH_SHORT;
}
return normallyMinutes <= LONG_HOURS * 60 ? FINISH_MID : FINISH_LONG;
}
public static Optional<RewardEntry> completed(FinishFacts f) {
if (!f.completed() || f.manual() || !f.allAchievements()) {
return Optional.empty();
}
return Optional.of(new RewardEntry(RewardKind.COMPLETED, String.valueOf(f.appid()), COMPLETED_BONUS, f.finishedAt()));
}
public static String monthRef(YearMonth month) {
return month.toString();
}
/**
* ISO week, e.g. {@code 2026-W41}; the year is the week-based year.
*/
public static String weekRef(LocalDate day) {
return "%d-W%02d".formatted(day.get(IsoFields.WEEK_BASED_YEAR), day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
}
}
@@ -0,0 +1,39 @@
package core_service.modules.rewards.domain;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
* The whitelist of looks. The frontend maps a code to its visuals; nothing here is ever uploaded CSS.
*/
public enum ShopItem {
BORDER_GOLD(LookSlot.AVATAR_BORDER, 150),
BORDER_NEON(LookSlot.AVATAR_BORDER, 200),
BADGE_PILE_SLAYER(LookSlot.BADGE, 100),
BADGE_COMPLETIONIST(LookSlot.BADGE, 150),
ACCENT_GOLD(LookSlot.ACCENT, 200),
ACCENT_TEAL(LookSlot.ACCENT, 200),
FRAME_GOLD(LookSlot.BANNER_FRAME, 150),
FRAME_NEON(LookSlot.BANNER_FRAME, 200);
private final LookSlot slot;
private final int price;
ShopItem(LookSlot slot, int price) {
this.slot = slot;
this.price = price;
}
public LookSlot slot() {
return slot;
}
public int price() {
return price;
}
public static Set<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toSet());
}
}
@@ -0,0 +1,9 @@
package core_service.modules.rewards.domain;
import java.time.Instant;
/**
* @param manual a game added by hand: it has no playtime proof
* @param playedAfter a playtime day on or after the day of the start
*/
public record StartFacts(long appid, boolean manual, Instant startedAt, boolean playedAfter) {}
@@ -0,0 +1,141 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.StartFacts;
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.Date;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
* library module owns the entities and a module may not depend on another.
*/
@Repository
@RequiredArgsConstructor
public class RewardFactsRepository {
private static final String NOT_DELETED_OR_BLOCKED =
"user_id not in (select user_id from deleted_user) and user_id not in (select user_id from blocked_user)";
private final NamedParameterJdbcTemplate jdbc;
public boolean isActive(long userId) {
return Boolean.TRUE.equals(jdbc.queryForObject("""
select not exists (select 1 from deleted_user where user_id = :userId)
and not exists (select 1 from blocked_user where user_id = :userId)
""", new MapSqlParameterSource("userId", userId), Boolean.class));
}
public List<Long> activeUserIds() {
return jdbc.queryForList("select distinct user_id from user_game where " + NOT_DELETED_OR_BLOCKED
+ " order by user_id", new MapSqlParameterSource(), Long.class);
}
/**
* The first BACKLOG to PLAYING change of each game not paid yet.
*/
public List<StartFacts> startCandidates(long userId, ZoneId zone) {
return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, c.started_at,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.started_at at time zone :zone)::date) as played_after
from (select user_id, appid, min(changed_at) as started_at from user_game_status_change
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.hidden_at is null
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
and l.ref = c.appid::text)
order by c.started_at
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()),
(rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"),
rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after")));
}
/**
* Games that are FINISHED or COMPLETED now, with their first change to one of the two, and no FINISHED or
* FINISHED_MANUAL row yet. Oldest first. Only fresh play proves a finish: the ending achievement must be unlocked
* no earlier than {@link RewardRules#PLAYED_AROUND_DAYS} before that change, and the playtime share counts only
* the playtime days since it. Hidden games pay nothing.
*/
public List<FinishFacts> finishCandidates(long userId, ZoneId zone) {
return jdbc.query("""
select c.appid, ug.source = 'MANUAL' as manual, ug.status = 'COMPLETED' as completed,
ug.playtime_minutes, t.normally_minutes,
coalesce(ug.finished_at_estimate >= c.finished_at - make_interval(days => :aroundDays), false)
as ending_unlocked,
(select coalesce(sum(d.minutes), 0) from user_game_playtime_day d
where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.finished_at at time zone :zone)::date) as played_since,
exists (select 1 from user_game_playtime_day d where d.user_id = c.user_id and d.appid = c.appid
and d.day >= (c.finished_at at time zone :zone)::date - :aroundDays) as played_around,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements,
c.finished_at
from (select user_id, appid, min(changed_at) as finished_at from user_game_status_change
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null
left join game_time_to_beat t on t.appid = c.appid
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
left join game_achievement_schema_meta s on s.appid = c.appid
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id
and l.kind in ('FINISHED', 'FINISHED_MANUAL') and l.ref = c.appid::text)
order by c.finished_at
""", new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId())
.addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS),
(rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"),
rs.getInt("playtime_minutes"), rs.getInt("played_since"),
rs.getObject("normally_minutes", Integer.class),
rs.getBoolean("ending_unlocked"), rs.getBoolean("played_around"),
rs.getBoolean("all_achievements"), rs.getTimestamp("finished_at").toInstant()));
}
/**
* COMPLETED Steam games with a paid FINISHED row and no COMPLETED row yet; finishedAt is the FINISHED event.
*/
public List<FinishFacts> completedCandidates(long userId) {
return jdbc.query("""
select ug.appid, ug.playtime_minutes, f.event_at,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements
from user_game ug
join reward_ledger f on f.user_id = ug.user_id and f.kind = 'FINISHED' and f.ref = ug.appid::text
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
left join game_achievement_schema_meta s on s.appid = ug.appid
where ug.user_id = :userId and ug.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
and l.ref = ug.appid::text)
""", new MapSqlParameterSource("userId", userId),
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null,
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant()));
}
/**
* The pile (backlog_games) on the first and on the last day of the month, {first, last}. Empty unless both days
* have a row.
*/
public Optional<int[]> pileFirstAndLast(long userId, YearMonth month) {
List<int[]> rows = jdbc.query("""
select (select backlog_games from backlog_snapshot where user_id = :userId and day = :first) as first_day,
(select backlog_games from backlog_snapshot where user_id = :userId and day = :last) as last_day
""", new MapSqlParameterSource("userId", userId).addValue("first", Date.valueOf(month.atDay(1)))
.addValue("last", Date.valueOf(month.atEndOfMonth())),
(rs, i) -> {
Integer first = rs.getObject("first_day", Integer.class);
Integer last = rs.getObject("last_day", Integer.class);
return first == null || last == null ? null : new int[]{first, last};
});
return rows.stream().filter(Objects::nonNull).findFirst();
}
}
@@ -0,0 +1,127 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowCallbackHandler;
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.Collection;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The tables reward_ledger and reward_equipped. The balance is always the sum of the rows.
*/
@Repository
@RequiredArgsConstructor
public class RewardLedgerRepository {
private final NamedParameterJdbcTemplate jdbc;
private static List<String> names(Collection<RewardKind> kinds) {
return kinds.stream().map(Enum::name).toList();
}
/**
* Holds until the end of the transaction. Settle and buy take it first: the weekly cap and the balance are
* checked against rows another request could write at the same time.
*/
public void lockUser(long userId) {
jdbc.query("select pg_advisory_xact_lock(:userId)", new MapSqlParameterSource("userId", userId),
(RowCallbackHandler) rs -> { });
}
public long balance(long userId) {
Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = :userId",
new MapSqlParameterSource("userId", userId), Long.class);
return sum != null ? sum : 0;
}
/**
* @return false when the user already has a row with this kind and ref
*/
public boolean add(long userId, RewardEntry entry) {
return jdbc.update("""
insert into reward_ledger (user_id, kind, ref, points, event_at)
values (:userId, :kind, :ref, :points, :eventAt)
on conflict (user_id, kind, ref) do nothing
""", new MapSqlParameterSource("userId", userId).addValue("kind", entry.kind().name())
.addValue("ref", entry.ref()).addValue("points", entry.points())
.addValue("eventAt", Timestamp.from(entry.eventAt()))) > 0;
}
/**
* Newest first. A manual game is in steam_game too (negative appid), so it has its name as well.
*/
public List<HistoryEntry> history(long userId, int limit) {
return jdbc.query("""
select l.kind, l.ref, sg.name, l.points, l.event_at
from reward_ledger l
left join steam_game sg on sg.appid::text = l.ref and l.kind in (:gameKinds)
where l.user_id = :userId
order by l.created_at desc, l.id desc
limit :limit
""", new MapSqlParameterSource("userId", userId).addValue("limit", limit)
.addValue("gameKinds", names(RewardKind.GAME_KINDS)),
(rs, i) -> new HistoryEntry(RewardKind.valueOf(rs.getString("kind")), rs.getString("ref"),
rs.getString("name"), rs.getInt("points"), rs.getTimestamp("event_at").toInstant()));
}
/**
* The codes of the items the user bought.
*/
public Set<String> bought(long userId) {
return new HashSet<>(jdbc.queryForList("select ref from reward_ledger where user_id = :userId and kind = 'BUY'",
new MapSqlParameterSource("userId", userId), String.class));
}
/**
* Rows with an event_at in [from, to).
*/
public int countKindInRange(long userId, Collection<RewardKind> kinds, Instant from, Instant to) {
Integer count = jdbc.queryForObject("""
select count(*) from reward_ledger
where user_id = :userId and kind in (:kinds) and event_at >= :from and event_at < :to
""", new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds))
.addValue("from", Timestamp.from(from)).addValue("to", Timestamp.from(to)), Integer.class);
return count != null ? count : 0;
}
public List<Instant> eventsOfKinds(long userId, Collection<RewardKind> kinds, Instant since) {
return jdbc.query("""
select event_at from reward_ledger
where user_id = :userId and kind in (:kinds) and event_at >= :since order by event_at
""", new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds))
.addValue("since", Timestamp.from(since)), (rs, i) -> rs.getTimestamp("event_at").toInstant());
}
public Map<LookSlot, String> equipped(long userId) {
Map<LookSlot, String> result = new EnumMap<>(LookSlot.class);
jdbc.query("select slot, item_code from reward_equipped where user_id = :userId",
new MapSqlParameterSource("userId", userId),
(RowCallbackHandler) rs -> result.put(LookSlot.valueOf(rs.getString("slot")), rs.getString("item_code")));
return result;
}
public void equip(long userId, LookSlot slot, String code) {
jdbc.update("""
insert into reward_equipped (user_id, slot, item_code) values (:userId, :slot, :code)
on conflict (user_id, slot) do update set item_code = excluded.item_code
""", new MapSqlParameterSource("userId", userId).addValue("slot", slot.name()).addValue("code", code));
}
public void unequip(long userId, LookSlot slot) {
jdbc.update("delete from reward_equipped where user_id = :userId and slot = :slot",
new MapSqlParameterSource("userId", userId).addValue("slot", slot.name()));
}
}
@@ -0,0 +1,219 @@
package core_service.modules.rewards.service;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.Looks;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.ShopItem;
import core_service.modules.rewards.repo.RewardFactsRepository;
import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.security.Public;
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 org.springframework.transaction.support.TransactionTemplate;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Pile Points. No code path pays points: a settle derives the rows from the data the library already keeps
* (status log, playtime days, achievements, pile snapshots) and inserts them once per (user, kind, ref).
*/
@Slf4j
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class RewardService {
private static final int HISTORY_SIZE = 50;
private final RewardLedgerRepository ledger;
private final RewardFactsRepository facts;
private final TransactionTemplate transactionTemplate;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* @param owned the codes the user may wear: bought, or all for a platform admin
* @param equipped the worn looks, only those the user still owns
*/
public record Wallet(long balance, Set<String> owned, Map<LookSlot, String> equipped, List<HistoryEntry> history) {}
// ponytail: settles on every wallet read (the app reads it once per start); a settled_at column if that hurts.
@Transactional
public Wallet wallet(long userId, Role role) {
settleLocked(userId);
Set<String> owned = Looks.owned(ledger.bought(userId), role);
return new Wallet(ledger.balance(userId), owned, Looks.wearable(ledger.equipped(userId), owned),
ledger.history(userId, HISTORY_SIZE));
}
public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS }
public enum EquipResult { DONE, NOT_OWNED, WRONG_SLOT }
/**
* An admin owns every item, so never buys.
*/
@Transactional
public BuyResult buy(long userId, Role role, ShopItem item) {
ledger.lockUser(userId);
if (Looks.owned(ledger.bought(userId), role).contains(item.name())) {
return BuyResult.ALREADY_OWNED;
}
if (ledger.balance(userId) < item.price()) {
return BuyResult.NOT_ENOUGH_POINTS;
}
ledger.add(userId, new RewardEntry(RewardKind.BUY, item.name(), -item.price(), Instant.now()));
return BuyResult.BOUGHT;
}
/**
* @param item null takes the slot's look off
*/
@Transactional
public EquipResult equip(long userId, Role role, LookSlot slot, ShopItem item) {
if (item == null) {
ledger.unequip(userId, slot);
return EquipResult.DONE;
}
if (item.slot() != slot) {
return EquipResult.WRONG_SLOT;
}
if (!Looks.owned(ledger.bought(userId), role).contains(item.name())) {
return EquipResult.NOT_OWNED;
}
ledger.equip(userId, slot, item.name());
return EquipResult.DONE;
}
/**
* The looks a visitor of the public profile sees; the role comes from the profile ticket.
*/
@Public
public Map<LookSlot, String> publicLooks(long userId, Role role) {
return Looks.wearable(ledger.equipped(userId), Looks.owned(ledger.bought(userId), role));
}
/**
* Every active user, each in its own transaction; one failure does not stop the others. Runs after the nightly
* sync and the image repair, so month and streak bonuses also reach users who do not open the app.
*/
@Scheduled(cron = "${app.rewards.settle-cron:0 30 2 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void settleAll() {
List<Long> userIds = facts.activeUserIds();
log.info("Nightly reward settle for {} users", userIds.size());
for (long userId : userIds) {
try {
transactionTemplate.executeWithoutResult(status -> settleLocked(userId));
} catch (Exception e) {
log.error("Reward settle of user {} failed", userId, e);
}
}
}
private void settleLocked(long userId) {
if (!facts.isActive(userId)) {
return;
}
ledger.lockUser(userId);
int paid = settleStarts(userId) + settleFinishes(userId) + settleCompleted(userId)
+ settleMonths(userId) + settleStreaks(userId);
if (paid > 0) {
log.info("Reward settle of user {} booked {} rows", userId, paid);
}
}
private int book(long userId, RewardEntry entry) {
return ledger.add(userId, entry) ? 1 : 0;
}
private int settleStarts(long userId) {
return facts.startCandidates(userId, zone).stream()
.flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> book(userId, e)).sum();
}
private int settleFinishes(long userId) {
int paid = 0;
for (FinishFacts f : facts.finishCandidates(userId, zone)) {
RewardEntry entry = RewardRules.finish(f).orElse(null);
if (entry == null) {
continue;
}
if (entry.kind() == RewardKind.FINISHED_MANUAL) {
LocalDate monday = weekStart(entry.eventAt());
// The rows booked earlier in this run are in the count already.
int used = ledger.countKindInRange(userId, List.of(RewardKind.FINISHED_MANUAL),
startOf(monday), startOf(monday.plusWeeks(1)));
if (used >= RewardRules.MANUAL_PER_WEEK) {
continue;
}
}
paid += book(userId, entry);
}
return paid;
}
private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream()
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> book(userId, e)).sum();
}
// Only full months: the running month is not over.
private int settleMonths(long userId) {
int paid = 0;
for (int back = 1; back <= RewardRules.MONTHS_BACK; back++) {
YearMonth month = YearMonth.now(zone).minusMonths(back);
Instant from = startOf(month.atDay(1));
Instant to = startOf(month.plusMonths(1).atDay(1));
boolean smaller = facts.pileFirstAndLast(userId, month).filter(pile -> pile[1] < pile[0]).isPresent();
// Hiding games alone shrinks the pile: a month pays only with a finished game in it.
if (smaller && ledger.countKindInRange(userId, RewardKind.FINISH_KINDS, from, to) > 0) {
paid += book(userId, new RewardEntry(RewardKind.MONTH, RewardRules.monthRef(month),
RewardRules.MONTH_BONUS, to));
}
}
return paid;
}
private int settleStreaks(long userId) {
LocalDate since = LocalDate.now(zone).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.minusWeeks(RewardRules.STREAK_WEEKS);
Set<LocalDate> weeks = new HashSet<>();
ledger.eventsOfKinds(userId, RewardKind.FINISH_KINDS, startOf(since)).forEach(at -> weeks.add(weekStart(at)));
int paid = 0;
for (LocalDate monday : weeks) {
if (weeks.contains(monday.minusWeeks(1))) {
paid += book(userId, new RewardEntry(RewardKind.STREAK, RewardRules.weekRef(monday),
RewardRules.STREAK_BONUS, startOf(monday)));
}
}
return paid;
}
private LocalDate weekStart(Instant at) {
return at.atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
private Instant startOf(LocalDate day) {
return day.atStartOfDay(zone).toInstant();
}
}
@@ -13,10 +13,17 @@ public record PublicStatsDto(
long biggestLibrary, long biggestLibrary,
long biggestBacklog, long biggestBacklog,
List<TopGame> topOwnedGames, List<TopGame> topOwnedGames,
List<TopGame> topPlayedGames List<TopGame> topPlayedGames,
List<NameCount> gamesPerPlatform,
List<NameCount> topGenres,
List<YearCount> finishedPerYear
) { ) {
/** /**
* @param imageUrl see GameDto * @param imageUrl see GameDto
*/ */
public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {} public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {}
public record NameCount(String name, long games, long minutes) {}
public record YearCount(int year, long games) {}
} }
@@ -93,4 +93,53 @@ public class StatsRepository {
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED_OR_BLOCKED), limit); """.formatted(NOT_DELETED_OR_BLOCKED), limit);
} }
/**
* Per platform: the name is trimmed and compared ignoring case, the label is the most used spelling; Steam games
* are "Steam". Columns: name, games, minutes.
*/
public List<Map<String, Object>> gamesPerPlatform() {
return jdbc.queryForList("""
SELECT MODE() WITHIN GROUP (ORDER BY g.name) AS name, COUNT(*) AS games,
COALESCE(SUM(g.playtime_minutes), 0) AS minutes
FROM (SELECT CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name, playtime_minutes
FROM listed_user_game WHERE %s) g
WHERE g.name <> ''
GROUP BY LOWER(g.name) ORDER BY games DESC, minutes DESC, name
""".formatted(NOT_DELETED_OR_BLOCKED));
}
/**
* IGDB genres by playtime of all listed games. Columns: name, games, minutes.
*/
public List<Map<String, Object>> topGenres(int limit) {
return jdbc.queryForList("""
SELECT tag.name, COUNT(*) AS games, COALESCE(SUM(ug.playtime_minutes), 0) AS minutes
FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid
CROSS JOIN UNNEST(m.genres) AS tag(name)
WHERE %s
GROUP BY tag.name ORDER BY minutes DESC, games DESC, tag.name
LIMIT ?
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
}
/**
* Same rule as the Finished group of a user: games now FINISHED or COMPLETED, counted once in the year of their
* first move into FINISHED or COMPLETED; {@code finished_at_estimate} wins. Columns: year, games.
*/
public List<Map<String, Object>> finishedPerYear(String zone) {
return jdbc.queryForList("""
SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
AS INTEGER) AS year, COUNT(*) AS games
FROM listed_user_game ug
LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at
FROM user_game_status_change sc
WHERE sc.to_status IN ('FINISHED', 'COMPLETED')
AND sc.from_status NOT IN ('FINISHED', 'COMPLETED')
GROUP BY sc.user_id, sc.appid) f ON f.uid = ug.user_id AND f.appid = ug.appid
WHERE %s AND ug.status IN ('FINISHED', 'COMPLETED')
AND COALESCE(ug.finished_at_estimate, f.finished_at) IS NOT NULL
GROUP BY year ORDER BY year
""".formatted(NOT_DELETED_OR_BLOCKED), zone);
}
} }
@@ -4,9 +4,11 @@ import core_service.modules.stats.api.dto.PublicStatsDto;
import core_service.modules.stats.repo.StatsRepository; import core_service.modules.stats.repo.StatsRepository;
import core_service.shared.security.Public; import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,9 +18,13 @@ import java.util.Map;
public class PublicStatsService { public class PublicStatsService {
private static final int TOP_LIMIT = 5; private static final int TOP_LIMIT = 5;
private static final int TOP_GENRES = 8;
private final StatsRepository statsRepository; private final StatsRepository statsRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@Cacheable(value = "publicStats", key = "'global'") @Cacheable(value = "publicStats", key = "'global'")
public PublicStatsDto getPublicStats() { public PublicStatsDto getPublicStats() {
long distinctGames = statsRepository.countDistinctGames(); long distinctGames = statsRepository.countDistinctGames();
@@ -45,7 +51,10 @@ public class PublicStatsService {
biggestLibrary, biggestLibrary,
biggestBacklog, biggestBacklog,
topOwned, topOwned,
topPlayed topPlayed,
mapCounts(statsRepository.gamesPerPlatform()),
mapCounts(statsRepository.topGenres(TOP_GENRES)),
mapYears(statsRepository.finishedPerYear(zone.getId()))
); );
} }
@@ -60,4 +69,23 @@ public class PublicStatsService {
)) ))
.toList(); .toList();
} }
private List<PublicStatsDto.NameCount> mapCounts(List<Map<String, Object>> rows) {
return rows.stream()
.map(r -> new PublicStatsDto.NameCount(
(String) r.get("name"),
((Number) r.get("games")).longValue(),
((Number) r.get("minutes")).longValue()
))
.toList();
}
private List<PublicStatsDto.YearCount> mapYears(List<Map<String, Object>> rows) {
return rows.stream()
.map(r -> new PublicStatsDto.YearCount(
((Number) r.get("year")).intValue(),
((Number) r.get("games")).longValue()
))
.toList();
}
} }
@@ -5,7 +5,8 @@ import java.util.Set;
/** /**
* A valid public-profile ticket from Auth. * A valid public-profile ticket from Auth.
* *
* @param role the role of the user; USER for a ticket without the claim
* @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them * @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them
* @param sections the names of the profile sections the user shows; null for a ticket without them, which shows all * @param sections the names of the profile sections the user shows; null for a ticket without them, which shows all
*/ */
public record ProfileTicket(long userId, Set<String> profileStats, Set<String> sections) {} public record ProfileTicket(long userId, Role role, Set<String> profileStats, Set<String> sections) {}
+3
View File
@@ -67,6 +67,9 @@ 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
rewards:
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
settle-cron: "0 30 2 * * *"
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,29 @@
-- Pile Points (ROADMAP item 17): every earn and spend is a row, the balance is the sum, never one stored number.
-- (user_id, kind, ref) is the "once per game and kind, ever" rule; ref is the appid, a month (2026-10), an ISO
-- week (2026-W41), an item code (BUY) or STARTER. event_at is when the game event happened (the status change),
-- created_at when the row was written; a later settle still books the event in its own week and month.
CREATE TABLE reward_ledger (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
ref VARCHAR(32) NOT NULL,
points INTEGER NOT NULL,
event_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, kind, ref)
);
CREATE TABLE reward_equipped (
user_id BIGINT NOT NULL,
slot VARCHAR(16) NOT NULL,
item_code VARCHAR(32) NOT NULL,
PRIMARY KEY (user_id, slot)
);
-- Starter grant: finishes from before the status log (V14) can never be paid by the rules; 20 per game, cap 250.
INSERT INTO reward_ledger (user_id, kind, ref, points, event_at)
SELECT ug.user_id, 'STARTER', 'STARTER', LEAST(250, 20 * COUNT(*)), NOW()
FROM listed_user_game ug
WHERE ug.status IN ('FINISHED', 'COMPLETED') AND ug.playtime_minutes > 0
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
AND NOT EXISTS (SELECT 1 FROM user_game_status_change c WHERE c.user_id = ug.user_id AND c.appid = ug.appid
AND c.to_status IN ('FINISHED', 'COMPLETED'))
GROUP BY ug.user_id;
@@ -109,17 +109,28 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void verifyProfileTicket_ReturnsTheUser() { void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)), assertEquals(Optional.of(new ProfileTicket(42L, Role.USER, Set.of(), null)),
jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000))); jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
} }
@Test
void verifyProfileTicket_ReadsTheRoleAndTakesUserWithoutIt() {
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.claim("role", "PLATFORM_ADMIN")
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.PLATFORM_ADMIN, jwtService.verifyProfileTicket(ticket).orElseThrow().role());
assertEquals(Role.USER, jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000))
.orElseThrow().role());
}
@Test @Test
void verifyProfileTicket_ReadsTheSharedStatGroups() { void verifyProfileTicket_ReadsTheSharedStatGroups() {
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42") String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.claim("profileStats", List.of("PILE", "TREND")) .claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket)); assertEquals(Optional.of(new ProfileTicket(42L, Role.USER, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket));
} }
@Test @Test
@@ -128,7 +139,7 @@ class JwtServiceTest extends AbstractUnitTest {
.claim("sections", List.of("PLAYING", "STATS")) .claim("sections", List.of("PLAYING", "STATS"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket)); assertEquals(Optional.of(new ProfileTicket(42L, Role.USER, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket));
} }
@Test @Test
@@ -93,6 +93,12 @@ class PublicEndpointsTest {
.andExpect(jsonPath("$.code").value("INVALID_TICKET")); .andExpect(jsonPath("$.code").value("INVALID_TICKET"));
} }
@Test
void publicRewardsEndpointIsPublic() throws Exception {
mockMvc.perform(get("/api/public/profiles/rewards")).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
// Without a login: a class role marker on the way would answer 401. // Without a login: a class role marker on the way would answer 401.
@Test @Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception { void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
@@ -17,7 +17,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",
"user_steam_sync", "user_game", "game_import"); "user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped");
@Autowired @Autowired
private AccountCleanupRepository repository; private AccountCleanupRepository repository;
@@ -43,6 +43,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""", insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId); userId);
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_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
} }
} }
@@ -79,6 +79,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
void seed() { void seed() {
jdbc.update("delete from game_alias"); jdbc.update("delete from game_alias");
jdbc.update("delete from admin_audit"); jdbc.update("delete from admin_audit");
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
gameCorrectionRepository.deleteAll(); gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll(); userGameRepository.deleteAll();
gameMoodsRepository.deleteAll(); gameMoodsRepository.deleteAll();
@@ -160,6 +161,22 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined"); + " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
} }
@Test
void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
for (Object[] row : List.of(new Object[]{"FINISHED_MANUAL", MANUAL}, new Object[]{"STARTED", MANUAL},
new Object[]{"STARTED", STEAM}, new Object[]{"MONTH", "2026-09"})) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 5, now())",
AUDITOR_ID, row[0], String.valueOf(row[1]));
}
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForList("select kind || ' ' || ref from reward_ledger where user_id = ? ",
String.class, AUDITOR_ID)).containsExactlyInAnyOrder("FINISHED_MANUAL " + STEAM, "MONTH 2026-09",
"STARTED " + MANUAL, "STARTED " + STEAM);
}
@Test @Test
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception { void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now()); UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
@@ -81,7 +81,9 @@ class LibraryControllerStatsTest extends AbstractMockedLibraryServicesTest {
.andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620)) .andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620))
.andExpect(jsonPath("$.taste.genres").isArray()) .andExpect(jsonPath("$.taste.genres").isArray())
.andExpect(jsonPath("$.habits.underOneHourGames").value(0)) .andExpect(jsonPath("$.habits.underOneHourGames").value(0))
.andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1)); .andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1))
.andExpect(jsonPath("$.platforms.perPlatform[0].name").value("Steam"))
.andExpect(jsonPath("$.platforms.perPlatform[0].games").value(2));
verify(moodService).refreshListedInBackground(USER_ID); verify(moodService).refreshListedInBackground(USER_ID);
} }
@@ -228,6 +228,26 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
new UserStats.YearCount(thisYear, 1)); new UserStats.YearCount(thisYear, 1));
} }
@Test
void gamesPerPlatform_GroupsTrimmedIgnoringCase_LabelIsTheMostUsedSpelling() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(8001L, "A", null, Instant.now()), new SteamGame(8002L, "B", null, Instant.now()),
new SteamGame(8003L, "C", null, Instant.now()), new SteamGame(8004L, "D", null, Instant.now())));
Instant now = Instant.now();
userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now),
UserGame.manual(USER_ID, 8001L, "PS5", GameStatus.BACKLOG, 10, now),
UserGame.manual(USER_ID, 8002L, "PS5", GameStatus.BACKLOG, 20, now),
UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now),
UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now)));
seedHiddenAndRemoved();
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(
new UserStats.TagCount("PS5", 3, 60),
new UserStats.TagCount("Steam", 2, 150),
new UserStats.TagCount("Switch", 1, 5));
}
@Test @Test
void finishedBeforeTracking_FinishedGamesThatCameInFinished() { void finishedBeforeTracking_FinishedGamesThatCameInFinished() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
@@ -58,6 +58,7 @@ class UserStatsServiceTest extends AbstractUnitTest {
assertThat(stats.habits()).isNull(); assertThat(stats.habits()).isNull();
assertThat(stats.finished()).isNull(); assertThat(stats.finished()).isNull();
assertThat(stats.trend()).isNull(); assertThat(stats.trend()).isNull();
assertThat(stats.platforms()).isNull();
verifyNoInteractions(userGameRepository, backlogSnapshotRepository); verifyNoInteractions(userGameRepository, backlogSnapshotRepository);
} }
@@ -91,7 +92,7 @@ class UserStatsServiceTest extends AbstractUnitTest {
void getStats_NoGroups_RunsNoQuery() { void getStats_NoGroups_RunsNoQuery() {
UserStatsDto stats = service.getStats(7L, Set.of()); UserStatsDto stats = service.getStats(7L, Set.of());
assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null)); assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null, null));
verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository); verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository);
} }
} }
@@ -0,0 +1,63 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
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.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class PublicRewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 5L;
@Autowired
private MockMvc mockMvc;
@Autowired
private RewardLedgerRepository ledger;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from reward_equipped where user_id = ?", USER);
// An admin wears a free item: only the role in the ticket makes it show.
ledger.equip(USER, LookSlot.BADGE, "BADGE_PILE_SLAYER");
}
@Test
void rewards_TicketOfAnAdmin_ShowsTheFreeItem() throws Exception {
mockMvc.perform(get("/api/public/profiles/rewards")
.header("X-Profile-Ticket", ContractTokens.ticketToken(USER, Role.PLATFORM_ADMIN)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.equipped.BADGE").value("BADGE_PILE_SLAYER"));
}
@Test
void rewards_TicketOfAUser_ShowsOnlyWhatIsOwned() throws Exception {
mockMvc.perform(get("/api/public/profiles/rewards")
.header("X-Profile-Ticket", ContractTokens.ticketToken(USER, Role.USER)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.equipped").isEmpty());
}
@Test
void rewards_WithoutATicket_Answers401() throws Exception {
mockMvc.perform(get("/api/public/profiles/rewards"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
}
@@ -0,0 +1,227 @@
package core_service.modules.rewards.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.rewards.service.RewardService;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
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.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
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.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 dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class RewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 1L;
private static final long ADMIN = 2L;
private static final long GAME = 4_100_001L;
@Autowired
private MockMvc mockMvc;
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
jdbc.update("delete from reward_ledger where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from reward_equipped where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from user_game_playtime_day where user_id = ?", USER);
jdbc.update("delete from user_game where user_id = ?", USER);
}
private void game(long appid, String source, String status, int playtime) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, ?, ?, ?)",
USER, appid, status, playtime, source);
}
private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, appid);
}
private int rows(String kind) {
Integer count = jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = ?",
Integer.class, USER, kind);
return count != null ? count : 0;
}
private void wallet() throws Exception {
mockMvc.perform(get("/api/rewards/wallet")).andExpect(status().isOk());
}
@Test
void wallet_SteamFinishWithPlaytimeProof_PaysOnceEvenAfterBacklogAndFinishedAgain() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
setStatus(GAME, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.balance").value(30))
.andExpect(jsonPath("$.history[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.history[0].gameName").value("Game " + GAME))
.andExpect(jsonPath("$.catalog.length()").value(8))
.andExpect(jsonPath("$.owned").isEmpty());
setStatus(GAME, "BACKLOG");
setStatus(GAME, "FINISHED");
wallet();
assertThat(rows("FINISHED")).isEqualTo(1);
}
@Test
void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception {
game(GAME, "STEAM", "BACKLOG", 0);
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
setStatus(GAME, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(0));
assertThat(rows("FINISHED")).isZero();
}
@Test
void wallet_FourManualFinishesInOneWeek_PayThree() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_014L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(45));
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
}
@Test
void wallet_ManualGameInTheHistoryHasItsName() throws Exception {
game(-4_100_020L, "MANUAL", "BACKLOG", 0);
setStatus(-4_100_020L, "FINISHED");
mockMvc.perform(get("/api/rewards/wallet"))
.andExpect(jsonPath("$.history[0].kind").value("FINISHED_MANUAL"))
.andExpect(jsonPath("$.history[0].gameName").value("Game -4100020"));
}
@Test
void settleAll_RunTwice_WritesTheSameRows() {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "PLAYING");
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
setStatus(GAME, "FINISHED");
rewardService.settleAll();
long first = ledgerSum();
rewardService.settleAll();
assertThat(first).isEqualTo(35);
assertThat(ledgerSum()).isEqualTo(first);
assertThat(rows("STARTED")).isEqualTo(1);
assertThat(rows("FINISHED")).isEqualTo(1);
}
private long ledgerSum() {
Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = ?", Long.class, USER);
return sum != null ? sum : 0;
}
private void points(long userId, int points) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', ?, now())",
userId, points);
}
private static MockHttpServletRequestBuilder asAdmin(
MockHttpServletRequestBuilder request) {
return request.header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN, Role.PLATFORM_ADMIN));
}
private ResultActions buy(String item) throws Exception {
return mockMvc.perform(post("/api/rewards/purchases").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"" + item + "\"}"));
}
private ResultActions equip(String slot, String item) throws Exception {
return mockMvc.perform(put("/api/rewards/equipped/" + slot).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"item\":" + (item == null ? "null" : "\"" + item + "\"") + "}"));
}
@Test
void buy_WithTooFewPoints_Answers409AndKeepsTheBalance() throws Exception {
points(USER, 99);
buy("BADGE_PILE_SLAYER").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_ENOUGH_POINTS"));
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(99));
}
@Test
void buy_WithEnoughPoints_BooksTheSpendAndOwnsTheItem() throws Exception {
points(USER, 150);
buy("BADGE_PILE_SLAYER").andExpect(status().isOk())
.andExpect(jsonPath("$.balance").value(50))
.andExpect(jsonPath("$.owned[0]").value("BADGE_PILE_SLAYER"))
.andExpect(jsonPath("$.history[0].kind").value("BUY"))
.andExpect(jsonPath("$.history[0].points").value(-100));
buy("BADGE_PILE_SLAYER").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_OWNED"));
}
@Test
void equip_NotOwned_Answers409_OwnedShowsInTheWalletAndNullTakesItOff() throws Exception {
points(USER, 150);
equip("BADGE", "BADGE_PILE_SLAYER").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_OWNED"));
buy("BADGE_PILE_SLAYER");
equip("BADGE", "BADGE_PILE_SLAYER").andExpect(status().isOk())
.andExpect(jsonPath("$.equipped.BADGE").value("BADGE_PILE_SLAYER"));
equip("BADGE", null).andExpect(status().isOk()).andExpect(jsonPath("$.equipped.BADGE").doesNotExist());
}
@Test
void equip_ItemOfAnotherSlot_Answers400() throws Exception {
points(USER, 150);
buy("BADGE_PILE_SLAYER");
equip("ACCENT", "BADGE_PILE_SLAYER").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("WRONG_SLOT"));
}
@Test
void admin_OwnsEveryItem_NeverBuysAndFallsBackToDefaultAsAUser() throws Exception {
mockMvc.perform(asAdmin(get("/api/rewards/wallet")))
.andExpect(jsonPath("$.owned.length()").value(8));
mockMvc.perform(asAdmin(post("/api/rewards/purchases")).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"FRAME_GOLD\"}"))
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_OWNED"));
mockMvc.perform(asAdmin(put("/api/rewards/equipped/BANNER_FRAME")).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"FRAME_GOLD\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.equipped.BANNER_FRAME").value("FRAME_GOLD"));
mockMvc.perform(get("/api/rewards/wallet").header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN)))
.andExpect(jsonPath("$.owned").isEmpty())
.andExpect(jsonPath("$.equipped").isEmpty());
}
}
@@ -0,0 +1,33 @@
package core_service.modules.rewards.domain;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LooksTest extends AbstractUnitTest {
@Test
void owned_AdminOwnsTheWholeCatalog() {
assertThat(Looks.owned(Set.of(), Role.PLATFORM_ADMIN)).isEqualTo(ShopItem.codes());
}
@Test
void owned_UserOwnsOnlyWhatIsBought() {
assertThat(Looks.owned(Set.of("BORDER_GOLD"), Role.USER)).containsExactly("BORDER_GOLD");
assertThat(Looks.owned(Set.of(), Role.AUDITOR)).isEmpty();
}
@Test
void wearable_DropsLooksThatAreNotOwnedOrNotInTheCatalog() {
Map<LookSlot, String> equipped = Map.of(LookSlot.AVATAR_BORDER, "BORDER_GOLD", LookSlot.BADGE, "BADGE_PILE_SLAYER",
LookSlot.ACCENT, "ACCENT_NEON_PINK");
assertThat(Looks.wearable(equipped, Set.of("BORDER_GOLD", "ACCENT_NEON_PINK")))
.containsOnly(Map.entry(LookSlot.AVATAR_BORDER, "BORDER_GOLD"));
}
}
@@ -0,0 +1,92 @@
package core_service.modules.rewards.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class RewardRulesTest extends AbstractUnitTest {
private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z");
private static FinishFacts steam(int playtime, Integer normallyMinutes, boolean ending, boolean around) {
return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT);
}
private static int points(FinishFacts facts) {
return RewardRules.finish(facts).map(RewardEntry::points).orElse(0);
}
@Test
void finish_PaysByTheLengthOfTheGame() {
assertThat(points(steam(60, null, true, false))).isEqualTo(30);
assertThat(points(steam(60, 9 * 60 + 59, true, false))).isEqualTo(30);
assertThat(points(steam(60, 10 * 60, true, false))).isEqualTo(50);
assertThat(points(steam(60, 30 * 60, true, false))).isEqualTo(50);
assertThat(points(steam(60, 31 * 60, true, false))).isEqualTo(80);
}
@Test
void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty();
}
@Test
void finish_NeedsOneProof() {
assertThat(points(steam(60, 300, false, false))).isZero();
assertThat(points(steam(60, 300, true, false))).isEqualTo(30);
assertThat(points(steam(60, 300, false, true))).isEqualTo(30);
}
@Test
void finish_HalfOfTheNormalTimeIsAProof() {
assertThat(points(steam(149, 300, false, false))).isZero();
assertThat(points(steam(150, 300, false, false))).isEqualTo(30);
assertThat(points(steam(150, null, false, false))).isZero();
}
@Test
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT);
assertThat(RewardRules.finish(oldPlay)).isEmpty();
}
@Test
void finish_ManualGame_PaysTheSmallPriceWithoutProof() {
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT);
assertThat(RewardRules.finish(manual)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
}
@Test
void completed_NeedsAllAchievementsAndASteamGame() {
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT);
assertThat(RewardRules.completed(all)).contains(new RewardEntry(RewardKind.COMPLETED, "220", 40, AT));
assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT))).isEmpty();
}
@Test
void start_NeedsASteamGameWithPlaytimeAfterTheStart() {
Optional<RewardEntry> paid = RewardRules.start(new StartFacts(220L, false, AT, true));
assertThat(paid).contains(new RewardEntry(RewardKind.STARTED, "220", 5, AT));
assertThat(RewardRules.start(new StartFacts(220L, false, AT, false))).isEmpty();
assertThat(RewardRules.start(new StartFacts(-5L, true, AT, true))).isEmpty();
}
@Test
void refs_UseTheIsoWeekAndTheMonth() {
assertThat(RewardRules.monthRef(YearMonth.of(2026, 10))).isEqualTo("2026-10");
assertThat(RewardRules.weekRef(LocalDate.of(2026, 10, 8))).isEqualTo("2026-W41");
assertThat(RewardRules.weekRef(LocalDate.of(2027, 1, 1))).isEqualTo("2026-W53");
}
}
@@ -0,0 +1,234 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.StartFacts;
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.time.Instant;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import({RewardFactsRepository.class, RewardLedgerRepository.class})
class RewardFactsRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L;
private static final long GAME = 220L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
private RewardFactsRepository repository;
@Autowired
private RewardLedgerRepository ledger;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from reward_ledger");
jdbc.update("delete from backlog_snapshot");
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2') on conflict do nothing", GAME);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 600, now()) "
+ "on conflict do nothing", GAME);
}
private void game(String status, int playtime) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
USER, GAME, status, playtime);
}
private void setStatus(String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, GAME);
}
// The trigger stamps every change with the transaction start; this moves them apart.
private void changedAt(String toStatus, String timestamp) {
jdbc.update("update user_game_status_change set changed_at = ?::timestamptz where user_id = ? and appid = ? "
+ "and to_status = ?", timestamp, USER, GAME, toStatus);
}
private void playedOn(String day) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?::date, ?, 30)",
USER, day, GAME);
}
@Test
void finishCandidates_AppearsAfterTheChangeWithItsFacts() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-08-20");
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
List<FinishFacts> candidates = repository.finishCandidates(USER, BERLIN);
assertThat(candidates).singleElement().satisfies(f -> {
assertThat(f.appid()).isEqualTo(GAME);
assertThat(f.manual()).isFalse();
assertThat(f.completed()).isFalse();
assertThat(f.playtimeMinutes()).isEqualTo(120);
assertThat(f.normallyMinutes()).isEqualTo(600);
assertThat(f.endingUnlocked()).isTrue();
assertThat(f.playedAround()).isTrue();
assertThat(f.allAchievements()).isFalse();
assertThat(f.finishedAt()).isEqualTo(Instant.parse("2026-09-15T10:00:00Z"));
});
}
@Test
void finishCandidates_PlaytimeMoreThan30DaysBeforeIsNoProof() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-08-10");
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.playedAround()).isFalse());
}
@Test
void finishCandidates_AllAchievementsNeedTheSchemaTotalAndTheUnlockCount() {
game("BACKLOG", 120);
setStatus("COMPLETED");
jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true)", GAME);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement().satisfies(f -> {
assertThat(f.completed()).isTrue();
assertThat(f.allAchievements()).isTrue();
});
}
@Test
void finishCandidates_GoneOnceAFinishedRowExistsOrTheGameWentBackToBacklog() {
game("BACKLOG", 120);
setStatus("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(1);
setStatus("BACKLOG");
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
setStatus("FINISHED");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void finishCandidates_HiddenGameIsNoCandidate() {
game("BACKLOG", 120);
setStatus("FINISHED");
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void startAndCompletedCandidates_HiddenGameIsNoCandidate() {
game("BACKLOG", 60);
setStatus("PLAYING");
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1);
setStatus("COMPLETED");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(1);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty();
}
@Test
void finishCandidates_EndingUnlockedBeforeTheFinishWindowIsNoProof() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
jdbc.update("update user_game set finished_at_estimate = '2015-01-01T00:00:00Z' where user_id = ?", USER);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.endingUnlocked()).isFalse());
jdbc.update("update user_game set finished_at_estimate = '2026-08-20T00:00:00Z' where user_id = ?", USER);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.endingUnlocked()).isTrue());
}
@Test
void finishCandidates_PlayedSinceCountsOnlyDaysFromTheFinishDay() {
game("BACKLOG", 500);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-09-14");
playedOn("2026-09-15");
playedOn("2026-09-20");
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.playedSinceMinutes()).isEqualTo(60));
}
@Test
void startCandidates_PlayedAfterTheStartDayAndGoneOncePaid() {
game("BACKLOG", 60);
setStatus("PLAYING");
changedAt("PLAYING", "2026-09-15T10:00:00Z");
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
.extracting(StartFacts::playedAfter).isEqualTo(false);
playedOn("2026-09-15");
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
.extracting(StartFacts::playedAfter).isEqualTo(true);
ledger.add(USER, new RewardEntry(RewardKind.STARTED, String.valueOf(GAME), 5, Instant.now()));
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
}
@Test
void completedCandidates_NeedACompletedGameWithAPaidFinish() {
game("BACKLOG", 120);
setStatus("COMPLETED");
assertThat(repository.completedCandidates(USER)).isEmpty();
Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt));
assertThat(repository.completedCandidates(USER)).singleElement()
.extracting(FinishFacts::finishedAt).isEqualTo(finishedAt);
ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt));
assertThat(repository.completedCandidates(USER)).isEmpty();
}
@Test
void pileFirstAndLast_NeedsBothDays() {
YearMonth month = YearMonth.of(2026, 9);
jdbc.update("insert into backlog_snapshot values (?, '2026-09-01', 10, 0, 0, 10)", USER);
assertThat(repository.pileFirstAndLast(USER, month)).isEmpty();
jdbc.update("insert into backlog_snapshot values (?, '2026-09-30', 8, 0, 0, 10)", USER);
assertThat(repository.pileFirstAndLast(USER, month)).hasValueSatisfying(pile -> assertThat(pile).containsExactly(10, 8));
}
@Test
void isActive_FalseForDeletedAndBlockedUsers() {
game("BACKLOG", 0);
jdbc.update("insert into deleted_user (user_id) values (8)");
jdbc.update("insert into blocked_user (user_id) values (9)");
jdbc.update("insert into user_game (user_id, appid, status) values (8, ?, 'BACKLOG'), (9, ?, 'BACKLOG')", GAME, GAME);
assertThat(repository.isActive(USER)).isTrue();
assertThat(repository.isActive(8L)).isFalse();
assertThat(repository.isActive(9L)).isFalse();
assertThat(repository.activeUserIds()).contains(USER).doesNotContain(8L, 9L);
}
}
@@ -0,0 +1,102 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
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.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(RewardLedgerRepository.class)
class RewardLedgerRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L;
private static final Instant JAN_1 = Instant.parse("2026-01-01T12:00:00Z");
@Autowired
private RewardLedgerRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
// V26 paid the starter grant of the migration day; the tests start empty.
jdbc.update("delete from reward_ledger");
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
jdbc.update("insert into steam_game (appid, name) values (-1942, 'Zelda') on conflict do nothing");
}
@Test
void add_TheSameKindAndRefTwice_WritesOneRow() {
assertThat(repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1))).isTrue();
assertThat(repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 80, JAN_1))).isFalse();
assertThat(repository.balance(USER)).isEqualTo(30);
assertThat(repository.add(USER, new RewardEntry(RewardKind.STARTED, "220", 5, JAN_1))).isTrue();
}
@Test
void balance_IsTheSumIncludingSpends() {
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 80, JAN_1));
repository.add(USER, new RewardEntry(RewardKind.BUY, "BORDER_GOLD", -50, JAN_1));
repository.add(USER + 1, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
assertThat(repository.balance(USER)).isEqualTo(30);
assertThat(repository.balance(99L)).isZero();
assertThat(repository.bought(USER)).containsExactly("BORDER_GOLD");
}
@Test
void countKindInRange_CountsByEventTimeFromInclusiveToExclusive() {
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-1", 15, JAN_1));
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-2", 15, JAN_1.plusSeconds(60)));
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
List<RewardKind> manual = List.of(RewardKind.FINISHED_MANUAL);
assertThat(repository.countKindInRange(USER, manual, JAN_1, JAN_1.plusSeconds(60))).isEqualTo(1);
assertThat(repository.countKindInRange(USER, manual, JAN_1, JAN_1.plusSeconds(61))).isEqualTo(2);
assertThat(repository.countKindInRange(USER, RewardKind.FINISH_KINDS, JAN_1, JAN_1.plusSeconds(61))).isEqualTo(3);
assertThat(repository.eventsOfKinds(USER, manual, JAN_1.plusSeconds(1))).containsExactly(JAN_1.plusSeconds(60));
}
@Test
void history_CarriesTheGameNameOfSteamAndManualGamesAndNullForOtherRows() {
repository.add(USER, new RewardEntry(RewardKind.FINISHED, "220", 30, JAN_1));
repository.add(USER, new RewardEntry(RewardKind.FINISHED_MANUAL, "-1942", 15, JAN_1));
repository.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, JAN_1));
List<HistoryEntry> history = repository.history(USER, 10);
assertThat(history).extracting(HistoryEntry::kind, HistoryEntry::gameName).containsExactlyInAnyOrder(
tuple(RewardKind.FINISHED, "Half-Life 2"),
tuple(RewardKind.FINISHED_MANUAL, "Zelda"),
tuple(RewardKind.MONTH, null));
assertThat(repository.history(USER, 2)).hasSize(2);
}
@Test
void equip_ReplacesTheItemOfTheSlotAndUnequipRemovesIt() {
repository.equip(USER, LookSlot.BADGE, "BADGE_PILE_SLAYER");
repository.equip(USER, LookSlot.BADGE, "BADGE_COMPLETIONIST");
repository.equip(USER, LookSlot.ACCENT, "ACCENT_GOLD");
assertThat(repository.equipped(USER)).containsOnly(
Map.entry(LookSlot.BADGE, "BADGE_COMPLETIONIST"), Map.entry(LookSlot.ACCENT, "ACCENT_GOLD"));
repository.unequip(USER, LookSlot.BADGE);
assertThat(repository.equipped(USER)).containsOnlyKeys(LookSlot.ACCENT);
}
}
@@ -0,0 +1,60 @@
package core_service.modules.rewards.service;
import core_service.modules.rewards.domain.ShopItem;
import core_service.shared.security.Role;
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.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
class RewardServiceConcurrencyTest extends AbstractIntegrationTest {
private static final long USER = 6L;
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
}
@Test
void twoParallelBuysOfTheLastPoints_OnlyOneSucceeds() throws Exception {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 150, now())", USER);
CountDownLatch go = new CountDownLatch(1);
// Two different items: only the balance check can stop the second one.
CompletableFuture<RewardService.BuyResult> first = buyAfter(go, ShopItem.BORDER_GOLD);
CompletableFuture<RewardService.BuyResult> second = buyAfter(go, ShopItem.FRAME_GOLD);
go.countDown();
List<RewardService.BuyResult> results = List.of(first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS));
assertThat(results).containsExactlyInAnyOrder(RewardService.BuyResult.BOUGHT, RewardService.BuyResult.NOT_ENOUGH_POINTS);
assertThat(rewardService.wallet(USER, Role.USER).balance()).isZero();
}
private CompletableFuture<RewardService.BuyResult> buyAfter(CountDownLatch go, ShopItem item) {
return CompletableFuture.supplyAsync(() -> {
try {
go.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return rewardService.buy(USER, Role.USER, item);
});
}
}
@@ -0,0 +1,71 @@
package core_service.modules.rewards.service;
import core_service.shared.security.Role;
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.Date;
import java.time.YearMonth;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
class RewardServiceMonthTest extends AbstractIntegrationTest {
private static final long USER = 13L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
private final YearMonth month = YearMonth.now(BERLIN).minusMonths(1);
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from backlog_snapshot where user_id = ?", USER);
}
private void smallerPile() {
jdbc.update("insert into backlog_snapshot values (?, ?, 10, 0, 0, 10)", USER, Date.valueOf(month.atDay(1)));
jdbc.update("insert into backlog_snapshot values (?, ?, 8, 0, 0, 10)", USER, Date.valueOf(month.atEndOfMonth()));
}
private void paid(String kind) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, '1', 5, ?)",
USER, kind, java.sql.Timestamp.from(month.atDay(10).atStartOfDay(BERLIN).toInstant()));
}
private int monthRows() {
return jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'MONTH'",
Integer.class, USER);
}
@Test
void wallet_MonthBonusNeedsAFinishedGameInTheMonth_AStartAloneIsNotEnough() {
smallerPile();
paid("STARTED");
rewardService.wallet(USER, Role.USER);
assertThat(monthRows()).isZero();
}
@Test
void wallet_SmallerPileAndAFinishedGame_PaysTheMonthBonus() {
smallerPile();
paid("FINISHED_MANUAL");
rewardService.wallet(USER, Role.USER);
assertThat(monthRows()).isEqualTo(1);
}
}
@@ -1,17 +1,24 @@
package core_service.modules.stats.repo; package core_service.modules.stats.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.stats.api.dto.PublicStatsDto;
import core_service.modules.stats.service.PublicStatsService;
import core_service.shared.test.AbstractPersistenceTest; 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.jdbc.core.JdbcTemplate;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -29,6 +36,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired @Autowired
private JdbcTemplate jdbc; private JdbcTemplate jdbc;
@@ -62,19 +72,98 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5))); assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5)));
} }
@Test
void gamesPerPlatform_GroupsIgnoringCase_LeavesDeletedUsersOut() {
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()),
new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now())));
userGameRepository.saveAllAndFlush(List.of(
UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
List<Map<String, Object>> rows = statsRepository.gamesPerPlatform();
assertEquals(List.of("PS5", "Steam"), rows.stream().map(row -> row.get("name")).toList());
assertEquals(List.of(2L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList());
assertEquals(30L, ((Number) rows.get(0).get("minutes")).longValue());
}
@Test
void topGenres_OrderByPlaytime_LeaveHiddenGamesOut() {
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG", "Action"}, false, Instant.now());
gameMoodsRepository.upsert(1000L, new String[]{}, new String[]{"Simulator"}, false, Instant.now());
List<Map<String, Object>> rows = statsRepository.topGenres(8);
assertEquals(List.of("Action", "RPG"), rows.stream().map(row -> row.get("name")).toList());
assertEquals(300L, ((Number) rows.get(0).get("minutes")).longValue());
}
@Test
void finishedPerYear_CountsTheFirstMoveIntoFinished_EstimateWins() {
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(700L, "OJ", null, Instant.now())));
UserGame estimated = new UserGame(1L, 620L, 0, Instant.now(), Instant.now());
estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z"));
userGameRepository.saveAllAndFlush(List.of(estimated, new UserGame(1L, 700L, 0, Instant.now(), Instant.now())));
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620");
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 700");
// 220 is saved as FINISHED without a move: no year.
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 220");
jdbc.update("DELETE FROM user_game_status_change WHERE appid = 220");
List<Map<String, Object>> rows = statsRepository.finishedPerYear("Europe/Berlin");
assertEquals(List.of(2025, LocalDate.now().getYear()),
rows.stream().map(row -> ((Number) row.get("year")).intValue()).toList());
assertEquals(List.of(1L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList());
}
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();
} }
private void finishWithGenreForUser3() {
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 3 AND appid = 620");
gameMoodsRepository.upsert(620L, new String[]{}, new String[]{"Horror"}, false, Instant.now());
}
private void assertLandingListsIgnoreUser3() {
assertEquals(List.of(), statsRepository.topGenres(8));
assertEquals(List.of(), statsRepository.finishedPerYear("Europe/Berlin"));
}
@Test
void publicStats_ReadEveryRepositoryKey() {
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now());
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
UserGame estimated = new UserGame(1L, 620L, 0, Instant.now(), Instant.now());
estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z"));
userGameRepository.saveAndFlush(estimated);
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620");
PublicStatsService service = new PublicStatsService(statsRepository);
ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin"));
PublicStatsDto dto = service.getPublicStats();
assertEquals("Steam", dto.gamesPerPlatform().get(0).name());
assertEquals(300L, dto.gamesPerPlatform().get(0).minutes());
assertEquals("RPG", dto.topGenres().get(0).name());
assertEquals(2025, dto.finishedPerYear().get(0).year());
}
@Test @Test
void deletedUsers_LeaveEveryNumberAndTheTopLists() { void deletedUsers_LeaveEveryNumberAndTheTopLists() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now())); steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()), userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
new UserGame(3L, 220L, 0, Instant.now(), Instant.now()))); new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
finishWithGenreForUser3();
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)"); jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists(); hiddenGames_LeaveTheTopLists();
assertLandingListsIgnoreUser3();
} }
@Test @Test
@@ -82,9 +171,11 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now())); steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()), userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
new UserGame(3L, 220L, 0, Instant.now(), Instant.now()))); new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
finishWithGenreForUser3();
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)"); jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists(); hiddenGames_LeaveTheTopLists();
assertLandingListsIgnoreUser3();
} }
} }
@@ -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.speedrun.enabled=false", properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "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")
@@ -87,8 +87,13 @@ public final class ContractTokens {
* @param sections null for a ticket without the claim * @param sections null for a ticket without the claim
*/ */
public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) { public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) {
return ticket(userId, Role.USER, profileStats, sections);
}
public static Map<String, Object> ticket(long userId, Role role, List<String> profileStats, List<String> sections) {
Map<String, Object> claims = claims(PUBLIC_PROFILE); Map<String, Object> claims = claims(PUBLIC_PROFILE);
claims.put("profileUserId", String.valueOf(userId)); claims.put("profileUserId", String.valueOf(userId));
claims.put("role", role.name());
putOrRemove(claims, "profileStats", profileStats); putOrRemove(claims, "profileStats", profileStats);
putOrRemove(claims, "sections", sections); putOrRemove(claims, "sections", sections);
return claims; return claims;
@@ -98,6 +103,10 @@ public final class ContractTokens {
return sign(ticket(userId, profileStats, sections)); return sign(ticket(userId, profileStats, sections));
} }
public static String ticketToken(long userId, Role role) {
return sign(ticket(userId, role, null, null));
}
public static String serviceToken() { public static String serviceToken() {
return sign(claims(SERVICE)); return sign(claims(SERVICE));
} }
@@ -13,6 +13,7 @@
"aud": ["tbp-public-profile"], "aud": ["tbp-public-profile"],
"sub": "public-profile", "sub": "public-profile",
"profileUserId": "42", "profileUserId": "42",
"role": "USER",
"profileStats": ["OVERVIEW"], "profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"] "sections": ["SUMMARY"]
}, },