feat(library): add question queue for open game decisions

Add open questions per game: Start it?, Finished?, Dropped?, Rate it?.
A "No" is saved in user_game_question (V41) and snoozes the question.
Account cleanup clears the new table too. Refs Gitea #32.
This commit is contained in:
lobo committed 2026-10-09 09:31:48 +02:00
1 parent 0ddb345a41
commit 6cd92f3e83
18 files changed
+934 -2

No files matched your search

@@ -20,7 +20,7 @@ import java.util.Map;
public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
static final List<String> TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
@@ -0,0 +1,59 @@
package core_service.modules.library.api;
import core_service.modules.library.api.dto.AnsweredDto;
import core_service.modules.library.api.dto.QuestionAnswer;
import core_service.modules.library.api.dto.QuestionDto;
import core_service.modules.library.api.dto.QuestionsDto;
import core_service.modules.library.domain.Question;
import core_service.modules.library.service.QuestionService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/library/questions")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
@GetMapping
public QuestionsDto list(@AuthenticationPrincipal UserPrincipal principal) {
return new QuestionsDto(questionService.open(principal.getIdAsLong()).stream().map(QuestionDto::from).toList());
}
@GetMapping("/count")
public Map<String, Integer> count(@AuthenticationPrincipal UserPrincipal principal) {
return Map.of("count", questionService.count(principal.getIdAsLong()));
}
@PostMapping("/{appid}")
public ResponseEntity<?> answer(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long appid,
@Valid @RequestBody QuestionAnswer answer) {
if (answer.question() == Question.RATE && answer.yes() && answer.rating() == null) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("INVALID_ANSWER", "A yes to RATE needs a rating"));
}
QuestionService.Answered answered = questionService.answer(principal.getIdAsLong(), appid, answer.question(),
answer.yes(), answer.rating());
return switch (answered.result()) {
case DONE -> ResponseEntity.ok(new AnsweredDto(answered.next().map(QuestionDto::from).orElse(null)));
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("GAME_NOT_FOUND", "Game not in user library"));
case GONE -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("QUESTION_GONE", "The question is not open any more"));
};
}
}
@@ -0,0 +1,4 @@
package core_service.modules.library.api.dto;
/** @param next the next open question of the same game; null when there is none */
public record AnsweredDto(QuestionDto next) {}
@@ -0,0 +1,12 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.Question;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
/**
* @param yes yes changes the game; no snoozes the question
* @param rating 1 to 5; needed for a yes to RATE
*/
public record QuestionAnswer(@NotNull Question question, @NotNull Boolean yes, @Min(1) @Max(5) Integer rating) {}
@@ -0,0 +1,22 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Question;
import core_service.modules.library.service.QuestionService;
import java.time.Instant;
/**
* A game with an open question.
*
* @param lastActivity the last sign of play; only for DROPPED
*/
public record QuestionDto(long appid, String name, String imageUrl, Question question, GameStatus status,
int playtimeMinutes, Integer normallyMinutes, Instant lastActivity) {
public static QuestionDto from(QuestionService.Open open) {
return new QuestionDto(open.game().appid(), open.game().name(), open.game().imageUrl(),
open.question().question(), open.game().status(), open.game().playtimeMinutes(),
open.game().normallyMinutes(), open.question().lastActivity());
}
}
@@ -0,0 +1,5 @@
package core_service.modules.library.api.dto;
import java.util.List;
public record QuestionsDto(List<QuestionDto> results) {}
@@ -0,0 +1,62 @@
package core_service.modules.library.domain;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
/**
* The open question of a game in the question queue. Only a question: the user decides.
*
* @param lastActivity the last sign of play, for the Dropped? text; null for the other questions
*/
public record OpenQuestion(Question question, Instant lastActivity) {
static final int SNOOZE_MINUTES = 300;
static final int FINISHED_FALLBACK_MINUTES = 600;
static final Duration IDLE = Duration.ofDays(90);
/** The first rule that matches wins. Every rule starts from the status, so an automatic status change closes it. */
public static Optional<OpenQuestion> of(QuestionFacts facts, Instant now, ZoneId zone) {
GameStatus status = facts.status();
int playtime = facts.playtimeMinutes();
if (status == GameStatus.BACKLOG && playtime > 0 && isDue(playtime, facts.startAskedAt())) {
return Optional.of(new OpenQuestion(Question.START, null));
}
if ((status == GameStatus.BACKLOG || status == GameStatus.PLAYING) && !Boolean.TRUE.equals(facts.endless())
&& playtime >= finishedMinutes(facts) && isDue(playtime, facts.finishedAskedAt())) {
return Optional.of(new OpenQuestion(Question.FINISHED, null));
}
if (status == GameStatus.PLAYING && facts.playtime2Weeks() == 0) {
Instant lastActivity = lastActivity(facts, zone);
if (lastActivity.isBefore(now.minus(IDLE))) return Optional.of(new OpenQuestion(Question.DROPPED, lastActivity));
}
if ((status == GameStatus.FINISHED || status == GameStatus.COMPLETED) && facts.rating() == null
&& !facts.rateSkipped()) {
return Optional.of(new OpenQuestion(Question.RATE, null));
}
return Optional.empty();
}
private static boolean isDue(int playtime, Integer askedAt) {
return askedAt == null || playtime >= askedAt + SNOOZE_MINUTES;
}
private static int finishedMinutes(QuestionFacts facts) {
Integer normally = facts.normallyMinutes();
return normally != null && normally > 0 ? normally : FINISHED_FALLBACK_MINUTES;
}
/** The first sighting is the floor, so a new user waits the full idle time. */
private static Instant lastActivity(QuestionFacts facts, ZoneId zone) {
Instant playDay = facts.lastPlayDay() == null ? null
: facts.lastPlayDay().plusDays(1).atStartOfDay(zone).toInstant();
return Stream.of(playDay, facts.lastPlayedAt(), facts.lastStatusChange(), facts.firstSeenAt(),
facts.droppedAnsweredAt())
.filter(Objects::nonNull)
.max(Instant::compareTo)
.orElse(Instant.EPOCH);
}
}
@@ -0,0 +1,9 @@
package core_service.modules.library.domain;
/** A question of the question queue. The declaration order is the ask order: one question per game at a time. */
public enum Question {
START,
FINISHED,
DROPPED,
RATE
}
@@ -0,0 +1,21 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.time.LocalDate;
/**
* Everything {@link OpenQuestion#of} needs to know about one game of one user.
*
* @param normallyMinutes IGDB's normal time to beat; null when unknown
* @param endless see {@link Mood#endless}; null when IGDB does not know the game
* @param lastPlayDay the last day with Steam playtime
* @param lastStatusChange the last time the status changed
* @param startAskedAt the playtime at the "No" to Start it?; null when never answered
* @param finishedAskedAt the playtime at the "No" to Finished?; null when never answered
* @param droppedAnsweredAt the time of the "No" to Dropped?; null when never answered
*/
public record QuestionFacts(long appid, String name, String imageUrl, GameStatus status, Integer rating,
int playtimeMinutes, int playtime2Weeks, Integer normallyMinutes, Boolean endless,
LocalDate lastPlayDay, Instant lastPlayedAt, Instant lastStatusChange, Instant firstSeenAt,
Integer startAskedAt, Integer finishedAskedAt, Instant droppedAnsweredAt,
boolean rateSkipped) {}
@@ -0,0 +1,86 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Question;
import core_service.modules.library.domain.QuestionFacts;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/** The facts behind the question queue, and the "No" answers it stores. Play activity covers every listed edition of a linked set. */
@Repository
@RequiredArgsConstructor
public class QuestionRepository {
private static final RowMapper<QuestionFacts> FACTS = (rs, i) -> new QuestionFacts(
rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"),
GameStatus.valueOf(rs.getString("status")), (Integer) rs.getObject("rating"),
rs.getInt("playtime_minutes"), rs.getInt("playtime_2weeks"), (Integer) rs.getObject("normally_minutes"),
(Boolean) rs.getObject("endless"), rs.getObject("last_day", LocalDate.class),
instant(rs.getTimestamp("last_played_at")), instant(rs.getTimestamp("last_change")),
instant(rs.getTimestamp("first_seen_at")), (Integer) rs.getObject("start_asked"),
(Integer) rs.getObject("finished_asked"), instant(rs.getTimestamp("dropped_answered")),
rs.getBoolean("rate_skipped"));
private final NamedParameterJdbcTemplate jdbc;
/** @param appid one game; null for all games of the user */
public List<QuestionFacts> facts(long userId, Long appid) {
return jdbc.query("""
with members as (
select ug.appid as counted, ug.appid as member from counted_user_game ug where ug.user_id = :userId
union
select ug.appid, o.appid from counted_user_game ug
join game_link l on l.appid = ug.appid
join game_link o on o.set_id = l.set_id and o.appid <> l.appid
join listed_user_game og on og.user_id = ug.user_id and og.appid = o.appid
where ug.user_id = :userId
)
select ug.appid, sg.name, sg.image_url, ug.status, ug.rating, ug.playtime_minutes, a.playtime_2weeks,
a.last_played_at, ug.first_seen_at, t.normally_minutes, m.endless, p.last_day, c.last_change,
q.start_asked, q.finished_asked, q.dropped_answered, coalesce(q.rate_skipped, false) as rate_skipped
from counted_user_game ug
left join steam_game sg on sg.appid = ug.appid
left join game_time_to_beat t on t.appid = ug.appid
left join game_mood m on m.appid = ug.appid
left join (select m.counted as appid, max(og.playtime_2weeks) as playtime_2weeks,
max(og.last_played_at) as last_played_at
from members m join listed_user_game og on og.user_id = :userId and og.appid = m.member
group by m.counted) a on a.appid = ug.appid
left join (select m.counted as appid, max(d.day) as last_day
from members m join user_game_playtime_day d on d.user_id = :userId and d.appid = m.member
group by m.counted) p on p.appid = ug.appid
left join (select m.counted as appid, max(s.changed_at) as last_change
from members m join user_game_status_change s on s.user_id = :userId and s.appid = m.member
group by m.counted) c on c.appid = ug.appid
left join (select appid,
max(playtime_minutes) filter (where question = 'START') as start_asked,
max(playtime_minutes) filter (where question = 'FINISHED') as finished_asked,
max(answered_at) filter (where question = 'DROPPED') as dropped_answered,
bool_or(question = 'RATE') as rate_skipped
from user_game_question where user_id = :userId group by appid) q on q.appid = ug.appid
where ug.user_id = :userId and (cast(:appid as bigint) is null or ug.appid = :appid)
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid), FACTS);
}
public void snooze(long userId, long appid, Question question, int playtimeMinutes) {
jdbc.update("""
insert into user_game_question (user_id, appid, question, playtime_minutes)
values (:userId, :appid, :question, :playtime)
on conflict (user_id, appid, question)
do update set playtime_minutes = excluded.playtime_minutes, answered_at = now()
""", new MapSqlParameterSource("userId", userId).addValue("appid", appid)
.addValue("question", question.name()).addValue("playtime", playtimeMinutes));
}
private static Instant instant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
}
@@ -65,6 +65,14 @@ public class BacklogService {
});
}
/** Sets only the rating, like a {@link #patch} that sends nothing else. */
@Transactional
public Optional<BacklogEntryDto> rate(Long userId, Long appid, int rating) {
BacklogPatch patch = new BacklogPatch();
patch.setRating(Optional.of(rating));
return patch(userId, appid, patch);
}
/**
* Sets the user's backlog order. Appids not in the backlog are ignored; backlog games not sent go after the sent
* ones in their old order.
@@ -0,0 +1,91 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.OpenQuestion;
import core_service.modules.library.domain.Question;
import core_service.modules.library.domain.QuestionFacts;
import core_service.modules.library.repo.QuestionRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/** The question queue: games with a decision the user has not made yet. */
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class QuestionService {
public record Open(QuestionFacts game, OpenQuestion question) {}
public enum AnswerResult { DONE, NOT_FOUND, GONE }
/** @param next the open question of the game after the answer, for example Rate it? after Finished? */
public record Answered(AnswerResult result, Optional<Open> next) {}
private static final Comparator<Open> ORDER = Comparator
.comparing((Open open) -> open.question().question())
.thenComparing(open -> open.game().playtimeMinutes(), Comparator.reverseOrder())
.thenComparingLong(open -> open.game().appid());
private final QuestionRepository questionRepository;
private final BacklogService backlogService;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
public List<Open> open(long userId) {
return questionRepository.facts(userId, null).stream()
.flatMap(facts -> toOpen(facts).stream())
.sorted(ORDER)
.toList();
}
public int count(long userId) {
return open(userId).size();
}
/**
* Not transactional on purpose: {@link BacklogService} commits its own change, so the read of the next question
* sees it. "Yes" writes like the detail page does; "No" snoozes the question.
*/
public Answered answer(long userId, long appid, Question question, boolean yes, Integer rating) {
Optional<QuestionFacts> facts = factsOf(userId, appid);
if (facts.isEmpty()) return new Answered(AnswerResult.NOT_FOUND, Optional.empty());
Optional<Open> current = toOpen(facts.get());
if (current.isEmpty() || current.get().question().question() != question) {
return new Answered(AnswerResult.GONE, Optional.empty());
}
if (yes) {
if (write(userId, appid, question, rating).isEmpty()) {
return new Answered(AnswerResult.NOT_FOUND, Optional.empty());
}
} else {
questionRepository.snooze(userId, appid, question, facts.get().playtimeMinutes());
}
return new Answered(AnswerResult.DONE, factsOf(userId, appid).flatMap(this::toOpen));
}
private Optional<?> write(long userId, long appid, Question question, Integer rating) {
return switch (question) {
case START -> backlogService.updateStatus(userId, appid, GameStatus.PLAYING);
case FINISHED -> backlogService.updateStatus(userId, appid, GameStatus.FINISHED);
case DROPPED -> backlogService.updateStatus(userId, appid, GameStatus.DROPPED);
case RATE -> backlogService.rate(userId, appid, rating);
};
}
private Optional<QuestionFacts> factsOf(long userId, long appid) {
return questionRepository.facts(userId, appid).stream().findFirst();
}
private Optional<Open> toOpen(QuestionFacts facts) {
return OpenQuestion.of(facts, Instant.now(), zone).map(question -> new Open(facts, question));
}
}
@@ -0,0 +1,11 @@
-- "No" / "Skip" answers of the question queue (Gitea #32), one row per user, game and question.
CREATE TABLE user_game_question (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
question VARCHAR(16) NOT NULL, -- START, FINISHED, DROPPED, RATE
playtime_minutes INTEGER NOT NULL, -- user_game.playtime_minutes at the answer
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, appid, question),
-- A merge moves or drops user_game rows (V21 pattern); the answers follow.
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE
);
@@ -15,7 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountCleanupRepository.class)
class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
private static final List<String> USER_TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@@ -35,6 +35,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row.
jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId);
jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, 220, 'START', 0)", userId);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, 220, 30)", userId);
@@ -0,0 +1,186 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
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.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
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.request.MockMvcRequestBuilders.post;
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 QuestionFlowTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final long BACKLOG_GAME = 11L;
private static final long PLAYING_GAME = 12L;
private static final long FINISHED_GAME = 13L;
private static final long HIDDEN_GAME = 14L;
private static final long COMPLETED_GAME = 15L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("user_game_question", "user_game_playtime_day", "game_time_to_beat", "game_mood",
"game_link", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(BACKLOG_GAME, PLAYING_GAME, FINISHED_GAME, HIDDEN_GAME, COMPLETED_GAME)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
game(BACKLOG_GAME, "BACKLOG", 60);
game(PLAYING_GAME, "PLAYING", 700);
game(FINISHED_GAME, "FINISHED", 100);
game(COMPLETED_GAME, "COMPLETED", 100);
game(HIDDEN_GAME, "BACKLOG", 60);
jdbc.update("update user_game set hidden_at = now() where appid = ?", HIDDEN_GAME);
}
private void game(long appid, String status, int minutes) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
USER_ID, appid, status, minutes);
}
private ResultActions answer(long appid, String body) throws Exception {
return mockMvc.perform(post("/api/library/questions/" + appid).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private String statusOf(long appid) {
return jdbc.queryForObject("select status from user_game where appid = ?", String.class, appid);
}
@Test
void list_HasOneCardPerOpenGameInAskOrder_AndTheCountMatches() throws Exception {
mockMvc.perform(get("/api/library/questions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(4))
.andExpect(jsonPath("$.results[0].appid").value(BACKLOG_GAME))
.andExpect(jsonPath("$.results[0].question").value("START"))
.andExpect(jsonPath("$.results[1].appid").value(PLAYING_GAME))
.andExpect(jsonPath("$.results[1].question").value("FINISHED"))
.andExpect(jsonPath("$.results[2].question").value("RATE"));
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(4));
}
@Test
void finishedYes_SetsTheStatusLikeTheDetailPage_AndAsksToRateNext() throws Exception {
answer(PLAYING_GAME, "{\"question\":\"FINISHED\",\"yes\":true}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.next.question").value("RATE"));
assertThat(statusOf(PLAYING_GAME)).isEqualTo("FINISHED");
assertThat(jdbc.queryForObject("select status_set_by_user from user_game where appid = ?", Boolean.class,
PLAYING_GAME)).isTrue();
assertThat(jdbc.queryForObject("""
select count(*) from user_game_status_change
where appid = ? and to_status = 'FINISHED' and by_user""", Integer.class, PLAYING_GAME)).isEqualTo(1);
}
@Test
void startYes_SetsPlaying_AndHasNoNextQuestion() throws Exception {
answer(BACKLOG_GAME, "{\"question\":\"START\",\"yes\":true}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.next").doesNotExist());
assertThat(statusOf(BACKLOG_GAME)).isEqualTo("PLAYING");
}
@Test
void startNo_RemovesTheCard_AndItComesBackAfterFiveMoreHours() throws Exception {
answer(BACKLOG_GAME, "{\"question\":\"START\",\"yes\":false}").andExpect(status().isOk());
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(3));
jdbc.update("update user_game set playtime_minutes = 359 where appid = ?", BACKLOG_GAME);
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(3));
jdbc.update("update user_game set playtime_minutes = 360 where appid = ?", BACKLOG_GAME);
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(4));
assertThat(statusOf(BACKLOG_GAME)).isEqualTo("BACKLOG");
}
@Test
void rateYes_SavesTheRating_AlsoForACompletedGame() throws Exception {
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":true,\"rating\":4}").andExpect(status().isOk());
answer(COMPLETED_GAME, "{\"question\":\"RATE\",\"yes\":true,\"rating\":5}").andExpect(status().isOk());
assertThat(jdbc.queryForObject("select rating from user_game where appid = ?", Integer.class, FINISHED_GAME)).isEqualTo(4);
assertThat(jdbc.queryForObject("select rating from user_game where appid = ?", Integer.class, COMPLETED_GAME)).isEqualTo(5);
assertThat(statusOf(FINISHED_GAME)).isEqualTo("FINISHED");
}
@Test
void rateSkip_NeverAsksAgain() throws Exception {
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":false}").andExpect(status().isOk());
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(3));
}
@Test
void rateYesWithoutRating_Is400() throws Exception {
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":true}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_ANSWER"));
}
@Test
void badBody_Is400() throws Exception {
answer(PLAYING_GAME, "{\"question\":\"FINISHED\"}").andExpect(status().isBadRequest());
answer(PLAYING_GAME, "{\"yes\":true}").andExpect(status().isBadRequest());
answer(FINISHED_GAME, "{\"question\":\"RATE\",\"yes\":true,\"rating\":6}").andExpect(status().isBadRequest());
assertThat(statusOf(PLAYING_GAME)).isEqualTo("PLAYING");
}
@Test
void staleQuestion_Is409_AndChangesNothing() throws Exception {
answer(PLAYING_GAME, "{\"question\":\"START\",\"yes\":true}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("QUESTION_GONE"));
answer(PLAYING_GAME, "{\"question\":\"FINISHED\",\"yes\":true}").andExpect(status().isOk());
answer(PLAYING_GAME, "{\"question\":\"FINISHED\",\"yes\":true}").andExpect(status().isConflict());
assertThat(statusOf(PLAYING_GAME)).isEqualTo("FINISHED");
}
@Test
void linkedSetPlayedYesterdayOnTheOtherEdition_IsNotAskedDropped() throws Exception {
jdbc.update("delete from user_game");
for (long appid : List.of(21L, 22L)) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
}
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (21, 6000, now()), (22, 6000, now())");
game(21L, "PLAYING", 1800);
game(22L, "PLAYING", 300);
jdbc.update("update user_game set first_seen_at = now() - interval '1 year' where appid in (21, 22)");
jdbc.update("update user_game set last_played_at = now() - interval '1 day' where appid = 22");
jdbc.update("update user_game_status_change set changed_at = now() - interval '1 year' where appid in (21, 22)");
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date - 200, 21, 60)", USER_ID);
jdbc.update("insert into game_link (appid, set_id) values (21, 1), (22, 1)");
mockMvc.perform(get("/api/library/questions/count")).andExpect(jsonPath("$.count").value(0));
}
@Test
void hiddenOrUnknownGame_Is404() throws Exception {
answer(HIDDEN_GAME, "{\"question\":\"START\",\"yes\":true}").andExpect(status().isNotFound());
answer(999L, "{\"question\":\"START\",\"yes\":true}").andExpect(status().isNotFound());
assertThat(statusOf(HIDDEN_GAME)).isEqualTo("BACKLOG");
}
}
@@ -0,0 +1,185 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.assertThat;
class OpenQuestionTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant NOW = Instant.parse("2026-10-09T12:00:00Z");
private static final Instant LONG_AGO = NOW.minus(400, ChronoUnit.DAYS);
/** A game with a calm default: playing 1 h, played lately, rated. */
private static final class Game {
GameStatus status = GameStatus.PLAYING;
Integer rating = 3;
int playtime = 60;
int playtime2Weeks = 30;
Integer normally = null;
Boolean endless = null;
LocalDate lastPlayDay = null;
Instant lastPlayedAt = null;
Instant lastStatusChange = LONG_AGO;
Instant firstSeenAt = LONG_AGO;
Integer startAskedAt = null;
Integer finishedAskedAt = null;
Instant droppedAnsweredAt = null;
boolean rateSkipped = false;
QuestionFacts facts() {
return new QuestionFacts(1L, "Game", null, status, rating, playtime, playtime2Weeks, normally, endless,
lastPlayDay, lastPlayedAt, lastStatusChange, firstSeenAt, startAskedAt, finishedAskedAt,
droppedAnsweredAt, rateSkipped);
}
}
private static Question askIdle(UnaryOperator<Game> setup) {
return ask(g -> {
g.playtime2Weeks = 0;
return setup.apply(g);
});
}
private static Question ask(UnaryOperator<Game> setup) {
return OpenQuestion.of(setup.apply(new Game()).facts(), NOW, ZONE).map(OpenQuestion::question).orElse(null);
}
@Test
void start_PlayedBacklogGame() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 1; return g; })).isEqualTo(Question.START);
}
@Test
void start_NotAskedForAnUnplayedGame() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 0; return g; })).isNull();
}
@Test
void start_SnoozedUntilFiveMoreHours() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 100; g.startAskedAt = 100 - 299; return g; }))
.isNull();
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 400; g.startAskedAt = 100; return g; }))
.isEqualTo(Question.START);
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 399; g.startAskedAt = 100; return g; }))
.isNull();
}
@Test
void finished_UsesTimeToBeatWhenKnown() {
assertThat(ask(g -> { g.playtime = 1999; g.normally = 2000; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 2000; g.normally = 2000; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void finished_FallsBackToTenHoursWithoutTimeToBeat() {
assertThat(ask(g -> { g.playtime = 599; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 600; return g; })).isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.playtime = 600; g.normally = 0; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void finished_AlsoForBacklogGame_ButNeverForFinishedDroppedOrCompleted() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 700; g.startAskedAt = 600; return g; }))
.isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.status = GameStatus.DROPPED; g.playtime = 700; return g; })).isNull();
assertThat(ask(g -> { g.status = GameStatus.COMPLETED; g.playtime = 700; g.rating = 5; return g; })).isNull();
}
@Test
void finished_EndlessGameNeverAsks_ButUnknownOrFalseDoes() {
assertThat(ask(g -> { g.playtime = 700; g.endless = true; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 700; g.endless = false; return g; })).isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.playtime = 700; g.endless = null; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void finished_SnoozedUntilFiveMoreHours() {
assertThat(ask(g -> { g.playtime = 899; g.finishedAskedAt = 600; return g; })).isNull();
assertThat(ask(g -> { g.playtime = 900; g.finishedAskedAt = 600; return g; })).isEqualTo(Question.FINISHED);
}
@Test
void endlessGame_StillGetsDropped() {
assertThat(askIdle(g -> { g.playtime = 700; g.endless = true; return g; })).isEqualTo(Question.DROPPED);
}
@Test
void dropped_AfterNinetyIdleDays() {
assertThat(askIdle(g -> { g.lastPlayedAt = NOW.minus(89, ChronoUnit.DAYS); return g; })).isNull();
assertThat(askIdle(g -> { g.lastPlayedAt = NOW.minus(91, ChronoUnit.DAYS); return g; })).isEqualTo(Question.DROPPED);
}
@Test
void dropped_PlaytimeInTheLastTwoWeeksBlocks() {
assertThat(ask(g -> g)).isNull();
assertThat(askIdle(g -> g)).isEqualTo(Question.DROPPED);
}
@Test
void dropped_EachActivitySourceCounts() {
Instant recent = NOW.minus(10, ChronoUnit.DAYS);
assertThat(askIdle(g -> { g.lastPlayDay = LocalDate.of(2026, 10, 1); return g; })).isNull();
assertThat(askIdle(g -> { g.lastPlayedAt = recent; return g; })).isNull();
assertThat(askIdle(g -> { g.lastStatusChange = recent; return g; })).isNull();
assertThat(askIdle(g -> { g.firstSeenAt = recent; return g; })).isNull();
assertThat(askIdle(g -> { g.droppedAnsweredAt = recent; return g; })).isNull();
}
@Test
void dropped_AnswerNoRestartsTheNinetyDays() {
assertThat(askIdle(g -> { g.droppedAnsweredAt = NOW.minus(91, ChronoUnit.DAYS); return g; })).isEqualTo(Question.DROPPED);
assertThat(askIdle(g -> { g.droppedAnsweredAt = NOW.minus(89, ChronoUnit.DAYS); return g; })).isNull();
}
@Test
void dropped_ResultCarriesTheLastActivity() {
Game game = new Game();
game.playtime2Weeks = 0;
game.lastPlayedAt = NOW.minus(120, ChronoUnit.DAYS);
QuestionFacts facts = game.facts();
assertThat(OpenQuestion.of(facts, NOW, ZONE).orElseThrow().lastActivity()).isEqualTo(facts.lastPlayedAt());
}
@Test
void dropped_OnlyForPlayingGames() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 0; return g; })).isNull();
}
@ParameterizedTest
@EnumSource(value = GameStatus.class, names = {"FINISHED", "COMPLETED"})
void rate_UnratedFinishedGame(GameStatus status) {
assertThat(ask(g -> { g.status = status; g.rating = null; return g; })).isEqualTo(Question.RATE);
}
@Test
void rate_NotWhenRatedOrSkipped() {
assertThat(ask(g -> { g.status = GameStatus.FINISHED; return g; })).isNull();
assertThat(ask(g -> { g.status = GameStatus.FINISHED; g.rating = null; g.rateSkipped = true; return g; })).isNull();
}
@Test
void priority_StartBeforeFinishedBeforeDroppedBeforeRate() {
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 700; return g; })).isEqualTo(Question.START);
assertThat(ask(g -> { g.playtime = 700; return g; })).isEqualTo(Question.FINISHED);
assertThat(ask(g -> { g.status = GameStatus.FINISHED; g.rating = null; g.playtime = 700; return g; }))
.isEqualTo(Question.RATE);
}
@Test
void manualGameWithoutPlaytime_GetsOnlyDroppedOrRate() {
assertThat(askIdle(g -> { g.playtime = 0; return g; })).isEqualTo(Question.DROPPED);
assertThat(ask(g -> { g.status = GameStatus.FINISHED; g.rating = null; g.playtime = 0; return g; }))
.isEqualTo(Question.RATE);
assertThat(ask(g -> { g.status = GameStatus.BACKLOG; g.playtime = 0; return g; })).isNull();
}
}
@@ -0,0 +1,149 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Question;
import core_service.modules.library.domain.QuestionFacts;
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.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(QuestionRepository.class)
class QuestionRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long OTHER_USER = 2L;
@Autowired
private QuestionRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
for (String table : List.of("user_game_question", "user_game_playtime_day", "game_link", "game_mood",
"game_time_to_beat", "user_game")) {
jdbc.update("delete from " + table);
}
for (long appid : List.of(10L, 20L, 30L, 40L, 50L)) {
jdbc.update("insert into steam_game (appid, name, image_url) values (?, ?, ?) on conflict do nothing",
appid, "Game " + appid, "img" + appid);
}
game(USER, 10, "PLAYING", 120);
}
private void game(long userId, long appid, String status, int minutes) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
userId, appid, status, minutes);
}
private QuestionFacts only(long appid) {
List<QuestionFacts> facts = repository.facts(USER, appid);
assertThat(facts).hasSize(1);
return facts.get(0);
}
@Test
void facts_ReadTheGameItsPlaytimeDayAndTheStatusChange() {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, '2026-05-01', 10, 30)", USER);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, '2026-06-02', 10, 30)", USER);
QuestionFacts facts = only(10);
assertThat(facts.name()).isEqualTo("Game 10");
assertThat(facts.imageUrl()).isEqualTo("img10");
assertThat(facts.status()).isEqualTo(GameStatus.PLAYING);
assertThat(facts.playtimeMinutes()).isEqualTo(120);
assertThat(facts.lastPlayDay()).isEqualTo(LocalDate.of(2026, 6, 2));
assertThat(facts.lastStatusChange()).isNotNull();
assertThat(facts.firstSeenAt()).isNotNull();
assertThat(facts.normallyMinutes()).isNull();
assertThat(facts.endless()).isNull();
assertThat(facts.rateSkipped()).isFalse();
}
@Test
void facts_ReadTimeToBeatAndEndless() {
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (10, 900, now())");
jdbc.update("insert into game_mood (appid, moods, checked_at, endless) values (10, '{}', now(), true)");
QuestionFacts facts = only(10);
assertThat(facts.normallyMinutes()).isEqualTo(900);
assertThat(facts.endless()).isTrue();
}
@Test
void facts_ReadTheSnoozes() {
repository.snooze(USER, 10, Question.START, 100);
repository.snooze(USER, 10, Question.FINISHED, 700);
repository.snooze(USER, 10, Question.DROPPED, 120);
repository.snooze(USER, 10, Question.RATE, 120);
QuestionFacts facts = only(10);
assertThat(facts.startAskedAt()).isEqualTo(100);
assertThat(facts.finishedAskedAt()).isEqualTo(700);
assertThat(facts.droppedAnsweredAt()).isNotNull();
assertThat(facts.rateSkipped()).isTrue();
}
@Test
void facts_OnlyTheCountedGamesOfTheUser() {
game(USER, 20, "BACKLOG", 0);
game(USER, 30, "BACKLOG", 0);
game(OTHER_USER, 40, "BACKLOG", 0);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 30", USER);
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactly(10L);
}
@Test
void facts_LinkedSetGivesOnlyTheCountedEdition() {
game(USER, 20, "BACKLOG", 0);
jdbc.update("insert into game_link (appid, set_id) values (10, 1), (20, 1)");
assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactly(10L);
}
@Test
void facts_AppidFilter() {
game(USER, 20, "BACKLOG", 0);
assertThat(repository.facts(USER, 20L)).extracting(QuestionFacts::appid).containsExactly(20L);
assertThat(repository.facts(USER, null)).hasSize(2);
assertThat(repository.facts(USER, 99L)).isEmpty();
}
@Test
void snooze_OverwritesPlaytimeAndTime() {
repository.snooze(USER, 10, Question.START, 100);
jdbc.update("update user_game_question set answered_at = now() - interval '1 day'");
repository.snooze(USER, 10, Question.START, 400);
assertThat(jdbc.queryForList("select playtime_minutes from user_game_question", Integer.class)).containsExactly(400);
assertThat(jdbc.queryForObject("select answered_at > now() - interval '1 hour' from user_game_question", Boolean.class))
.isTrue();
}
@Test
void snooze_GoesWithTheGameRow() {
repository.snooze(USER, 10, Question.START, 100);
jdbc.update("update user_game set appid = 50 where appid = 10");
assertThat(jdbc.queryForList("select appid from user_game_question", Long.class)).containsExactly(50L);
jdbc.update("delete from user_game where appid = 50");
assertThat(jdbc.queryForObject("select count(*) from user_game_question", Integer.class)).isZero();
}
}