feat(picker): add FRIENDS row from followed users

Backlog games a followed user finished or played >= 5 h. One game per
friend; strongest signal first (finished, then playtime). Requires an
active follow of a public profile that shows SUMMARY or STATUS. Skips
DROPPED games, blocked and deleted users, hidden and removed games.
This commit is contained in:
lobo committed 2026-10-07 09:42:10 +02:00
1 parent 117ff61730
commit e87f40bba0
8 files changed
+481 -18

No files matched your search

@@ -7,35 +7,43 @@ import java.time.Instant;
/**
* Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST,
* {@code seriesName} only for SERIES, {@code studioName} only for STUDIO; the others are null.
* {@code seriesName} only for SERIES, {@code studioName} only for STUDIO, {@code friendName}, {@code friendFinished} and
* {@code friendPlaytimeMinutes} only for FRIENDS; the others are null.
*
* @param anchorName null when the anchor game has no name
*/
public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes,
Instant firstSeenAt, String seriesName, String studioName) {
Instant firstSeenAt, String seriesName, String studioName,
String friendName, Boolean friendFinished, Integer friendPlaytimeMinutes) {
public static PickReasonDto similar(UserGame anchor, String anchorName) {
return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null,
null, null);
null, null, null, null, null);
}
public static PickReasonDto longest(Instant firstSeenAt) {
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null);
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null, null, null, null);
}
public static PickReasonDto random() {
return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null);
return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null, null, null, null);
}
public static PickReasonDto nextUp() {
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null);
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null, null, null, null);
}
public static PickReasonDto series(String seriesName) {
return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null);
return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null, null, null, null);
}
public static PickReasonDto studio(String studioName) {
return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName);
return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName, null, null, null);
}
/** @param playtimeMinutes null when the friend's profile hides it */
public static PickReasonDto friends(String friendName, boolean finished, Integer playtimeMinutes) {
return new PickReasonDto(PickSource.FRIENDS, null, null, null, null, null, null, friendName, finished,
playtimeMinutes);
}
}
@@ -0,0 +1,9 @@
package core_service.modules.library.domain;
/**
* A backlog game of the caller that a followed user finished or played a lot, with that friend's visible data.
*
* @param friendUserId never sent to the client; one slot per friend
* @param playtimeMinutes null when the friend's profile hides SUMMARY
*/
public record FriendPlay(long appid, long friendUserId, String friendName, boolean finished, Integer playtimeMinutes) {}
@@ -2,14 +2,15 @@ package core_service.modules.library.domain;
/**
* Where a picker game came from, and the picker row it is shown in. The order of the constants is the order of the
* rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played. RANDOM is the mood
* row.
* rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played, FRIENDS a
* backlog game a followed user finished or played a lot. RANDOM is the mood row.
*/
public enum PickSource {
NEXT_UP,
SERIES,
SIMILAR,
STUDIO,
FRIENDS,
LONGEST,
RANDOM
}
@@ -0,0 +1,74 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.FriendPlay;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Types;
import java.util.Collection;
import java.util.List;
/**
* What the users the caller follows played of the caller's backlog. Plain SQL: it reads the follow module's tables
* so the privacy rules and the data come from one statement.
*/
@Repository
@RequiredArgsConstructor
public class FriendPlayRepository {
/** Playtime from which a friend "played" a game: 5 h. */
private static final int PLAYED_MINUTES = 300;
// A finish and playtime are shown with SUMMARY or STATUS (the profile's full lists); FINISHED alone lists only
// 12 games. No sections = all shown. Hidden values are nulled before the order, so they do not leak through it.
private static final String SQL = """
with played as (
select ug.appid, f.followed_user_id, a.display_name,
(fg.status in ('FINISHED', 'COMPLETED')
and (p.sections is null or p.sections && array['SUMMARY', 'STATUS']::varchar[])) as finished,
case when p.sections is null or p.sections && array['SUMMARY', 'STATUS']::varchar[]
then fg.playtime_minutes end as playtime_minutes
from follow f
join public_user p on p.user_id = f.followed_user_id
join account_name a on a.user_id = f.followed_user_id
join counted_user_game fg on fg.user_id = f.followed_user_id
join counted_user_game ug on ug.user_id = f.user_id and ug.appid = fg.appid
where f.user_id = :userId
and fg.status <> 'DROPPED'
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
and not exists (select 1 from deleted_user d where d.user_id = f.followed_user_id)
and %s
),
best as (
select distinct on (appid) * from played
where finished or playtime_minutes >= :minPlayed
order by appid, finished desc, playtime_minutes desc nulls last, followed_user_id
)
select appid, followed_user_id, display_name, finished, playtime_minutes from best
order by finished desc, playtime_minutes desc nulls last, random()
""".formatted(UserGameRepository.PICKER_FILTERS);
private final NamedParameterJdbcTemplate jdbc;
/**
* Per backlog game of the caller that passes the picker filters, the followed user with the strongest signal
* (a finish first, then the most playtime), strongest game first. Only active follows of public users who are
* not blocked or deleted; a finish and playtime count only when the profile shows SUMMARY or STATUS. Hidden, removed and dropped games of the friend are not counted.
*
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
* @param excludeAppids never picked; may be empty
*/
public List<FriendPlay> findFriendPlays(long userId, Integer maxMinutes, Collection<Long> excludeAppids) {
MapSqlParameterSource params = new MapSqlParameterSource("userId", userId)
.addValue("excludeAppids", UserGameRepository.excludedOrNone(excludeAppids))
.addValue("maxMinutes", maxMinutes, Types.INTEGER)
.addValue("mood", null, Types.VARCHAR)
.addValue("speedrun", false)
.addValue("minPlayed", PLAYED_MINUTES);
return jdbc.query(SQL, params, (rs, row) -> new FriendPlay(rs.getLong("appid"),
rs.getLong("followed_user_id"), rs.getString("display_name"), rs.getBoolean("finished"),
rs.getObject("playtime_minutes", Integer.class)));
}
}
@@ -303,7 +303,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"order by ug.playtime_minutes desc, ug.appid limit :limit", nativeQuery = true)
List<UserGame> findMostPlayedGames(@Param("userId") Long userId, @Param("limit") int limit);
private static List<Long> excludedOrNone(Collection<Long> excludeAppids) {
static List<Long> excludedOrNone(Collection<Long> excludeAppids) {
List<Long> excluded = new ArrayList<>(excludeAppids);
// "not in ()" is invalid SQL
excluded.add(NO_GAME);
@@ -15,6 +15,7 @@ import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.FriendPlay;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
@@ -26,8 +27,10 @@ import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.ComingGameRepository;
import core_service.modules.library.repo.FriendPlayRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository;
@@ -96,6 +99,7 @@ public class LibraryQueryService {
private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository;
private final GameCreditRepository gameCreditRepository;
private final FriendPlayRepository friendPlayRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@@ -115,9 +119,9 @@ public class LibraryQueryService {
}
/**
* One row per {@link PickSource} (next up, next in series, because you played, same studio, longest waiting,
* mood), in the order of its constants, each with up to three backlog games. Budget and exclude apply to every
* row; the mood only to the RANDOM row. Rows without a game are left out.
* One row per {@link PickSource} (next up, next in series, because you played, same studio, friends played,
* longest waiting, mood), in the order of its constants, each with up to three backlog games. Budget and exclude
* apply to every row; the mood only to the RANDOM row. Rows without a game are left out.
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*
* @param only the one row to build (a reroll); null builds all rows
@@ -139,6 +143,7 @@ public class LibraryQueryService {
case SERIES -> nextInSeries(userId, maxMinutes, excludeAppids);
case SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids);
case STUDIO -> sameStudio(userId, maxMinutes, excludeAppids);
case FRIENDS -> friendsPlayed(userId, maxMinutes, excludeAppids);
case LONGEST -> withReason(userGameRepository.findLongestWaitingGames(userId, maxMinutes, null,
excludeAppids, ROW_SIZE), g -> PickReasonDto.longest(g.getFirstSeenAt()));
case RANDOM -> withReason(userGameRepository.findPickerGames(userId, maxMinutes, mood,
@@ -179,6 +184,24 @@ public class LibraryQueryService {
anchor -> PickReasonDto.similar(anchor, names.get(anchor.getAppid())));
}
/**
* The strongest friend signals first, one game per friend. A game a stronger friend took is not shown for another
* friend.
*/
private List<Map.Entry<UserGame, PickReasonDto>> friendsPlayed(Long userId, Integer maxMinutes,
List<Long> excludeAppids) {
Set<Long> friends = new HashSet<>();
List<FriendPlay> picks = friendPlayRepository.findFriendPlays(userId, maxMinutes, excludeAppids).stream()
.filter(p -> friends.add(p.friendUserId())).limit(ROW_SIZE).toList();
Map<Long, UserGame> games = userGameRepository.findAllById(
picks.stream().map(p -> new UserGameId(userId, p.appid())).toList()).stream()
.collect(Collectors.toMap(UserGame::getAppid, g -> g));
return picks.stream()
.map(p -> Map.entry(games.get(p.appid()),
PickReasonDto.friends(p.friendName(), p.finished(), p.playtimeMinutes())))
.toList();
}
/**
* For each series the user played (most played first), the first backlog part no earlier series took: the parts
* released after the latest played part come first.
@@ -0,0 +1,344 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractMockedLibraryServicesTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.time.LocalDate;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The FRIENDS picker row. The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTest {
private static final long USER_ID = 1L;
private static final long MAX = 950_001L;
private static final long ANNA = 950_002L;
private static final long TOM = 950_003L;
private static final long[] FRIENDS = {MAX, ANNA, TOM, 950_004L, 950_005L, 950_006L, 950_007L};
private static final String ROW = "$.categories[?(@.source == 'FRIENDS')]";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private UserGameAchievementMetaRepository achievementMetaRepository;
@BeforeEach
void clean() {
for (long id : FRIENDS) {
jdbc.update("delete from follow where user_id = ? or followed_user_id = ?", id, id);
jdbc.update("delete from public_user where user_id = ?", id);
jdbc.update("delete from blocked_user where user_id = ?", id);
jdbc.update("delete from deleted_user where user_id = ?", id);
jdbc.update("delete from account_name where user_id = ?", id);
}
jdbc.update("delete from follow where user_id = ?", USER_ID);
gameReleaseRepository.deleteAll();
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
}
private void mine(long appid, GameStatus status, Integer normallyMinutes) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
save(USER_ID, appid, status, 0);
if (normallyMinutes != null) {
gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now()));
}
}
private void friendGame(long userId, long appid, GameStatus status, int playtime) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
save(userId, appid, status, playtime);
}
private void save(long userId, long appid, GameStatus status, int playtime) {
UserGame game = new UserGame(userId, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
userGameRepository.save(game);
}
/** A public friend the caller follows; null sections show everything. */
private void friend(long userId, String name, String[] sections) {
jdbc.update("insert into account_name (user_id, display_name) values (?, ?)", userId, name);
jdbc.update("insert into public_user (user_id, sections) values (?, ?)", userId, sections);
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", USER_ID, userId);
}
private ResultActions pick(String... params) throws Exception {
var request = get("/api/library/picker").param("category", "FRIENDS");
for (int i = 0; i < params.length; i += 2) {
request.param(params[i], params[i + 1]);
}
return mockMvc.perform(request).andExpect(status().isOk());
}
private void expectNoRow() throws Exception {
pick().andExpect(jsonPath("$.categories", hasSize(0)));
}
private static String reason(long appid, String field) {
return ROW + ".games[?(@.appid == " + appid + ")].reason." + field;
}
@Test
void friends_FinishedAndPlayedGames_WithReason() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
mine(20L, GameStatus.BACKLOG, null);
mine(30L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friend(ANNA, "Anna", null);
friend(TOM, "Tom", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
friendGame(ANNA, 20L, GameStatus.PLAYING, 2400);
friendGame(TOM, 30L, GameStatus.PLAYING, 60);
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10, 20)))
.andExpect(jsonPath(reason(10, "source"), contains("FRIENDS")))
.andExpect(jsonPath(reason(10, "friendName"), contains("Max")))
.andExpect(jsonPath(reason(10, "friendFinished"), contains(true)))
.andExpect(jsonPath(reason(20, "friendName"), contains("Anna")))
.andExpect(jsonPath(reason(20, "friendFinished"), contains(false)))
.andExpect(jsonPath(reason(20, "friendPlaytimeMinutes"), contains(2400)));
}
@Test
void friends_UsesTheCurrentDisplayName() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
jdbc.update("update account_name set display_name = 'Maximilian' where user_id = ?", MAX);
pick().andExpect(jsonPath(reason(10, "friendName"), contains("Maximilian")));
}
@Test
void friends_NeverSendsAUserId() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
String body = pick().andReturn().getResponse().getContentAsString();
org.assertj.core.api.Assertions.assertThat(body).doesNotContain(String.valueOf(MAX)).doesNotContain("friendUserId");
}
@Test
void friends_OneGamePerFriend_StrongestFriendPerGame() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
mine(20L, GameStatus.BACKLOG, null);
mine(30L, GameStatus.BACKLOG, null);
mine(40L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friend(ANNA, "Anna", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 900);
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
friendGame(MAX, 30L, GameStatus.FINISHED, 50);
friendGame(ANNA, 10L, GameStatus.PLAYING, 600);
friendGame(ANNA, 40L, GameStatus.PLAYING, 400);
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10, 40)))
.andExpect(jsonPath(ROW + ".games[*].reason.friendName", contains("Max", "Anna")));
}
@Test
void friends_ExactlyFiveHours_Counts() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
mine(20L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friend(ANNA, "Anna", null);
friendGame(MAX, 10L, GameStatus.PLAYING, 300);
friendGame(ANNA, 20L, GameStatus.PLAYING, 299);
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)));
}
@Test
void friends_Reroll_ExcludesShownAndEndsWhenNoneLeft() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
mine(20L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 900);
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)));
pick("exclude", "10").andExpect(jsonPath(ROW + ".games[*].appid", contains(20)));
pick("exclude", "10,20").andExpect(jsonPath("$.categories", hasSize(0)));
}
@Test
void friends_RowSitsBetweenStudioAndLongest() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk())
.andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "FRIENDS", "LONGEST", "RANDOM")));
}
@Test
void friends_NoFollows_LeavesOutTheRow() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk())
.andExpect(jsonPath("$.categories[*].source", not(hasItem("FRIENDS"))));
}
private enum Hidden { NOT_FOLLOWED, NOT_PUBLIC, BLOCKED, DELETED, ONLY_FOLLOWS_ME }
@ParameterizedTest
@EnumSource(Hidden.class)
void friends_NeverShows(Hidden case_) throws Exception {
mine(10L, GameStatus.BACKLOG, null);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'Max')", MAX);
if (case_ != Hidden.NOT_PUBLIC) {
jdbc.update("insert into public_user (user_id) values (?)", MAX);
}
switch (case_) {
case NOT_FOLLOWED, NOT_PUBLIC -> { }
case BLOCKED -> jdbc.update("insert into blocked_user (user_id) values (?)", MAX);
case DELETED -> jdbc.update("insert into deleted_user (user_id) values (?)", MAX);
case ONLY_FOLLOWS_ME -> jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", MAX, USER_ID);
}
if (case_ != Hidden.NOT_FOLLOWED && case_ != Hidden.ONLY_FOLLOWS_ME) {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", USER_ID, MAX);
}
friendGame(MAX, 10L, GameStatus.FINISHED, 2400);
expectNoRow();
jdbc.update("delete from follow where user_id = ?", MAX);
}
@Test
void friends_HiddenSections_ShownOnlyWithSummaryOrStatus() throws Exception {
for (long appid = 10; appid <= 60; appid += 10) {
mine(appid, GameStatus.BACKLOG, null);
}
friend(MAX, "Stats", new String[]{"STATS"});
friend(ANNA, "Summary", new String[]{"SUMMARY"});
friend(TOM, "FinishedOnly", new String[]{"FINISHED"});
friend(950_004L, "All", null);
friend(950_005L, "Status", new String[]{"STATUS"});
friend(950_006L, "PlayingFinished", new String[]{"PLAYING", "FINISHED"});
friendGame(MAX, 10L, GameStatus.FINISHED, 2400);
friendGame(ANNA, 20L, GameStatus.FINISHED, 2400);
friendGame(TOM, 30L, GameStatus.FINISHED, 2400);
friendGame(950_004L, 40L, GameStatus.FINISHED, 2400);
friendGame(950_005L, 50L, GameStatus.FINISHED, 2400);
friendGame(950_006L, 60L, GameStatus.FINISHED, 2400);
pick().andExpect(jsonPath(ROW + ".games[*].appid", containsInAnyOrder(20, 40, 50)));
pick("exclude", "20,40,50").andExpect(jsonPath("$.categories", hasSize(0)));
for (long appid : new long[]{20, 40, 50}) {
pick().andExpect(jsonPath(reason(appid, "friendFinished"), contains(true)))
.andExpect(jsonPath(reason(appid, "friendPlaytimeMinutes"), contains(2400)));
}
}
@Test
void friends_FinishedOnlyProfile_NeverShowsAFinishTheProfileDoesNotList() throws Exception {
friend(MAX, "Max", new String[]{"FINISHED"});
for (long appid = 101; appid <= 113; appid++) {
friendGame(MAX, appid, GameStatus.FINISHED, 2400);
}
mine(113L, GameStatus.BACKLOG, null);
expectNoRow();
}
@Test
void friends_DroppedGame_NeverCounts() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.DROPPED, 6000);
expectNoRow();
}
@Test
void friends_HiddenOrRemovedGameOfFriend_NeverCounts() throws Exception {
mine(10L, GameStatus.BACKLOG, null);
mine(20L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 10", MAX);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 20", MAX);
expectNoRow();
}
@Test
void friends_OnlyMyBacklog() throws Exception {
mine(10L, GameStatus.PLAYING, null);
mine(20L, GameStatus.BACKLOG, null);
mine(30L, GameStatus.BACKLOG, null);
friend(MAX, "Max", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
friendGame(MAX, 40L, GameStatus.FINISHED, 100);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER_ID);
expectNoRow();
}
@Test
void friends_BudgetAndReleasedOnly() throws Exception {
mine(10L, GameStatus.BACKLOG, 3000);
mine(20L, GameStatus.BACKLOG, 60);
mine(30L, GameStatus.BACKLOG, 60);
gameReleaseRepository.upsert(20L, LocalDate.now().plusDays(1), false, Instant.now());
friend(MAX, "Max", null);
friend(ANNA, "Anna", null);
friend(TOM, "Tom", null);
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
// No playtime: a game anybody played is never "coming" (coming_game view).
friendGame(ANNA, 20L, GameStatus.FINISHED, 0);
friendGame(TOM, 30L, GameStatus.FINISHED, 100);
pick("maxMinutes", "300").andExpect(jsonPath(ROW + ".games[*].appid", contains(30)))
.andExpect(jsonPath(ROW + ".games", hasSize(1)));
}
}