Public Access
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:
1 parent
0ddb345a41
commit
6cd92f3e83
18 files changed
+934
-2
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user