Compare commits

...
2 Commits
Author SHA1 Message Date
lobo e11fae8e31 feat(follow): add player search with rate limit (#57)
Build, Test & Push Core Service / docker (push) Successful in 1m34s
Build, Test & Push Core Service / quality-check (push) Successful in 2m19s
Find public profiles by name: GET /api/follows/players?q=.
Queries under 2 characters find nothing; more than 30 searches
per minute get 429. LIKE escaping is shared with the library search.
2026-10-21 14:54:48 +02:00
lobo c42526bc16 feat(library): sort by name, last played and time to beat (#56)
Sort by name ignores upper and lower case.
Games never played, or without a time to beat, come last.
2026-10-21 11:09:44 +02:00
16 changed files with 347 additions and 14 deletions

No files matched your search

+6 -4
View File
@@ -59,9 +59,10 @@ To Be Played is split into four repositories:
Filters --> Out["games + reason"] Filters --> Out["games + reason"]
``` ```
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its 500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves
out what the followed user's profile hides (`public_user.sections`).
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
@@ -253,7 +254,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT | | `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT | | `GET` | `/api/library/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT |
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT | | `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
@@ -308,6 +309,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login | | `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login |
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login | | `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login | | `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
| `GET` | `/api/follows/players?q=` (`{results: [{displayName}]}`: public profiles whose name contains `q`, case-insensitive, not blocked or deleted, at most 20, names that start with `q` first, the caller's own name included; `q` under 2 characters answers `[]`; `429 TOO_MANY_SEARCHES` beyond 30 searches per user and minute, `app.follow.search-per-minute`) | login |
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | | `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login | | `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login | | `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
@@ -1,14 +1,17 @@
package core_service.modules.follow.api; package core_service.modules.follow.api;
import core_service.modules.follow.api.dto.FollowsDto; import core_service.modules.follow.api.dto.FollowsDto;
import core_service.modules.follow.api.dto.PlayersDto;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.service.FollowService; import core_service.modules.follow.service.FollowService;
import core_service.shared.api.ErrorResponse; import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -33,6 +37,20 @@ public class FollowController {
return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers()))); return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers())));
} }
/**
* The caller's own name is a normal hit.
*/
@GetMapping("/players")
public ResponseEntity<?> players(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam("q") @Size(max = 100) String q) {
PlayerSearch search = followService.searchPlayers(principal.getIdAsLong(), q);
if (search.limited()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new ErrorResponse("TOO_MANY_SEARCHES", "Too many searches"));
}
return ResponseEntity.ok(new PlayersDto(search.names().stream().map(PlayersDto.Player::new).toList()));
}
@PutMapping("/by-name/{displayName}") @PutMapping("/by-name/{displayName}")
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("displayName") @Size(max = 100) String displayName) { @PathVariable("displayName") @Size(max = 100) String displayName) {
@@ -0,0 +1,15 @@
package core_service.modules.follow.api.dto;
import java.util.List;
/**
* Public profiles found by name: names that start with the query first, then by name.
*/
public record PlayersDto(List<Player> results) {
/**
* @param displayName the key of the profile URL
*/
public record Player(String displayName) {
}
}
@@ -0,0 +1,17 @@
package core_service.modules.follow.domain;
import java.util.List;
/**
* The answer of a player search: the names found, or "limited" when the caller searched too often.
*/
public record PlayerSearch(List<String> names, boolean limited) {
public static PlayerSearch tooMany() {
return new PlayerSearch(List.of(), true);
}
public static PlayerSearch of(List<String> names) {
return new PlayerSearch(names, false);
}
}
@@ -1,6 +1,7 @@
package core_service.modules.follow.repo; package core_service.modules.follow.repo;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.shared.sql.LikePattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@@ -20,6 +21,7 @@ import java.util.Optional;
public class FollowRepository { public class FollowRepository {
static final int LIST_LIMIT = 500; static final int LIST_LIMIT = 500;
public static final int SEARCH_LIMIT = 20;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> { private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
Timestamp lastActivityAt = rs.getTimestamp("last_activity_at"); Timestamp lastActivityAt = rs.getTimestamp("last_activity_at");
@@ -45,6 +47,23 @@ public class FollowRepository {
return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty(); return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty();
} }
/**
* Public names that contain the query, case-insensitive; names that start with it come first. Same visibility
* rules as {@link #findFollowable}. Wildcards in the query match themselves.
*/
public List<String> searchPublicNames(String query, int limit) {
String escaped = LikePattern.escapeLower(query);
return jdbc.queryForList("""
select a.display_name from account_name a
join public_user p on p.user_id = a.user_id
where lower(a.display_name) like :pattern escape '\\'
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
order by (lower(a.display_name) like :prefix escape '\\') desc, lower(a.display_name)
limit :limit""", new MapSqlParameterSource("pattern", "%" + escaped + "%")
.addValue("prefix", escaped + "%").addValue("limit", limit), String.class);
}
/** /**
* Checks the visibility again in the same statement, so a user who went private in between gets no follower. * Checks the visibility again in the same statement, so a user who went private in between gets no follower.
* *
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.repo.FollowRepository; import core_service.modules.follow.repo.FollowRepository;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -17,8 +19,10 @@ import java.util.Optional;
public class FollowService { public class FollowService {
static final int MAX_FOLLOWING = 500; static final int MAX_FOLLOWING = 500;
static final int MIN_QUERY_LENGTH = 2;
private final FollowRepository followRepository; private final FollowRepository followRepository;
private final PlayerSearchLimiter limiter;
/** /**
* Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed. * Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed.
@@ -57,4 +61,20 @@ public class FollowService {
public Follows follows(long userId) { public Follows follows(long userId) {
return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId)); return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId));
} }
/**
* A query shorter than two characters finds nothing and does not count against the limit.
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public PlayerSearch searchPlayers(long userId, String query) {
String stripped = query.strip();
if (stripped.length() < MIN_QUERY_LENGTH) {
return PlayerSearch.of(List.of());
}
if (!limiter.allow(userId)) {
return PlayerSearch.tooMany();
}
return PlayerSearch.of(followRepository.searchPublicNames(stripped, FollowRepository.SEARCH_LIMIT));
}
} }
@@ -0,0 +1,32 @@
package core_service.modules.follow.service;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
/**
* Searches per user and minute. One limiter per user who searched since boot stays in memory: small, and bounded by
* the number of users. The limit holds per instance.
*/
@Component
public class PlayerSearchLimiter {
private final RateLimiterConfig config;
private final ConcurrentHashMap<Long, RateLimiter> limiters = new ConcurrentHashMap<>();
public PlayerSearchLimiter(@Value("${app.follow.search-per-minute:30}") int perMinute) {
this.config = RateLimiterConfig.custom()
.limitForPeriod(perMinute)
.limitRefreshPeriod(Duration.ofMinutes(1))
.timeoutDuration(Duration.ZERO)
.build();
}
public boolean allow(long userId) {
return limiters.computeIfAbsent(userId, id -> RateLimiter.of("player-search-" + id, config)).acquirePermission();
}
}
@@ -103,7 +103,8 @@ public class LibraryController {
private static final int MAX_PICKER_EXCLUDES = 100; private static final int MAX_PICKER_EXCLUDES = 100;
private static final int MAX_BACKLOG_GAMES = 20; private static final int MAX_BACKLOG_GAMES = 20;
private static final int MAX_DASHBOARD_GAMES = 20; private static final int MAX_DASHBOARD_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority",
"name", "lastPlayedAt", "timeToBeat");
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
private final LibraryQueryService queryService; private final LibraryQueryService queryService;
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.shared.sql.LikePattern;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -28,7 +29,6 @@ import java.util.EnumMap;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> { public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
@@ -366,7 +366,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path: // ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
// join it only for the playtime sort or the PLAYING status, with a second query. // join it only for the playtime sort or the PLAYING status, with a second query.
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + @Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE, "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
"left join GameTimeToBeat tb on tb.appid = ug.appid " + OWNED_WHERE,
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE) countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
Page<UserGame> findOwnedEntries(@Param("userId") Long userId, Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
@Param("hidden") boolean hidden, @Param("hidden") boolean hidden,
@@ -418,7 +419,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
/** /**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. * appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. A game never
* played or without a known time to beat comes last in both directions; the name sorts case-insensitive.
* The time to beat needs {@code left join GameTimeToBeat tb on tb.appid = ug.appid}.
*/ */
static Sort querySort(Sort requested) { static Sort querySort(Sort requested) {
Sort result = Sort.unsorted(); Sort result = Sort.unsorted();
@@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
.iterator().next().nullsLast()); .iterator().next().nullsLast());
case "name" -> Sort.by(JpaSort.unsafe(order.getDirection(), "lower(sg.name)").iterator().next());
case "lastPlayedAt" -> Sort.by(order.nullsLast());
case "timeToBeat" -> Sort.by(JpaSort.unsafe(order.getDirection(), "tb.normallyMinutes")
.iterator().next().nullsLast());
default -> Sort.by(order); default -> Sort.by(order);
}); });
} }
@@ -441,9 +448,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
static String containsPattern(String query) { static String containsPattern(String query) {
if (query == null || query.isBlank()) return null; if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT) return "%" + LikePattern.escapeLower(query.trim()) + "%";
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
} }
/** /**
@@ -0,0 +1,16 @@
package core_service.shared.sql;
import java.util.Locale;
public final class LikePattern {
private LikePattern() {}
/**
* Lower-cases the text and escapes {@code \}, {@code %} and {@code _}, so a {@code like ... escape '\'} matches
* them literally. The caller adds the wildcards.
*/
public static String escapeLower(String text) {
return text.toLowerCase(Locale.ROOT).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
+2
View File
@@ -106,6 +106,8 @@ app:
# The hand changes of a followed user are one batch after this quiet time. # The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000 digest-ms: 600000
quiet-minutes: 10 quiet-minutes: 10
# Player searches per user and minute; more answers 429.
search-per-minute: 30
inbox: inbox:
purge-cron: "0 15 3 * * *" purge-cron: "0 15 3 * * *"
keep-days: 90 keep-days: 90
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -83,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest {
} }
private JsonNode list(long userId) throws Exception { private JsonNode list(long userId) throws Exception {
String body = mockMvc.perform(as(get("/api/follows"), userId)).andExpect(status().isOk()) return okJson(get("/api/follows"), userId);
}
private JsonNode players(long userId, String query) throws Exception {
return okJson(get("/api/follows/players").param("q", query), userId);
}
private JsonNode okJson(MockHttpServletRequestBuilder request, long userId) throws Exception {
String body = mockMvc.perform(as(request, userId)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
return JsonMapper.builder().build().readTree(body); return JsonMapper.builder().build().readTree(body);
} }
@@ -239,4 +248,61 @@ class FollowControllerTest extends AbstractIntegrationTest {
assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue(); assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue();
} }
@Test
void players_FindsPublicNamesCaseInsensitiveAndNotPrivateBlockedDeleted() throws Exception {
JsonNode json = players(VIEWER, "ER");
List<String> names = new ArrayList<>();
json.get("results").forEach(player -> names.add(player.get("displayName").asString()));
assertThat(names).containsExactlyInAnyOrder("Viewer", "OtherSix");
assertThat(json.get("results").get(0).propertyNames()).containsExactly("displayName");
assertThat(players(VIEWER, "re").get("results")).isEmpty();
assertThat(players(VIEWER, "ed").get("results")).isEmpty();
}
@Test
void players_PrefixHitsComeFirst() throws Exception {
name(OTHER + 2, "Zolo");
jdbc.update("insert into public_user (user_id) values (?)", OTHER + 2);
JsonNode results = players(VIEWER, "lo").get("results");
assertThat(results.get(0).get("displayName").asString()).isEqualTo("Lobo");
assertThat(results.get(1).get("displayName").asString()).isEqualTo("Zolo");
}
@Test
void players_ShortQueryIsEmpty() throws Exception {
assertThat(players(VIEWER, "x").get("results")).isEmpty();
}
@Test
void players_EscapesLikeWildcards() throws Exception {
assertThat(players(VIEWER, "%").get("results")).isEmpty();
assertThat(players(VIEWER, "_o").get("results")).isEmpty();
}
@Test
void players_QueryLongerThan100_Is400() throws Exception {
mockMvc.perform(as(get("/api/follows/players").param("q", "x".repeat(101)), VIEWER))
.andExpect(status().isBadRequest());
}
@Test
void players_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/follows/players").param("q", "lo")).andExpect(status().isUnauthorized());
}
@Test
void players_31stSearchInAMinute_Is429() throws Exception {
long searcher = 950_001L;
for (int i = 0; i < 30; i++) {
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher)).andExpect(status().isOk());
}
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("TOO_MANY_SEARCHES"));
}
} }
@@ -0,0 +1,19 @@
package core_service.modules.follow.service;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PlayerSearchLimiterTest extends AbstractUnitTest {
@Test
void allow_RefusesTheSearchAfterTheLimitAndCountsEachUserOnItsOwn() {
PlayerSearchLimiter limiter = new PlayerSearchLimiter(2);
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isFalse();
assertThat(limiter.allow(2)).isTrue();
}
}
@@ -0,0 +1,69 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractMockedAdaptersTest;
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.test.web.servlet.MockMvc;
import java.time.Instant;
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 dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerSortTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(620L, "portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(700L, "Antichamber", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now()));
}
@Test
void sortByName_ListsTheNamesAZ() throws Exception {
mockMvc.perform(get("/api/library/games?sort=name,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(700))
.andExpect(jsonPath("$.content[1].appid").value(220))
.andExpect(jsonPath("$.content[2].appid").value(620));
}
@Test
void sortByTimeToBeatAndLastPlayed_AreAccepted() throws Exception {
mockMvc.perform(get("/api/library/games?sort=timeToBeat,asc")).andExpect(status().isOk());
mockMvc.perform(get("/api/library/games?sort=lastPlayedAt,desc")).andExpect(status().isOk());
}
@Test
void sortByLastSeenAt_StillWorks() throws Exception {
mockMvc.perform(get("/api/library/games?sort=lastSeenAt,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(3));
}
}
@@ -36,7 +36,8 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid "; private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid ";
private static final String OLD_SELECT = "select ug " + OLD_FROM private static final String OLD_SELECT = "select ug " + OLD_FROM
+ "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "; + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "
+ "left join GameTimeToBeat tb on tb.appid = ug.appid ";
private static final String OLD_COUNT = "select count(ug) " + OLD_FROM; private static final String OLD_COUNT = "select count(ug) " + OLD_FROM;
private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " + private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " + "and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
@@ -55,7 +56,9 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
private static final List<Sort> SORTS = List.of( private static final List<Sort> SORTS = List.of(
Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"), Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"),
Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"), Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"),
Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority")); Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"),
Sort.by(Sort.Direction.ASC, "name"), Sort.by(Sort.Direction.DESC, "lastPlayedAt"),
Sort.by(Sort.Direction.ASC, "timeToBeat"));
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@@ -348,6 +348,35 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority"))); assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority")));
} }
@Test
void querySort_NameAsc_IsCaseInsensitiveAZ() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "portal 2", null, Instant.now()));
assertEquals(List.of(700L, 220L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "name")));
assertEquals(List.of(620L, 380L, 220L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "name")));
}
@Test
void querySort_LastPlayedDesc_PutsNeverPlayedLast() {
Instant now = Instant.now();
userGameRepository.setLastPlayedAt(USER_ID, 380L, now.minusSeconds(3600));
userGameRepository.setLastPlayedAt(USER_ID, 700L, now);
assertEquals(List.of(700L, 380L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "lastPlayedAt")));
assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "lastPlayedAt")));
}
@Test
void querySort_TimeToBeatAsc_PutsUnknownLast() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()),
new GameTimeToBeat(380L, 100, null, 200, Instant.now())));
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.ASC, "timeToBeat")));
assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "timeToBeat")));
}
private void setBacklog(long appid, Integer rating, GamePriority priority) { private void setBacklog(long appid, Integer rating, GamePriority priority) {
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
game.setRating(rating); game.setRating(rating);