feat(rewards): add game points endpoint with reason codes

Frontend shows why a game earned no points after a status save.
Settle runs first. Rules and amounts are not changed.
This commit is contained in:
lobo committed 2026-10-09 15:25:04 +02:00
1 parent 4ed8a341af
commit 7c4b475321
12 files changed
+708 -17

No files matched your search

@@ -34,6 +34,8 @@ class RewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 1L;
private static final long ADMIN = 2L;
private static final long GAME = 4_100_001L;
private static final long EDITION = 4_100_002L;
private static final long OTHER_USER = 99L;
@Autowired
private MockMvc mockMvc;
@@ -49,7 +51,11 @@ class RewardControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from reward_ledger where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from reward_equipped where user_id in (?, ?)", USER, ADMIN);
jdbc.update("delete from user_game_playtime_day where user_id = ?", USER);
jdbc.update("delete from user_game where user_id = ?", USER);
jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER_USER);
jdbc.update("delete from user_psn_title where user_id = ?", USER);
jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION);
jdbc.update("delete from game_achievement_schema_meta where appid = ?", GAME);
jdbc.update("delete from reward_ledger where user_id = ?", OTHER_USER);
}
private void game(long appid, String source, String status, int playtime) {
@@ -117,6 +123,232 @@ class RewardControllerTest extends AbstractIntegrationTest {
assertThat(rows("FINISHED_MANUAL")).isEqualTo(3);
}
@Test
void gamePoints_ProvenSteamFinish_PaysOnTheCallAndSaysBookedNow() 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/games/" + GAME))
.andExpect(status().isOk())
.andExpect(jsonPath("$.balance").value(30))
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].points").value(30))
.andExpect(jsonPath("$.paid[0].bookedNow").value(true));
}
private void playedToday(long appid) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
USER, appid);
}
private ResultActions gamePoints(long appid) throws Exception {
return mockMvc.perform(get("/api/rewards/games/" + appid));
}
@Test
void gamePoints_SecondCall_SameRowNotBookedNow() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].bookedNow").value(true));
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].bookedNow").value(false))
.andExpect(jsonPath("$.balance").value(30));
}
@Test
void gamePoints_SteamFinishWithoutProof_MissingFinishIsNoProof() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("NO_PROOF"));
}
@Test
void gamePoints_FourthManualFinishInAWeek_MissingFinishIsWeeklyLimit() throws Exception {
for (long appid = -4_100_010L; appid > -4_100_014L; appid--) {
game(appid, "MANUAL", "BACKLOG", 0);
setStatus(appid, "FINISHED");
}
gamePoints(-4_100_013L)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.FINISHED").value("WEEKLY_LIMIT"));
gamePoints(-4_100_010L).andExpect(jsonPath("$.paid[0].kind").value("FINISHED_MANUAL"));
}
@Test
void gamePoints_OtherEditionPaid_ListsItsRow() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
game(EDITION, "STEAM", "BACKLOG", 0);
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
playedToday(GAME);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", USER);
gamePoints(EDITION)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.paid[0].appid").value(GAME))
.andExpect(jsonPath("$.missing.FINISHED").doesNotExist());
}
private void allAchievements() {
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)"
+ " on conflict (user_id, appid) do update set achievements_unlocked = 10", USER, GAME);
}
@Test
void gamePoints_StartedFinishedCompleted_ListsAllThreeRowsWithDates() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
allAchievements();
setStatus(GAME, "PLAYING");
setStatus(GAME, "FINISHED");
setStatus(GAME, "COMPLETED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(3))
.andExpect(jsonPath("$.paid[?(@.kind == 'STARTED')].points").value(5))
.andExpect(jsonPath("$.paid[?(@.kind == 'FINISHED')].points").value(30))
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.paid[0].paidAt").isNotEmpty())
.andExpect(jsonPath("$.missing").isEmpty())
.andExpect(jsonPath("$.balance").value(75));
}
@Test
void gamePoints_CompletedWithoutAllAchievements_MissingCompletedIsNotAllAchievements() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
setStatus(GAME, "COMPLETED");
gamePoints(GAME)
.andExpect(jsonPath("$.paid.length()").value(1))
.andExpect(jsonPath("$.paid[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.missing.COMPLETED").value("NOT_ALL_ACHIEVEMENTS"));
}
@Test
void gamePoints_HiddenGame_OneHiddenReasonOnly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
gamePoints(GAME)
.andExpect(jsonPath("$.paid").isEmpty())
.andExpect(jsonPath("$.missing.length()").value(1))
.andExpect(jsonPath("$.missing.FINISHED").value("HIDDEN"));
}
@Test
void gamePoints_HiddenAfterPaidStart_MissingFinishIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "PLAYING");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("STARTED"));
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("HIDDEN"));
}
@Test
void gamePoints_HiddenAfterPaidFinish_MissingCompletedIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.paid[0].kind").value("FINISHED"));
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
allAchievements();
setStatus(GAME, "COMPLETED");
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("HIDDEN"));
}
@Test
void gamePoints_HiddenStartedGame_MissingStartIsHidden() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
setStatus(GAME, "PLAYING");
gamePoints(GAME).andExpect(jsonPath("$.missing.STARTED").value("HIDDEN"));
}
@Test
void gamePoints_PsnRow_SaysPlaystation() throws Exception {
game(GAME, "PSN", "BACKLOG", 120);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION"));
}
@Test
void gamePoints_LinkedPsnTitle_SaysPlaystationLinked() 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);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("PLAYSTATION_LINKED"));
}
@Test
void gamePoints_FinishBeforeTheStatusLog_SaysBeforeHistory() throws Exception {
game(GAME, "STEAM", "FINISHED", 120);
jdbc.update("delete from user_game_status_change where user_id = ?", USER);
gamePoints(GAME).andExpect(jsonPath("$.missing.FINISHED").value("BEFORE_HISTORY"));
}
@Test
void gamePoints_FinishedPlayingFinished_PaysOnce() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120);
playedToday(GAME);
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
setStatus(GAME, "PLAYING");
setStatus(GAME, "FINISHED");
gamePoints(GAME).andExpect(jsonPath("$.balance").value(30));
assertThat(rows("FINISHED")).isEqualTo(1);
}
@Test
void gamePoints_GameNotInLibrary_Is404() throws Exception {
gamePoints(GAME)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void gamePoints_AnotherUsersGame_Is404AndBooksNothingForThem() 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);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)",
OTHER_USER, GAME);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ?", OTHER_USER);
gamePoints(GAME).andExpect(status().isNotFound());
assertThat(jdbc.queryForObject("select count(*) from reward_ledger where user_id = ?", Integer.class, OTHER_USER))
.isZero();
}
@Test
void wallet_ManualGameInTheHistoryHasItsName() throws Exception {
game(-4_100_020L, "MANUAL", "BACKLOG", 0);
@@ -24,6 +24,86 @@ class RewardRulesTest extends AbstractUnitTest {
return RewardRules.finish(facts).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);
}
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);
assertThat(RewardRules.blocked(all)).isEqualTo(NoPointsReason.HIDDEN);
assertThat(RewardRules.blocked(new GameRewardFacts(false, true, false, false, true, true, true, true)))
.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);
GameRewardFacts g = game(true, true, false, true);
assertThat(RewardRules.noFinishReason(g, manual, true)).isEqualTo(NoPointsReason.WEEKLY_LIMIT);
assertThat(RewardRules.noFinishReason(g, manual, false)).isNull();
}
@Test
void noFinishReason_SteamWithoutProof_IsNoProof() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(60, 300, false, false), false))
.isEqualTo(NoPointsReason.NO_PROOF);
}
@Test
void noFinishReason_SteamWithoutPlaytime_IsNoPlaytime() {
assertThat(RewardRules.noFinishReason(STEAM_FINISHED, steam(0, 300, true, true), false))
.isEqualTo(NoPointsReason.NO_PLAYTIME);
}
@Test
void noFinishReason_NoStatusLog_IsBeforeHistory() {
assertThat(RewardRules.noFinishReason(game(false, true, false, false), null, false))
.isEqualTo(NoPointsReason.BEFORE_HISTORY);
assertThat(RewardRules.noFinishReason(game(false, false, false, false), null, false))
.isEqualTo(NoPointsReason.NOT_FINISHED);
}
@Test
void noStartReason_ManualGame_IsManualGame() {
assertThat(RewardRules.noStartReason(game(true, false, false, false), null)).isEqualTo(NoPointsReason.MANUAL_GAME);
}
@Test
void noStartReason_NoPlayAfterStart() {
StartFacts idle = new StartFacts(220L, false, AT, false);
assertThat(RewardRules.noStartReason(game(false, false, false, false), idle))
.isEqualTo(NoPointsReason.NO_PLAY_AFTER_START);
assertThat(RewardRules.noStartReason(new GameRewardFacts(false, false, false, false, false, false, false, false),
null)).isEqualTo(NoPointsReason.NOT_STARTED);
}
@Test
void noCompletedReason_WithoutProvenFinish_IsNeedsProvenFinish() {
GameRewardFacts g = game(false, true, true, true);
assertThat(RewardRules.noCompletedReason(g, null, false)).isEqualTo(NoPointsReason.NEEDS_PROVEN_FINISH);
assertThat(RewardRules.noCompletedReason(STEAM_FINISHED, null, true)).isEqualTo(NoPointsReason.NOT_COMPLETED);
assertThat(RewardRules.noCompletedReason(game(true, true, true, true), null, true))
.isEqualTo(NoPointsReason.MANUAL_GAME);
}
@Test
void noCompletedReason_MissingAchievements() {
FinishFacts open = new FinishFacts(220L, false, true, 60, 0, null, false, false, false, AT);
assertThat(RewardRules.noCompletedReason(game(false, true, true, true), open, true))
.isEqualTo(NoPointsReason.NOT_ALL_ACHIEVEMENTS);
}
@Test
void finish_PaysByTheLengthOfTheGame() {
assertThat(points(steam(60, null, true, false))).isEqualTo(30);
@@ -1,6 +1,7 @@
package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.FinishFacts;
import core_service.modules.rewards.domain.GameRewardFacts;
import core_service.modules.rewards.domain.RewardEntry;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.StartFacts;
@@ -64,6 +65,68 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
USER, day, GAME);
}
@Test
void game_FlagsHiddenPsnLinkedManualAndStatusLog() {
game("BACKLOG", 120);
setStatus("PLAYING");
setStatus("FINISHED");
jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
assertThat(repository.game(USER, GAME)).hasValue(
new GameRewardFacts(false, true, false, true, false, true, true, true));
}
@Test
void game_RemovedGame_IsEmpty() {
game("BACKLOG", 120);
jdbc.update("update user_game set removed_at = now() where user_id = ?", USER);
assertThat(repository.game(USER, GAME)).isEmpty();
assertThat(repository.game(USER, EDITION)).isEmpty();
}
@Test
void startFinishCompletedCandidates_WithAppid_OnlyThatGame() {
game("BACKLOG", 120);
jdbc.update("insert into steam_game (appid, name) values (?, 'Other') on conflict do nothing", EDITION);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 120)",
USER, EDITION);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER);
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ?", USER);
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(2);
assertThat(repository.startCandidates(USER, BERLIN, EDITION)).extracting(StartFacts::appid)
.containsExactly(EDITION);
assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(2);
assertThat(repository.finishCandidates(USER, BERLIN, GAME)).extracting(FinishFacts::appid)
.containsExactly(GAME);
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(2);
assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME);
}
@Test
void paidRows_ListsTheRowOfAnotherEditionOfTheSet() {
game("BACKLOG", 120);
jdbc.update("insert into steam_game (appid, name) values (?, 'Edition') on conflict do nothing", EDITION);
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)",
USER, EDITION);
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, EDITION);
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
ledger.add(USER, new RewardEntry(RewardKind.MONTH, "2026-09", 40, Instant.now()));
assertThat(repository.paidRows(USER, EDITION)).singleElement().satisfies(row -> {
assertThat(row.kind()).isEqualTo(RewardKind.FINISHED);
assertThat(row.appid()).isEqualTo(GAME);
assertThat(row.points()).isEqualTo(30);
});
assertThat(repository.paidRows(7777L, GAME)).isEmpty();
}
@Test
void finishCandidates_AppearsAfterTheChangeWithItsFacts() {
game("BACKLOG", 120);