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

@@ -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)));
}
}