Public Access
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:
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")
|
||||
|
||||
Reference in new issue
Block a user