Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349fa40272 | ||
|
|
837dad8a6f | ||
|
|
732970697d |
No files matched your search
@@ -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) {}
|
||||||
@@ -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"]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user