diff --git a/readme.md b/readme.md index 00a0f6d..e9cf6cf 100644 --- a/readme.md +++ b/readme.md @@ -86,6 +86,25 @@ To Be Played is split into three repositories: weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint still lets the ending hint through. +- **Question queue** (Gitea #32): `GET /api/library/questions` lists one open question per game: Start it? (BACKLOG with + playtime), Finished? (BACKLOG or PLAYING, playtime at least IGDB's normal time to beat, else 10 h; never for + IGDB-endless games), Dropped? (PLAYING, no Steam playtime in the last 2 weeks and no sign of play for 90 days) and + Rate it? (FINISHED or COMPLETED without a rating). The order is the priority. `OpenQuestion.of` is a pure rule over + `QuestionFacts`, which one SQL query loads for the whole library (`counted_user_game`, so a linked set is one card). + Every rule starts from the status, so a game the sync already finished or started drops out by itself. A "No" is + stored in `user_game_question` (V41): Start it? and Finished? come back after 5 more hours of playtime, Dropped? + after another 90 idle days, a Rate it? "Skip" never. `POST /api/library/questions/{appid}` + (`{question, yes, rating?}`) checks that the question is still open (`409 QUESTION_GONE`, `404 GAME_NOT_FOUND`, + `400 INVALID_ANSWER` for a yes to RATE without a rating) and then calls `BacklogService`, the same write path as the + game page. `GET /api/library/questions/count` uses the same code as the list, so the badge equals the cards. + +```mermaid +flowchart LR + Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"] + Rule --> Card["question card"] + Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"] + Answer -->|no| Snooze["user_game_question"] +``` - **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library, picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library @@ -207,6 +226,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT | | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | +| `GET` | `/api/library/questions`, `/api/library/questions/count` (`{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes, lastActivity}]}`, `{count}`) | JWT | +| `POST` | `/api/library/questions/{appid}` (`{question, yes, rating?}`; `200 {next}`, `400 INVALID_ANSWER`, `404 GAME_NOT_FOUND`, `409 QUESTION_GONE`) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT | | `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT | diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 321da6d..35d6fde 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 TABLES = List.of("user_game_status_change", "user_game_achievement", + static final List 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"); diff --git a/src/main/java/core_service/modules/library/api/QuestionController.java b/src/main/java/core_service/modules/library/api/QuestionController.java new file mode 100644 index 0000000..3d6f8cc --- /dev/null +++ b/src/main/java/core_service/modules/library/api/QuestionController.java @@ -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 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")); + }; + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/AnsweredDto.java b/src/main/java/core_service/modules/library/api/dto/AnsweredDto.java new file mode 100644 index 0000000..aed192a --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AnsweredDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/QuestionAnswer.java b/src/main/java/core_service/modules/library/api/dto/QuestionAnswer.java new file mode 100644 index 0000000..74ab4c0 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/QuestionAnswer.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/api/dto/QuestionDto.java b/src/main/java/core_service/modules/library/api/dto/QuestionDto.java new file mode 100644 index 0000000..bfe75a3 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/QuestionDto.java @@ -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()); + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/QuestionsDto.java b/src/main/java/core_service/modules/library/api/dto/QuestionsDto.java new file mode 100644 index 0000000..71ee516 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/QuestionsDto.java @@ -0,0 +1,5 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +public record QuestionsDto(List results) {} diff --git a/src/main/java/core_service/modules/library/domain/OpenQuestion.java b/src/main/java/core_service/modules/library/domain/OpenQuestion.java new file mode 100644 index 0000000..20caea3 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/OpenQuestion.java @@ -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 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); + } +} diff --git a/src/main/java/core_service/modules/library/domain/Question.java b/src/main/java/core_service/modules/library/domain/Question.java new file mode 100644 index 0000000..6ac07ea --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/Question.java @@ -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 +} diff --git a/src/main/java/core_service/modules/library/domain/QuestionFacts.java b/src/main/java/core_service/modules/library/domain/QuestionFacts.java new file mode 100644 index 0000000..bb1e612 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/QuestionFacts.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/repo/QuestionRepository.java b/src/main/java/core_service/modules/library/repo/QuestionRepository.java new file mode 100644 index 0000000..7074fd0 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/QuestionRepository.java @@ -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 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 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(); + } +} diff --git a/src/main/java/core_service/modules/library/service/BacklogService.java b/src/main/java/core_service/modules/library/service/BacklogService.java index 4cdd638..4b631d2 100644 --- a/src/main/java/core_service/modules/library/service/BacklogService.java +++ b/src/main/java/core_service/modules/library/service/BacklogService.java @@ -65,6 +65,14 @@ public class BacklogService { }); } + /** Sets only the rating, like a {@link #patch} that sends nothing else. */ + @Transactional + public Optional 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. diff --git a/src/main/java/core_service/modules/library/service/QuestionService.java b/src/main/java/core_service/modules/library/service/QuestionService.java new file mode 100644 index 0000000..06c14b4 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/QuestionService.java @@ -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 next) {} + + private static final Comparator 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(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 facts = factsOf(userId, appid); + if (facts.isEmpty()) return new Answered(AnswerResult.NOT_FOUND, Optional.empty()); + Optional 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 factsOf(long userId, long appid) { + return questionRepository.facts(userId, appid).stream().findFirst(); + } + + private Optional toOpen(QuestionFacts facts) { + return OpenQuestion.of(facts, Instant.now(), zone).map(question -> new Open(facts, question)); + } +} diff --git a/src/main/resources/db/migration/V41__Game_Questions.sql b/src/main/resources/db/migration/V41__Game_Questions.sql new file mode 100644 index 0000000..630c9f3 --- /dev/null +++ b/src/main/resources/db/migration/V41__Game_Questions.sql @@ -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 +); diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index be86cc5..a29e07c 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -15,7 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; @Import(AccountCleanupRepository.class) class AccountCleanupRepositoryTest extends AbstractPersistenceTest { - private static final List USER_TABLES = List.of("user_game_status_change", "user_game_achievement", + private static final List 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); diff --git a/src/test/java/core_service/modules/library/api/QuestionFlowTest.java b/src/test/java/core_service/modules/library/api/QuestionFlowTest.java new file mode 100644 index 0000000..6284417 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/QuestionFlowTest.java @@ -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"); + } +} diff --git a/src/test/java/core_service/modules/library/domain/OpenQuestionTest.java b/src/test/java/core_service/modules/library/domain/OpenQuestionTest.java new file mode 100644 index 0000000..f31aebe --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/OpenQuestionTest.java @@ -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 setup) { + return ask(g -> { + g.playtime2Weeks = 0; + return setup.apply(g); + }); + } + + private static Question ask(UnaryOperator 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(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java b/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java new file mode 100644 index 0000000..fe71af0 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java @@ -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 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(); + } +}