Public Access
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:
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.
|
||||
|
||||
+344
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user