fix(rewards): let manual finish open COMPLETED for Steam edition

A paid FINISHED_MANUAL on a set edition blocked COMPLETED for the
Steam game forever. It now opens COMPLETED, but only when the Steam
game's first finish is on or after app.rewards.early-since. Refs #35
This commit is contained in:
lobo committed 2026-10-11 13:07:26 +02:00
1 parent 6215bb0dec
commit 1a9a4e912c
8 files changed
+174 -33

No files matched your search

+2 -1
View File
@@ -143,7 +143,8 @@ flowchart LR
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only `app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED` or `FINISHED_TOPUP` row. a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED`, `FINISHED_TOPUP` or `FINISHED_MANUAL` row on the game or its set, so a manual edition's finish opens it for the Steam edition, but only when the Steam game's own first finish
(first `FINISHED`/`COMPLETED` status change) is on or after `app.rewards.early-since`; a manual game itself never gets it.
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant `GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend), for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend),
banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only
@@ -30,7 +30,7 @@ public enum NoPointsReason {
WEEKLY_LIMIT, WEEKLY_LIMIT,
/** The weekly cap is used up, so no early points; proof still pays the full finish. */ /** The weekly cap is used up, so no early points; proof still pays the full finish. */
WEEKLY_LIMIT_NO_PROOF, WEEKLY_LIMIT_NO_PROOF,
/** COMPLETED needs a FINISHED or FINISHED_TOPUP row first. */ /** COMPLETED needs a FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row on the game or its set first. */
NEEDS_PROVEN_FINISH, NEEDS_PROVEN_FINISH,
/** COMPLETED needs all achievements. */ /** COMPLETED needs all achievements. */
NOT_ALL_ACHIEVEMENTS NOT_ALL_ACHIEVEMENTS
@@ -30,6 +30,11 @@ public enum RewardKind {
*/ */
public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY); public static final List<RewardKind> FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_EARLY);
/**
* The finish kinds that pay the full finish: a proven Steam finish, its top-up, or a manual finish. They open COMPLETED.
*/
public static final List<RewardKind> FULL_FINISH_KINDS = List.of(FINISHED, FINISHED_MANUAL, FINISHED_TOPUP);
/** /**
* The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish. * The finish kinds that share the weekly cap: a manual finish and an unproven Steam finish.
*/ */
@@ -191,17 +191,17 @@ public final class RewardRules {
/** /**
* @param c the completed candidate of the game, null when it is none * @param c the completed candidate of the game, null when it is none
* @param provenFinishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set * @param finishPaid a FINISHED or FINISHED_TOPUP row is paid for the game or its set, or a FINISHED_MANUAL row opens COMPLETED
* @return why no COMPLETED row is paid, null when nothing stops it * @return why no COMPLETED row is paid, null when nothing stops it
*/ */
public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean provenFinishPaid) { public static NoPointsReason noCompletedReason(GameRewardFacts g, FinishFacts c, boolean finishPaid) {
if (!g.completed()) { if (!g.completed()) {
return NoPointsReason.NOT_COMPLETED; return NoPointsReason.NOT_COMPLETED;
} }
if (g.manual()) { if (g.manual()) {
return NoPointsReason.MANUAL_GAME; return NoPointsReason.MANUAL_GAME;
} }
if (!provenFinishPaid) { if (!finishPaid) {
return NoPointsReason.NEEDS_PROVEN_FINISH; return NoPointsReason.NEEDS_PROVEN_FINISH;
} }
return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null; return c != null && !c.allAchievements() ? NoPointsReason.NOT_ALL_ACHIEVEMENTS : null;
@@ -13,6 +13,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Date; import java.sql.Date;
import java.time.Instant;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Collection; import java.util.Collection;
@@ -165,25 +166,34 @@ public class RewardFactsRepository {
} }
/** /**
* COMPLETED Steam games with a paid FINISHED row (on the key or any key of its set) and no COMPLETED row yet; finishedAt is the FINISHED event. * COMPLETED games with a paid FINISHED, FINISHED_TOPUP or FINISHED_MANUAL row (on the key or any key of its set)
* Every edition of a linked set is a candidate; the service books one per set. * and no COMPLETED row yet; finishedAt is the first of those rows. A manual game is a candidate with manual set, so
* the rules reject it. Every edition of a linked set is a candidate; the service books one per set.
*/ */
public List<FinishFacts> completedCandidates(long userId) { public List<FinishFacts> completedCandidates(long userId, Instant earlySince) {
return completedCandidates(userId, null); return completedCandidates(userId, null, earlySince);
} }
/** /**
* @param appid only this game, null for all * @param appid only this game, null for all
* @param earlySince a FINISHED_MANUAL row opens COMPLETED only when the game itself first reached FINISHED or
* COMPLETED on or after this. A manual edition can be added already finished, so its fresh row
* says nothing about an old Steam finish; no status change at all counts as old.
*/ */
public List<FinishFacts> completedCandidates(long userId, Long appid) { public List<FinishFacts> completedCandidates(long userId, Long appid, Instant earlySince) {
return jdbc.query(""" return jdbc.query("""
select ug.appid, ug.playtime_minutes, f.event_at, select ug.appid, ug.source = 'MANUAL' as manual, ug.playtime_minutes, f.event_at,
coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false) coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false)
as all_achievements as all_achievements
from user_game ug from user_game ug
left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid
join lateral (select min(l.event_at) as event_at from reward_ledger l join lateral (select min(l.event_at) as event_at from reward_ledger l
where l.user_id = ug.user_id and l.kind in ('FINISHED', 'FINISHED_TOPUP') where l.user_id = ug.user_id
and (l.kind in ('FINISHED', 'FINISHED_TOPUP')
or (l.kind = 'FINISHED_MANUAL' and (select min(c.changed_at) from user_game_status_change c
where c.user_id = ug.user_id and c.appid = ug.appid
and c.to_status in ('FINISHED', 'COMPLETED'))
>= :earlySince))
and (l.ref = ug.appid::text and (l.ref = ug.appid::text
or l.ref in (select o.appid::text from user_game_set o or l.ref in (select o.appid::text from user_game_set o
where o.user_id = st.user_id and o.set_id = st.set_id))) f where o.user_id = st.user_id and o.set_id = st.set_id))) f
@@ -196,9 +206,10 @@ public class RewardFactsRepository {
and not %1$s and not %1$s
order by f.event_at, ug.appid order by f.event_at, ug.appid
""".formatted(paidInSet("ug", "'COMPLETED'")), """.formatted(paidInSet("ug", "'COMPLETED'")),
new MapSqlParameterSource("userId", userId).addValue("appid", appid), new MapSqlParameterSource("userId", userId).addValue("appid", appid)
(rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null, .addValue("earlySince", java.sql.Timestamp.from(earlySince)),
false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(), (rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), true, rs.getInt("playtime_minutes"), 0,
null, false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant(),
false)); false));
} }
@@ -301,9 +301,10 @@ public class RewardService {
putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish)); putReason(missing, RewardKind.FINISHED_TOPUP, RewardRules.noTopUpReason(game, finish));
} }
if (noRow(paid, List.of(RewardKind.COMPLETED))) { if (noRow(paid, List.of(RewardKind.COMPLETED))) {
FinishFacts completed = facts.completedCandidates(userId, appid).stream().findFirst().orElse(null); FinishFacts completed = facts.completedCandidates(userId, appid, earlySince()).stream().findFirst().orElse(null);
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, // A candidate means an opener passed, a FINISHED_MANUAL row dated before early-since is none.
!noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP)))); boolean finishPaid = completed != null || !noRow(paid, List.of(RewardKind.FINISHED, RewardKind.FINISHED_TOPUP));
putReason(missing, RewardKind.COMPLETED, RewardRules.noCompletedReason(game, completed, finishPaid));
} }
return missing; return missing;
} }
@@ -363,7 +364,7 @@ public class RewardService {
private int bookOnePerSet(long userId, RewardEntry entry) { private int bookOnePerSet(long userId, RewardEntry entry) {
List<RewardKind> kinds = switch (entry.kind()) { List<RewardKind> kinds = switch (entry.kind()) {
case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS; case FINISHED, FINISHED_MANUAL, FINISHED_EARLY -> RewardKind.FINISH_KINDS;
case FINISHED_TOPUP -> List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL, RewardKind.FINISHED_TOPUP); case FINISHED_TOPUP -> RewardKind.FULL_FINISH_KINDS;
default -> List.of(entry.kind()); default -> List.of(entry.kind());
}; };
return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry); return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry);
@@ -397,7 +398,7 @@ public class RewardService {
} }
private int settleCompleted(long userId) { private int settleCompleted(long userId) {
return facts.completedCandidates(userId).stream() return facts.completedCandidates(userId, earlySince()).stream()
.flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); .flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum();
} }
@@ -481,6 +481,79 @@ class RewardControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.balance").value(70)); .andExpect(jsonPath("$.balance").value(70));
} }
private void paidRow(String kind, long appid) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 15, now())",
USER, kind, String.valueOf(appid));
}
private void completedSetWithManualFinish() {
game(EDITION, "MANUAL", "COMPLETED", 0);
game(GAME, "STEAM", "BACKLOG", 120);
setStatus(GAME, "COMPLETED");
allAchievements();
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
paidRow("FINISHED_MANUAL", EDITION);
}
@Test
void gamePoints_SetManualFinishPaid_SteamEditionCompletedPaysOnce() throws Exception {
completedSetWithManualFinish();
gamePoints(GAME)
.andExpect(jsonPath("$.paid[?(@.kind == 'COMPLETED')].points").value(40))
.andExpect(jsonPath("$.missing.COMPLETED").doesNotExist());
gamePoints(GAME);
gamePoints(EDITION);
assertThat(rows("COMPLETED")).isEqualTo(1);
assertThat(rows("FINISHED")).isZero();
assertThat(rows("FINISHED_MANUAL")).isEqualTo(1);
}
@Test
void gamePoints_SetManualFinishPaid_SteamFinishedBeforeEarlySince_NoCompleted() throws Exception {
completedSetWithManualFinish();
jdbc.update("update user_game_status_change set changed_at = '2026-01-01T00:00:00Z' where user_id = ? and appid = ?",
USER, GAME);
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH"));
assertThat(rows("COMPLETED")).isZero();
}
@Test
void gamePoints_ManualEditionAddedCompletedForOldSteamFinish_NoCompleted() throws Exception {
game(GAME, "STEAM", "COMPLETED", 120);
jdbc.update("update user_game_status_change set changed_at = '2025-01-01T00:00:00Z' where user_id = ? and appid = ?",
USER, GAME);
allAchievements();
game(EDITION, "MANUAL", "COMPLETED", 0);
jdbc.update("insert into game_link (appid, set_id) values (?, 4100), (?, 4100)", GAME, EDITION);
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("NEEDS_PROVEN_FINISH"));
assertThat(rows("COMPLETED")).isZero();
}
@Test
void gamePoints_SetManualAndSteamFinish_CompletedOnce() throws Exception {
completedSetWithManualFinish();
paidRow("FINISHED", GAME);
gamePoints(GAME);
gamePoints(EDITION);
assertThat(rows("COMPLETED")).isEqualTo(1);
}
@Test
void gamePoints_ManualGameCompletedWithManualFinish_NoCompleted() throws Exception {
game(GAME, "MANUAL", "COMPLETED", 0);
allAchievements();
paidRow("FINISHED_MANUAL", GAME);
gamePoints(GAME).andExpect(jsonPath("$.missing.COMPLETED").value("MANUAL_GAME"));
assertThat(rows("COMPLETED")).isZero();
}
@Test @Test
void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception { void gamePoints_FinishedPlayingFinishedAfterEarly_PaysNoSecondEarly() throws Exception {
game(GAME, "STEAM", "BACKLOG", 120); game(GAME, "STEAM", "BACKLOG", 120);
@@ -25,6 +25,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 7L; private static final long USER = 7L;
private static final long GAME = 220L; private static final long GAME = 220L;
private static final long EDITION = 221L; private static final long EDITION = 221L;
private static final java.time.Instant EARLY = java.time.Instant.parse("2026-09-10T00:00:00Z");
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired @Autowired
@@ -105,8 +106,8 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); 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())); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(EDITION), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(2); assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2);
assertThat(repository.completedCandidates(USER, GAME)).extracting(FinishFacts::appid).containsExactly(GAME); assertThat(repository.completedCandidates(USER, GAME, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
} }
@Test @Test
@@ -217,12 +218,12 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1); assertThat(repository.startCandidates(USER, BERLIN)).hasSize(1);
setStatus("COMPLETED"); setStatus("COMPLETED");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now())); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, Instant.now()));
assertThat(repository.completedCandidates(USER)).hasSize(1); assertThat(repository.completedCandidates(USER, EARLY)).hasSize(1);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME); jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
@Test @Test
@@ -275,15 +276,15 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
void completedCandidates_NeedACompletedGameWithAPaidFinish() { void completedCandidates_NeedACompletedGameWithAPaidFinish() {
game("BACKLOG", 120); game("BACKLOG", 120);
setStatus("COMPLETED"); setStatus("COMPLETED");
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z"); Instant finishedAt = Instant.parse("2026-09-15T10:00:00Z");
ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt)); ledger.add(USER, new RewardEntry(RewardKind.FINISHED, String.valueOf(GAME), 30, finishedAt));
assertThat(repository.completedCandidates(USER)).singleElement() assertThat(repository.completedCandidates(USER, EARLY)).singleElement()
.extracting(FinishFacts::finishedAt).isEqualTo(finishedAt); .extracting(FinishFacts::finishedAt).isEqualTo(finishedAt);
ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt)); ledger.add(USER, new RewardEntry(RewardKind.COMPLETED, String.valueOf(GAME), 40, finishedAt));
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
// The game and a manual edition of it, one set, both with the given status. // The game and a manual edition of it, one set, both with the given status.
@@ -329,11 +330,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
setOfTwo("COMPLETED"); setOfTwo("COMPLETED");
paid(RewardKind.FINISHED, GAME); paid(RewardKind.FINISHED, GAME);
paid(RewardKind.FINISHED, EDITION); paid(RewardKind.FINISHED, EDITION);
assertThat(repository.completedCandidates(USER)).hasSize(2); assertThat(repository.completedCandidates(USER, EARLY)).hasSize(2);
paid(RewardKind.COMPLETED, EDITION); paid(RewardKind.COMPLETED, EDITION);
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
} }
@Test @Test
@@ -411,7 +412,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty(); assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
assertThat(repository.minutesPerMonth(USER)).isEmpty(); assertThat(repository.minutesPerMonth(USER)).isEmpty();
} }
@@ -483,7 +484,56 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME); jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
paid(RewardKind.FINISHED, other); paid(RewardKind.FINISHED, other);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME); assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).contains(GAME);
}
private void allAchievements() {
jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true) "
+ "on conflict do nothing", GAME);
jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME);
}
@Test
void completedCandidates_ManualFinishPaidOnAnotherEditionOfTheSet() {
setOfTwo("COMPLETED");
jdbc.update("update user_game set status = 'COMPLETED' where user_id = ? and appid = ?", USER, EDITION);
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == GAME).singleElement()
.satisfies(f -> assertThat(f.manual()).isFalse());
assertThat(repository.completedCandidates(USER, EARLY)).filteredOn(f -> f.appid() == EDITION)
.allSatisfy(f -> assertThat(f.manual()).isTrue());
}
@Test
void completedCandidates_ManualFinishOpensNothingWhenTheSteamGameFinishedBeforeEarlySince() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
changedAt("COMPLETED", "2026-01-01T00:00:00Z");
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
}
@Test
void completedCandidates_ManualFinishOpensNothingWhenTheSteamGameHasNoStatusChange() {
setOfTwo("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, EDITION);
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.completedCandidates(USER, GAME, EARLY)).isEmpty();
}
@Test
void completedCandidates_ManualFinishOnOwnSteamKey() {
game("BACKLOG", 120);
setStatus("COMPLETED");
allAchievements();
paid(RewardKind.FINISHED_MANUAL, GAME);
assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
} }
private void linkedTitle() { private void linkedTitle() {
@@ -554,11 +604,11 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
game("BACKLOG", 120); game("BACKLOG", 120);
setStatus("COMPLETED"); setStatus("COMPLETED");
paid(RewardKind.FINISHED_EARLY, GAME); paid(RewardKind.FINISHED_EARLY, GAME);
assertThat(repository.completedCandidates(USER)).isEmpty(); assertThat(repository.completedCandidates(USER, EARLY)).isEmpty();
paid(RewardKind.FINISHED_TOPUP, GAME); paid(RewardKind.FINISHED_TOPUP, GAME);
assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).containsExactly(GAME); assertThat(repository.completedCandidates(USER, EARLY)).extracting(FinishFacts::appid).containsExactly(GAME);
} }
@Test @Test