feat(rewards): ledger, earning rules and nightly settle

New module rewards tracks Pile Points in an append-only ledger. A settle
derives rows from the status log, playtime days and achievements, once per
user, kind and game. The balance is the sum of all rows. Settle runs on
every wallet read and nightly at 02:30. The unique key (user_id, kind, ref)
makes every run idempotent. V26 also pays a one-time starter grant for
finishes from before the status log.
This commit is contained in:
lobo committed 2026-10-01 13:15:04 +02:00
1 parent 732970697d
commit 837dad8a6f
28 files changed
+1470 -3

No files matched your search

@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "game_import");
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped");
@Autowired
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())""",
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() {
jdbc.update("delete from game_alias");
jdbc.update("delete from admin_audit");
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll();
gameMoodsRepository.deleteAll();
@@ -160,6 +161,22 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
+ " \"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
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
@@ -0,0 +1,140 @@
package core_service.modules.rewards.api;
import core_service.modules.rewards.service.RewardService;
import core_service.shared.test.AbstractIntegrationTest;
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.assertj.core.api.Assertions.assertThat;
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;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class RewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 1L;
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 = ?", USER);
jdbc.update("delete from reward_equipped where user_id = ?", USER);
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;
}
}
@@ -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,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);
}
}
@@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference;
// 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.
@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",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev")