feat(rewards): pay early finish points and top-up on proof
Build, Test & Push Core Service / quality-check (push) Failing after 1m49s
Build, Test & Push Core Service / docker (push) Skipped

A linked PlayStation title no longer blocks. Its playtime never
proves a finish. An unproven Steam finish pays 10 early points,
the rest comes with proof. Back pay only from app.rewards.early-since.
This commit is contained in:
lobo committed 2026-10-09 16:00:06 +02:00
1 parent 7c4b475321
commit b24fd91e88
17 files changed
+920 -102

No files matched your search

@@ -246,6 +246,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
"STARTED " + MANUAL, "STARTED " + STEAM);
}
@Test
void merge_MovesEarlyAndTopUpRows() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
for (String kind : List.of("FINISHED_EARLY", "FINISHED_TOPUP")) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 5, now())",
AUDITOR_ID, kind, String.valueOf(MANUAL));
}
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_EARLY " + STEAM, "FINISHED_TOPUP " + STEAM);
}
@Test
void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
@@ -0,0 +1,107 @@
package core_service.modules.rewards.api;
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.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
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;
/**
* The back pay cutoff: unproven finishes before app.rewards.early-since get no early points. The dev profile
* authenticates every request as user 1.
*/
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.rewards.early-since=2026-09-10")
class RewardControllerEarlySinceTest extends AbstractIntegrationTest {
private static final long USER = 1L;
private static final long GAME = 4_100_101L;
private static final long OLD = 4_100_102L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
jdbc.update("delete from reward_ledger 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 finished(long appid, String source, String at) {
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 (?, ?, 'BACKLOG', 120, ?)",
USER, appid, source);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER, appid);
jdbc.update("update user_game_status_change set changed_at = ?::timestamptz where user_id = ? and appid = ?",
at, USER, appid);
}
private ResultActions gamePoints(long appid) throws Exception {
return mockMvc.perform(get("/api/rewards/games/" + 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;
}
@Test
void gamePoints_FinishBeforeTheCutoff_NoEarlyRowAndSaysNoProof() throws Exception {
finished(GAME, "STEAM", "2026-09-09T20:00:00Z");
gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF"));
}
@Test
void gamePoints_FinishOnTheCutoffDay_PaysTenEarly() throws Exception {
finished(GAME, "STEAM", "2026-09-10T08:00:00Z");
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.paid[0].points").value(10));
}
@Test
void gamePoints_FinishBeforeTheCutoffThenProof_PaysFullFinishedNoTopUp() throws Exception {
finished(GAME, "STEAM", "2026-09-01T10:00:00Z");
gamePoints(GAME).andExpect(jsonPath("$.paid").isEmpty());
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30));
assertThat(rows("FINISHED_TOPUP")).isZero();
}
@Test
void gamePoints_OldUnprovenFinishDoesNotUseTheWeeklyCap() throws Exception {
// Monday and Tuesday of one ISO week, both before the cutoff.
finished(OLD, "STEAM", "2026-09-07T10:00:00Z");
for (long appid = -4_100_110L; appid > -4_100_113L; appid--) {
finished(appid, "MANUAL", "2026-09-08T10:00:00Z");
}
mockMvc.perform(get("/api/rewards/wallet"));
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
assertThat(rows("FINISHED_EARLY")).isZero();
}
}
@@ -101,14 +101,15 @@ class RewardControllerTest extends AbstractIntegrationTest {
}
@Test
void wallet_HandSetFinishedWithoutPlaytime_PaysNothingEvenWithTheEndingProof() throws Exception {
void wallet_HandSetFinishedWithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() 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));
mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(10));
assertThat(rows("FINISHED")).isZero();
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
}
@Test
@@ -162,13 +163,15 @@ class RewardControllerTest extends AbstractIntegrationTest {
}
@Test
void gamePoints_SteamFinishWithoutProof_MissingFinishIsNoProof() throws Exception {
void gamePoints_SteamFinishWithoutProof_PaysTenEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF"));
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.paid[0].points").value(10))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PROOF"));
}
@Test
@@ -295,14 +298,44 @@ class RewardControllerTest extends AbstractIntegrationTest {
}
@Test
void gamePoints_LinkedPsnTitle_SaysPlaystationLinked() throws Exception {
void gamePoints_ManualGameWithLinkedPsnTitle_PaysFifteen() throws Exception {
game(-4_100_030L, "MANUAL", "BACKLOG", 0);
setStatus(-4_100_030L, "FINISHED");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, -4_100_030L);
gamePoints(-4_100_030L)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_MANUAL"))
.andExpect(jsonPath("$.paid[0].points").value(15))
.andExpect(jsonPath("$.missing.FINISHED").doesNotExist());
}
@Test
void gamePoints_SteamRowWithLinkedTitleAndEndingAchievement_PaysFull() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME);
playedToday(GAME);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game set finished_at_estimate = now() where user_id = ?", USER);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION_LINKED"));
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30));
}
@Test
void gamePoints_LinkedTitleDaysNeverProve_PaysTenEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("PLAY_NOT_COUNTED"));
}
@Test
@@ -327,6 +360,193 @@ class RewardControllerTest extends AbstractIntegrationTest {
assertThat(rows("FINISHED")).isEqualTo(1);
}
@Test
void gamePoints_LinkedTitlePlayedAfterEarly_SaysPlayNotCounted() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)",
USER, GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("PLAY_NOT_COUNTED"));
}
@Test
void gamePoints_EarlyThenPlay_TopsUpToFullOnce() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED_TOPUP')].points").value(20))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").doesNotExist())
.andExpect(jsonPath("$.balance").value(30));
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
assertThat(rows("FINISHED_TOPUP")).isEqualTo(1);
assertThat(rows("FINISHED")).isZero();
}
@Test
void gamePoints_ZeroPlaytimeFinished_PaysTenNoTopUpWithoutSteamPlay() throws Exception {
game(GAME, "STEAM", "BACKLOG", 0);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("NO_PLAYTIME"));
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
}
@Test
void gamePoints_FourthCappedFinishInAWeek_ManualAndEarlyShareTheCap() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_012L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
game(GAME, "STEAM", "BACKLOG", 120);
game(EDITION, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game_status_change set changed_at = changed_at + interval '1 second' where appid = ?", GAME);
setStatus(EDITION, "FINISHED");
jdbc.update("update user_game_status_change set changed_at = changed_at + interval '2 seconds' where appid = ?", EDITION);
gamePoints(EDITION)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT_NO_PROOF"));
assertThat(rows("FINISHED_MANUAL") + rows("FINISHED_EARLY")).isEqualTo(3);
}
@Test
void gamePoints_CappedEarlyThenProof_PaysTheFullFinish() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_013L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game_status_change set changed_at = changed_at + interval '1 second' where appid = ?", GAME);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT_NO_PROOF"));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30));
}
@Test
void gamePoints_SetEarlyOnOneEditionProofOnTheOther_TopsUpOnce() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
game(EDITION, "STEAM", "BACKLOG", 60);
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
playedToday(EDITION);
setStatus(EDITION, "FINISHED");
gamePoints(EDITION);
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
assertThat(rows("FINISHED_TOPUP")).isEqualTo(1);
assertThat(rows("FINISHED")).isZero();
}
@Test
void gamePoints_CompletedAfterEarly_NeedsProvenFinish_ThenPaysAfterTopUp() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
allAchievements();
setStatus(GAME, "FINISHED");
setStatus(GAME, "COMPLETED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"))
.andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH"));
playedToday(GAME);
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED_TOPUP')]").isNotEmpty())
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.balance").value(70));
}
@Test
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
setStatus(GAME, "PLAYING");
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(10));
assertThat(rows("FINISHED_EARLY")).isEqualTo(1);
}
@Test
void gamePoints_HiddenAfterEarly_MissingTopUpIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_EARLY"));
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED_TOPUP").value("HIDDEN"));
}
@Test
void expected_ReturnsFullValuePerGame_ZeroWhenPaid() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
game(-4_100_040L, "MANUAL", "BACKLOG", 0);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 1200, now()) "
+ "on conflict (appid) do update set normally_minutes = 1200", GAME);
try {
mockMvc.perform(get("/api/rewards/expected").param("appids", GAME + "," + (-4_100_040L)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.['" + GAME + "']").value(50))
.andExpect(jsonPath("$.['-4100040']").value(15));
setStatus(GAME, "FINISHED");
gamePoints(GAME);
mockMvc.perform(get("/api/rewards/expected").param("appids", String.valueOf(GAME)))
.andExpect(jsonPath("$.['" + GAME + "']").value(0));
} finally {
jdbc.update("delete from game_time_to_beat where appid = ?", GAME);
}
}
@Test
void expected_AnotherUsersAppid_IsLeftOut() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", GAME);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source) values (?, ?, 'BACKLOG', 120, 'STEAM')",
OTHER_USER, GAME);
mockMvc.perform(get("/api/rewards/expected").param("appids", String.valueOf(GAME)))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void expected_MoreThan200_Is400() throws Exception {
String ids = java.util.stream.LongStream.rangeClosed(1, 201).mapToObj(String::valueOf)
.collect(java.util.stream.Collectors.joining(","));
mockMvc.perform(get("/api/rewards/expected").param("appids", ids))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("TOO_MANY"));
}
@Test
void gamePoints_GameNotInLibrary_Is404() throws Exception {
gamePoints(GAME)
@@ -15,59 +15,94 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RewardRulesTest extends AbstractUnitTest {
private static final Instant AT = Instant.parse("2026-10-01T10:00:00Z");
private static final Instant EARLY_SINCE = Instant.parse("2026-09-10T00: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);
return new FinishFacts(220L, false, false, playtime, playtime, normallyMinutes, ending, around, false, AT, false);
}
private static int points(FinishFacts facts) {
return RewardRules.finish(facts).map(RewardEntry::points).orElse(0);
return RewardRules.finish(facts, EARLY_SINCE).map(RewardEntry::points).orElse(0);
}
private static GameRewardFacts game(boolean manual, boolean finished, boolean completed, boolean finishLogged) {
return new GameRewardFacts(manual, finished, completed, false, false, false, true, finishLogged);
return new GameRewardFacts(manual, finished, completed, false, false, true, finishLogged, false);
}
private static final GameRewardFacts STEAM_FINISHED = game(false, true, false, true);
@Test
void blocked_HiddenBeforePlaystation() {
GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, true);
GameRewardFacts all = new GameRewardFacts(false, true, false, true, true, true, true, false);
assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN);
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, true)))
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, false)))
.isEqualTo(NoPointsReason.PLAYSTATION);
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, false, true, true, true)))
.isEqualTo(NoPointsReason.PLAYSTATION_LINKED);
assertThat(RewardRules.blocked(STEAM_FINISHED)).isNull();
}
@Test
void noFinishReason_ManualOverTheCap_IsWeeklyLimit() {
FinishFacts manual = new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT);
FinishFacts manual = new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT, false);
GameRewardFacts g = game(true, true, false, true);
assertThat(RewardRules.noFinishReason(g, manual, true)).isEqualTo(NoPointsReason.WEEKLY_LIMIT);
assertThat(RewardRules.noFinishReason(g, manual, false)).isNull();
assertThat(RewardRules.noFinishReason(g, manual, true, EARLY_SINCE)).isEqualTo(NoPointsReason.WEEKLY_LIMIT);
assertThat(RewardRules.noFinishReason(g, manual, false, EARLY_SINCE)).isNull();
}
@Test
void noFinishReason_SteamWithoutProof_IsNoProof() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false))
void noFinishReason_SteamWithoutProof_IsNullBecauseEarlyPays() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false, EARLY_SINCE)).isNull();
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false, EARLY_SINCE)).isNull();
}
@Test
void noTopUpReason_SteamWithoutProof_IsNoProof() {
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(60, 300, false, false))).isEqualTo(NoPointsReason.NO_PROOF);
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(60, 300, true, false))).isNull();
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, null)).isNull();
}
@Test
void noTopUpReason_LinkedTitleWithoutProof_IsPlayNotCounted() {
GameRewardFacts linked = new GameRewardFacts(false, true, false, false, false, true, true, true);
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, false, false)))
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
assertThat(RewardRules.noTopUpReason(linked, steam(0, 300, false, false))).isEqualTo(NoPointsReason.NO_PLAYTIME);
assertThat(RewardRules.noTopUpReason(linked, steam(60, 300, true, false))).isNull();
assertThat(RewardRules.noStartReason(linked, new StartFacts(220L, false, AT, false)))
.isEqualTo(NoPointsReason.PLAY_NOT_COUNTED);
}
@Test
void noTopUpReason_SteamWithoutPlaytime_IsNoPlaytime() {
assertThat(RewardRules.noTopUpReason(STEAM_FINISHED, steam(0, 300, true, true))).isEqualTo(NoPointsReason.NO_PLAYTIME);
}
@Test
void noFinishReason_UnprovenBeforeTheCutoff_IsNoProof() {
Instant cutoff = AT.plusSeconds(1);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false, cutoff))
.isEqualTo(NoPointsReason.NO_PROOF);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, false, false), false, cutoff))
.isEqualTo(NoPointsReason.NO_PLAYTIME);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), false, cutoff)).isNull();
}
@Test
void noFinishReason_SteamWithoutPlaytime_IsNoPlaytime() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false))
.isEqualTo(NoPointsReason.NO_PLAYTIME);
void noFinishReason_UnprovenSteamOverTheCap_IsWeeklyLimitNoProof() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), true, EARLY_SINCE))
.isEqualTo(NoPointsReason.WEEKLY_LIMIT_NO_PROOF);
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, true, false), true, EARLY_SINCE)).isNull();
}
@Test
void noFinishReason_NoStatusLog_IsBeforeHistory() {
assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false))
assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false, EARLY_SINCE))
.isEqualTo(NoPointsReason.BEFORE_HISTORY);
assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false))
assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false, EARLY_SINCE))
.isEqualTo(NoPointsReason.NOT_FINISHED);
}
@@ -98,7 +133,7 @@ class RewardRulesTest extends AbstractUnitTest {
@Test
void noCompletedReason_MissingAchievements() {
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT);
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT, false);
assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true))
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
@@ -114,46 +149,109 @@ class RewardRulesTest extends AbstractUnitTest {
}
@Test
void finish_WithoutPlaytime_PaysNothingEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true))).isEmpty();
void finish_WithoutPlaytime_PaysTenEarlyEvenWithTheEndingProof() {
assertThat(RewardRules.finish(steam(0, 600, true, true), EARLY_SINCE))
.contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
}
@Test
void finish_NeedsOneProof() {
assertThat(points(steam(60, 300, false, false))).isZero();
void finish_WithoutProof_PaysTenEarly() {
assertThat(points(steam(60, 300, false, false))).isEqualTo(10);
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(149, 300, false, false))).isEqualTo(10);
assertThat(points(steam(150, 300, false, false))).isEqualTo(30);
assertThat(points(steam(150, null, false, false))).isZero();
assertThat(points(steam(150, null, false, false))).isEqualTo(10);
}
@Test
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT);
void finish_ShareProofCountsOnlyPlaytimeSinceTheFinish_PaysTenEarly() {
FinishFacts oldPlay = new FinishFacts(220L, false, false, 500, 100, 300, false, false, false, AT, false);
assertThat(RewardRules.finish(oldPlay)).isEmpty();
assertThat(RewardRules.finish(oldPlay, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
}
@Test
void finish_ManualGame_PaysTheSmallPriceWithoutProof() {
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT);
FinishFacts manual = new FinishFacts(-5L, true, false, 0, 0, null, false, false, false, AT, false);
assertThat(RewardRules.finish(manual)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
assertThat(RewardRules.finish(manual, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_MANUAL, "-5", 15, AT));
}
private static FinishFacts earlyPaid(FinishFacts f) {
return new FinishFacts(f.appid(), f.manual(), f.completed(), f.playtimeMinutes(), f.playedSinceMinutes(),
f.normallyMinutes(), f.endingUnlocked(), f.playedAround(), f.allAchievements(), f.finishedAt(), true);
}
@Test
void finish_UnprovenBeforeTheCutoff_PaysNothing() {
assertThat(RewardRules.finish(steam(60, 300, false, false), AT.plusSeconds(1))).isEmpty();
}
@Test
void finish_UnprovenOnTheCutoffDay_PaysTenEarly() {
assertThat(RewardRules.finish(steam(60, 300, false, false), AT)).contains(
new RewardEntry(RewardKind.FINISHED_EARLY, "220", 10, AT));
}
@Test
void finish_ProvenBeforeTheCutoff_PaysFull() {
assertThat(RewardRules.finish(steam(60, 300, true, false), AT.plusSeconds(1))).contains(
new RewardEntry(RewardKind.FINISHED, "220", 30, AT));
}
@Test
void finish_ProvenAfterEarly_TopsUpToFull() {
FinishFacts f = earlyPaid(steam(60, 20 * 60, true, false));
assertThat(RewardRules.finish(f, EARLY_SINCE)).contains(new RewardEntry(RewardKind.FINISHED_TOPUP, "220", 40, AT));
}
@Test
void finish_UnprovenAfterEarly_PaysNothing() {
assertThat(RewardRules.finish(earlyPaid(steam(60, 300, false, false)), EARLY_SINCE)).isEmpty();
}
@Test
void finish_ZeroPlaytime_PaysTenEarly() {
assertThat(points(steam(0, 300, false, false))).isEqualTo(10);
assertThat(RewardRules.finish(earlyPaid(steam(0, 300, true, true)), EARLY_SINCE)).isEmpty();
}
@Test
void capped_UnprovenBeforeTheCutoff_IsNotCapped() {
assertThat(RewardRules.capped(steam(60, 300, false, false), AT.plusSeconds(1))).isFalse();
assertThat(RewardRules.capped(steam(60, 300, false, false), AT)).isTrue();
}
@Test
void capped_ManualAndUnprovenSteam_NotProvenSteam() {
assertThat(RewardRules.capped(new FinishFacts(-1L, true, false, 0, 0, null, false, false, false, AT, false),
EARLY_SINCE)).isTrue();
assertThat(RewardRules.capped(steam(60, 300, false, false), EARLY_SINCE)).isTrue();
assertThat(RewardRules.capped(steam(60, 300, true, false), EARLY_SINCE)).isFalse();
}
@Test
void expectedFinish_ManualIsFifteen_SteamByLength() {
assertThat(RewardRules.expectedFinish(true, 3000)).isEqualTo(15);
assertThat(RewardRules.expectedFinish(false, null)).isEqualTo(30);
assertThat(RewardRules.expectedFinish(false, 20 * 60)).isEqualTo(50);
assertThat(RewardRules.expectedFinish(false, 40 * 60)).isEqualTo(80);
}
@Test
void completed_NeedsAllAchievementsAndASteamGame() {
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT);
FinishFacts all = new FinishFacts(220L, false, true, 60, 60, null, true, true, true, AT, false);
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();
assertThat(RewardRules.completed(new FinishFacts(220L, false, true, 60, 60, null, true, true, false, AT, false))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, false, false, 60, 60, null, true, true, true, AT, false))).isEmpty();
assertThat(RewardRules.completed(new FinishFacts(220L, true, true, 60, 60, null, true, true, true, AT, false))).isEmpty();
}
@Test
@@ -66,7 +66,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
}
@Test
void game_FlagsHiddenPsnLinkedManualAndStatusLog() {
void game_FlagsHiddenManualAndStatusLog() {
game("BACKLOG", 120);
setStatus("PLAYING");
setStatus("FINISHED");
@@ -485,4 +485,102 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME);
}
private void linkedTitle() {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
}
@Test
void finishCandidates_EarlyPaidStaysACandidateWithTheFlag() {
game("BACKLOG", 120);
setStatus("FINISHED");
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.earlyPaid()).isFalse());
paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.earlyPaid()).isTrue());
}
@Test
void finishCandidates_TopUpPaidIsNoCandidate() {
game("BACKLOG", 120);
setStatus("FINISHED");
paid(RewardKind.FINISHED_EARLY, GAME);
paid(RewardKind.FINISHED_TOPUP, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
}
@Test
void finishCandidates_ManualRowWithLinkedTitle_IsACandidate() {
long manual = -1_220L;
jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (Switch)') on conflict do nothing", manual);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, manual);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER);
assertThat(repository.finishCandidates(USER, BERLIN)).extracting(FinishFacts::appid).containsExactly(manual);
}
@Test
void finishCandidates_LinkedTitleDaysAreNoProof() {
game("BACKLOG", 120);
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
playedOn("2026-09-16");
linkedTitle();
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement().satisfies(f -> {
assertThat(f.playedAround()).isFalse();
assertThat(f.playedSinceMinutes()).isZero();
});
}
@Test
void startCandidates_LinkedTitleDaysAreNoPlayAfterStart() {
game("BACKLOG", 120);
setStatus("PLAYING");
playedOn("2099-01-01");
linkedTitle();
assertThat(repository.startCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.playedAfter()).isFalse());
}
@Test
void completedCandidates_OpenAfterTopUp_NotAfterEarly() {
game("BACKLOG", 120);
setStatus("COMPLETED");
paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.completedCandidates(USER)).isEmpty();
paid(RewardKind.FINISHED_TOPUP, GAME);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).containsExactly(GAME);
}
@Test
void expectedFinish_PaidSetIsZero_HiddenAndPsnLeftOut() {
long manual = -1_220L;
long hidden = 222L;
long psn = 223L;
game("BACKLOG", 120);
for (long appid : new long[]{manual, hidden, psn}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'G') on conflict do nothing", appid);
}
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual);
jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (?, ?, 'BACKLOG', now())", USER, hidden);
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', 'PSN', 'PS5')",
USER, psn);
assertThat(repository.expectedFinish(USER, List.of(GAME, manual, hidden, psn, 999L)))
.containsExactlyInAnyOrderEntriesOf(java.util.Map.of(GAME, 50, manual, 15));
paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.expectedFinish(USER, List.of(GAME))).containsExactly(java.util.Map.entry(GAME, 0));
assertThat(repository.expectedFinish(USER, List.of())).isEmpty();
}
}
@@ -0,0 +1,109 @@
package core_service.modules.rewards.service;
import core_service.shared.event.LibraryActivityEvent;
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.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
class RewardServiceSyncSettleTest extends AbstractIntegrationTest {
private static final long USER = 15L;
private static final long GAME = 240L;
@Autowired
private ApplicationEventPublisher publisher;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
jdbc.update("delete from user_game where user_id = ?", USER);
jdbc.update("delete from blocked_user where user_id = ?", USER);
}
private void finishedGameWithoutProof() {
jdbc.update("insert into steam_game (appid, name) values (?, 'Synced') on conflict do nothing", GAME);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 90)",
USER, GAME);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER);
}
private int earlyRows() {
return jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'FINISHED_EARLY'",
Integer.class, USER);
}
@Test
void libraryActivity_OutsideATransaction_BooksTheRow() {
finishedGameWithoutProof();
publisher.publishEvent(new LibraryActivityEvent(USER));
assertThat(earlyRows()).isEqualTo(1);
}
@Test
void libraryActivity_InsideATransaction_BooksTheRowAfterCommit() {
finishedGameWithoutProof();
transactionTemplate.executeWithoutResult(status -> {
publisher.publishEvent(new LibraryActivityEvent(USER));
assertThat(earlyRows()).isZero();
});
assertThat(earlyRows()).isEqualTo(1);
}
@Test
void libraryActivity_ARollbackBooksNothing() {
finishedGameWithoutProof();
transactionTemplate.executeWithoutResult(status -> {
publisher.publishEvent(new LibraryActivityEvent(USER));
status.setRollbackOnly();
});
assertThat(earlyRows()).isZero();
}
@Test
void onLibraryActivity_SettleThatThrows_DoesNotReachThePublisher() {
finishedGameWithoutProof();
jdbc.execute("create or replace function review_fail() returns trigger language plpgsql as "
+ "$$ begin raise exception 'review: settle fails'; end $$");
jdbc.execute("create trigger review_fail before insert on reward_ledger for each row when (new.user_id = "
+ USER + ") execute function review_fail()");
try {
assertThatCode(() -> publisher.publishEvent(new LibraryActivityEvent(USER))).doesNotThrowAnyException();
assertThat(earlyRows()).isZero();
} finally {
jdbc.execute("drop trigger review_fail on reward_ledger");
jdbc.execute("drop function review_fail()");
}
}
@Test
void libraryActivity_BlockedUser_BooksNothingAndThrowsNothing() {
finishedGameWithoutProof();
jdbc.update("insert into blocked_user (user_id) values (?)", USER);
assertThatCode(() -> publisher.publishEvent(new LibraryActivityEvent(USER))).doesNotThrowAnyException();
assertThat(earlyRows()).isZero();
}
}