feat(library): add drill-down lists for stat numbers
Build, Test & Push Core Service / quality-check (push) Successful in 1m17s
Build, Test & Push Core Service / docker (push) Successful in 1m27s

The number and list share the same SQL query, so they cannot drift.
StatKey enum maps each statistic to its query, validation rules, and visibility.
StatGamesService reads games per key with 40-game pages; repositories share the
same set logic as the stat counts. Three endpoints open games: own library stats,
public profile stats (with ticket checks), and public landing-page stats (cached
10 min). Rewards track and bonus rows support drill-down too.
This commit is contained in:
lobo committed 2026-10-03 18:51:59 +02:00
1 parent 74cc8297ce
commit 065048415c
39 files changed
+1938 -109

No files matched your search

@@ -69,7 +69,9 @@ class PublicEndpointsTest {
}
@ParameterizedTest
@ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/internal/anything"})
@ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/internal/anything",
"/api/library/stats/games", "/api/rewards/progress/GAMES_FINISHED/games",
"/api/rewards/history/MONTH/2026-09/games"})
void endpointIsNotPublic(String path) throws Exception {
int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus();
assertTrue(Set.of(401, 403).contains(status), path + " answered " + status);
@@ -115,6 +117,11 @@ class PublicEndpointsTest {
mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk());
}
@Test
void communityGamesArePublic() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED")).andExpect(status().isOk());
}
@Test
void versionIsPublic() throws Exception {
mockMvc.perform(get("/api/version"))
@@ -17,6 +17,8 @@ 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 org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.Instant;
import java.util.List;
@@ -193,4 +195,106 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.contentType("application/json").content("{\"status\":\"DROPPED\"}"))
.andExpect(status().isNotFound());
}
private ResultActions statGames(String ticket, String key, String arg) throws Exception {
MockHttpServletRequestBuilder request = get("/api/public/profiles/library/stats/games").param("key", key);
if (arg != null) {
request.param("arg", arg);
}
return mockMvc.perform(request.header("X-Profile-Ticket", ticket));
}
@Test
void statGames_SharedGroup_ListsTheGamesOfTheNumber() throws Exception {
statGames(ticket(USER_ID, List.of("TOP_GAMES")), "MOST_PLAYED", null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(3))
.andExpect(jsonPath("$.content[0].appid").value(620))
.andExpect(jsonPath("$.content[0].name").value("Portal 2"))
.andExpect(jsonPath("$.content[0].playtimeMinutes").value(900));
}
@Test
void statGames_ListsNoRemovedGameNoOtherUserAndNoPrivateFields() throws Exception {
statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "TOTAL", null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(3))
.andExpect(jsonPath("$.content[?(@.appid == 800)]").isEmpty())
.andExpect(jsonPath("$.content[1].appid").value(220))
.andExpect(jsonPath("$.content[1].playtimeMinutes").value(300))
.andExpect(jsonPath("$..notes").isEmpty())
.andExpect(jsonPath("$..rating").isEmpty())
.andExpect(jsonPath("$..priority").isEmpty());
}
@Test
void statGames_StatusSection_ListsTheStatusCount() throws Exception {
statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "STATUS", "FINISHED")
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(700));
}
@Test
void statGames_PerfectGames_EqualTheNumberOfTheProfile() throws Exception {
schemaMetaRepository.saveAll(List.of(new GameAchievementSchemaMeta(620L, 10, true, Instant.now()),
new GameAchievementSchemaMeta(220L, 4, true, Instant.now())));
userAchievementMetaRepository.saveAll(List.of(
new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 10, 900),
new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300)));
statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(620));
}
@Test
void statGames_PerfectGamesWithoutAny_IsAnEmptyPage() throws Exception {
statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(0))
.andExpect(jsonPath("$.content", hasSize(0)));
}
@Test
void statGames_GroupNotShared_Is403() throws Exception {
statGames(ticket(USER_ID, List.of("PILE")), "MOST_PLAYED", null)
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("NOT_SHARED"));
}
@Test
void statGames_StatsSectionHiddenButGroupInTheTicket_Is403() throws Exception {
statGames(ticket(USER_ID, List.of("TOP_GAMES"), List.of("SUMMARY")), "MOST_PLAYED", null)
.andExpect(status().isForbidden());
}
@Test
void statGames_OverviewKeys_AreNeverShared() throws Exception {
statGames(ticket(USER_ID, List.of("OVERVIEW")), "PLAYED", null).andExpect(status().isForbidden());
statGames(ticket(USER_ID, List.of("OVERVIEW")), "NEVER_STARTED", null).andExpect(status().isForbidden());
}
@Test
void statGames_HiddenSection_Is403() throws Exception {
statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "STATUS", "FINISHED").andExpect(status().isForbidden());
statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "TOTAL", null).andExpect(status().isForbidden());
statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "PERFECT", null).andExpect(status().isForbidden());
statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "FINISHED_ALL", null).andExpect(status().isForbidden());
}
@Test
void statGames_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library/stats/games").param("key", "TOTAL"))
.andExpect(status().isUnauthorized());
statGames("garbage", "TOTAL", null).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
@Test
void statGames_BadArg_Is400() throws Exception {
statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_ARG"));
}
}
@@ -0,0 +1,298 @@
package core_service.modules.library.api;
import core_service.modules.library.repo.GameMoodsRepository;
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.MethodSource;
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 tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* For every key: the number on the stats page equals what its list holds. The dev profile authenticates every request
* as user 1.
*/
@AutoConfigureMockMvc
class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
private static final long USER = 1L;
private static final long OTHER = 2L;
private static final long FINISHED_TRACKED = 5_000_001L;
private static final long PILE_WITH_TIME = 5_000_002L;
private static final long UNDER_HOUR = 5_000_003L;
private static final long COMPLETED_2011 = 5_000_004L;
private static final long PILE_NO_TIME = 5_000_005L;
private static final long HIDDEN = 5_000_006L;
private static final long REMOVED = 5_000_007L;
private static final long EDITION_MAIN = 5_000_008L;
private static final long EDITION_SECOND = 5_000_009L;
private static final long MANUAL_SWITCH = 5_000_010L;
private static final long MANUAL_SWITCH_LOWER = 5_000_011L;
private static final long BACK_TO_PILE = 5_000_012L;
private static final int YEAR = LocalDate.now(ZoneId.of("Europe/Berlin")).getYear();
private static final JsonMapper JSON = JsonMapper.builder().build();
private record Case(String key, String arg, Function<JsonNode, Long> count, String minutesField,
Function<JsonNode, Long> minutes) {
@Override
public String toString() {
return key + (arg == null ? "" : " " + arg);
}
}
private static Case count(String key, String arg, String path) {
return new Case(key, arg, s -> s.at(path).asLong(), null, null);
}
private static Long tagValue(JsonNode tags, String name, String field) {
for (JsonNode tag : tags) {
if (tag.get("name").asString().equals(name)) {
return tag.get(field).asLong();
}
}
return 0L;
}
private static Long yearGames(JsonNode stats, int year) {
for (JsonNode row : stats.at("/finished/perYear")) {
if (row.get("year").asInt() == year) {
return row.get("games").asLong();
}
}
return 0L;
}
static Stream<Case> cases() {
return Stream.of(
count("TOTAL", null, "/overview/totalGames"),
count("STATUS", "BACKLOG", "/overview/byStatus/BACKLOG"),
count("STATUS", "PLAYING", "/overview/byStatus/PLAYING"),
count("STATUS", "FINISHED", "/overview/byStatus/FINISHED"),
count("STATUS", "COMPLETED", "/overview/byStatus/COMPLETED"),
count("PLAYED", null, "/overview/playedGames"),
count("NEVER_STARTED", null, "/overview/neverStarted"),
count("PILE", null, "/pile/backlogGames"),
new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes",
s -> s.at("/pile/normalMinutes").asLong()),
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "playtimeMinutes",
s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")),
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "playtimeMinutes",
s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")),
new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()),
count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"),
new Case("FINISHED_YEAR", String.valueOf(YEAR), s -> yearGames(s, YEAR), null, null),
new Case("FINISHED_YEAR", "2011", s -> yearGames(s, 2011), null, null),
count("FINISHED_UNTRACKED", null, "/finished/finishedBeforeTracking"),
count("TREND_ADDED", null, "/trend/thisMonth/added"),
count("TREND_DONE", null, "/trend/thisMonth/done"),
new Case("PLATFORM", "Switch", s -> tagValue(s.at("/platforms/perPlatform"), "Switch", "games"),
"playtimeMinutes", s -> tagValue(s.at("/platforms/perPlatform"), "Switch", "minutes")),
new Case("PLATFORM", "Steam", s -> tagValue(s.at("/platforms/perPlatform"), "Steam", "games"),
"playtimeMinutes", s -> tagValue(s.at("/platforms/perPlatform"), "Steam", "minutes")),
new Case("FINISHED_ALL", null,
s -> s.at("/overview/byStatus/FINISHED").asLong() + s.at("/overview/byStatus/COMPLETED").asLong(),
null, null));
}
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@BeforeEach
void seed() {
jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER);
jdbc.update("delete from user_game_playtime_day where user_id in (?, ?)", USER, OTHER);
jdbc.update("delete from game_link where appid in (?, ?)", EDITION_MAIN, EDITION_SECOND);
game(USER, FINISHED_TRACKED, 600, "BACKLOG", "STEAM", null);
jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER, FINISHED_TRACKED);
game(USER, PILE_WITH_TIME, 0, "BACKLOG", "STEAM", null);
game(USER, UNDER_HOUR, 30, "PLAYING", "STEAM", null);
game(USER, COMPLETED_2011, 1200, "COMPLETED", "STEAM", null);
jdbc.update("update user_game set finished_at_estimate = '2011-06-15T10:00:00Z' where user_id = ? and appid = ?",
USER, COMPLETED_2011);
game(USER, PILE_NO_TIME, 0, "BACKLOG", "STEAM", null);
game(USER, HIDDEN, 9999, "BACKLOG", "STEAM", null);
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, HIDDEN);
game(USER, REMOVED, 8888, "BACKLOG", "STEAM", null);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = ?", USER, REMOVED);
game(USER, EDITION_MAIN, 100, "FINISHED", "STEAM", null);
game(USER, EDITION_SECOND, 0, "BACKLOG", "STEAM", null);
jdbc.update("insert into game_link (appid, set_id) values (?, 900001), (?, 900001) on conflict do nothing",
EDITION_MAIN, EDITION_SECOND);
game(USER, MANUAL_SWITCH, 50, "BACKLOG", "MANUAL", " Switch ");
game(USER, MANUAL_SWITCH_LOWER, 70, "BACKLOG", "MANUAL", "switch");
game(USER, BACK_TO_PILE, 10, "PLAYING", "STEAM", null);
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", USER, BACK_TO_PILE);
game(OTHER, FINISHED_TRACKED, 4000, "FINISHED", "STEAM", null);
jdbc.update("""
insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 500, now())
on conflict (appid) do update set normally_minutes = 500
""", PILE_WITH_TIME);
gameMoodsRepository.upsert(FINISHED_TRACKED, new String[]{"CHILL"}, new String[]{"RPG"}, true, Instant.now());
gameMoodsRepository.upsert(UNDER_HOUR, new String[]{"CHILL"}, new String[]{"RPG", "Indie"}, true, Instant.now());
gameMoodsRepository.upsert(HIDDEN, new String[]{"CHILL"}, new String[]{"RPG"}, true, Instant.now());
day(FINISHED_TRACKED, 3, 120);
day(UNDER_HOUR, 3, 40);
day(FINISHED_TRACKED, 9, 50);
day(HIDDEN, 3, 5000);
}
private void game(long user, long appid, int minutes, String status, String source, String platform) {
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
jdbc.update("""
insert into user_game (user_id, appid, status, playtime_minutes, source, platform)
values (?, ?, ?, ?, ?, ?)
""", user, appid, status, minutes, source, platform);
}
private void day(long appid, int daysAgo, int minutes) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, ?, ?)",
USER, java.sql.Date.valueOf(LocalDate.now().minusDays(daysAgo)), appid, minutes);
}
private JsonNode read(String url, String key, String arg, int page) throws Exception {
var request = get(url).param("key", key).param("page", String.valueOf(page));
if (arg != null) {
request.param("arg", arg);
}
return JSON.readTree(mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse()
.getContentAsString());
}
private List<JsonNode> allRows(String key, String arg) throws Exception {
List<JsonNode> rows = new ArrayList<>();
int page = 0;
JsonNode body;
do {
body = read("/api/library/stats/games", key, arg, page++);
body.get("content").forEach(rows::add);
} while (page < body.get("totalPages").asInt());
return rows;
}
private JsonNode stats() throws Exception {
return JSON.readTree(mockMvc.perform(get("/api/library/stats")).andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString());
}
@ParameterizedTest(name = "{0}")
@MethodSource("cases")
void number_EqualsTheGamesBehindIt(Case c) throws Exception {
JsonNode stats = stats();
List<JsonNode> rows = allRows(c.key(), c.arg());
assertThat(rows).as("the seed has games for every case").isNotEmpty();
if (c.count() != null) {
assertThat((long) rows.size()).isEqualTo(c.count().apply(stats));
assertThat(read("/api/library/stats/games", c.key(), c.arg(), 0).get("totalElements").asLong())
.isEqualTo(c.count().apply(stats));
}
if (c.minutes() != null) {
assertThat(rows.stream().mapToLong(r -> r.get(c.minutesField()).asLong()).sum())
.isEqualTo(c.minutes().apply(stats));
}
assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotContain(HIDDEN, REMOVED);
assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates();
}
@Test
void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception {
List<Long> top = new ArrayList<>();
stats().at("/topGames/mostPlayed").forEach(g -> top.add(g.get("appid").asLong()));
List<Long> rows = allRows("MOST_PLAYED", null).stream().map(r -> r.get("appid").asLong()).toList();
assertThat(rows.subList(0, top.size())).isEqualTo(top);
assertThat(rows).doesNotContain(HIDDEN, REMOVED);
}
@Test
void linkedSet_ListsTheCountedEditionOnce() throws Exception {
List<Long> rows = allRows("TOTAL", null).stream().map(r -> r.get("appid").asLong()).toList();
assertThat(rows).contains(EDITION_MAIN).doesNotContain(EDITION_SECOND);
}
@Test
void trendMoves_ListsEveryMoveOntoAndOffThePile_NewestFirst() throws Exception {
List<JsonNode> rows = allRows("TREND_MOVES", null);
assertThat(rows).extracting(r -> r.get("appid").asLong()).contains(FINISHED_TRACKED, BACK_TO_PILE);
assertThat(rows).extracting(r -> r.get("at").asString()).isSortedAccordingTo(java.util.Comparator.reverseOrder());
assertThat(rows).filteredOn(r -> r.get("appid").asLong() == BACK_TO_PILE)
.extracting(r -> r.get("fromStatus").asString(), r -> r.get("toStatus").asString())
.contains(org.assertj.core.groups.Tuple.tuple("PLAYING", "BACKLOG"));
}
@Test
void moreThanOnePage_Has40OnTheFirstAndTheRestOnTheNext_ByPlaytime() throws Exception {
for (int i = 0; i < 45; i++) {
game(USER, 5_100_000L + i, 2000 + i, "PLAYING", "STEAM", null);
}
JsonNode first = read("/api/library/stats/games", "TOTAL", null, 0);
JsonNode second = read("/api/library/stats/games", "TOTAL", null, 1);
assertThat(first.get("content")).hasSize(40);
assertThat(first.get("size").asInt()).isEqualTo(40);
assertThat(second.get("content")).hasSize(first.get("totalElements").asInt() - 40);
assertThat(first.get("content").get(0).get("playtimeMinutes").asLong()).isEqualTo(2044);
assertThat(first.get("content").get(39).get("playtimeMinutes").asLong())
.isGreaterThan(second.get("content").get(0).get("playtimeMinutes").asLong());
}
@Test
void aPageFarBeyondTheEnd_IsEmpty_AndClamped() throws Exception {
JsonNode body = read("/api/library/stats/games", "TOTAL", null, 100_000);
assertThat(body.get("content")).isEmpty();
assertThat(body.get("page").asInt()).isEqualTo(250);
}
@Test
void anArgThatDoesNotFitTheKey_Is400WithBadArg() throws Exception {
mockMvc.perform(get("/api/library/stats/games").param("key", "STATUS").param("arg", "NOPE"))
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("BAD_ARG"));
mockMvc.perform(get("/api/library/stats/games").param("key", "TOTAL").param("arg", "x"))
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("BAD_ARG"));
mockMvc.perform(get("/api/library/stats/games").param("key", "FINISHED_YEAR").param("arg", "1900"))
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("BAD_ARG"));
}
@Test
void anUnknownKey_Is400() throws Exception {
mockMvc.perform(get("/api/library/stats/games").param("key", "NOPE")).andExpect(status().isBadRequest());
}
@Test
void aTextArgIsNeverSql() throws Exception {
JsonNode body = read("/api/library/stats/games", "GENRE", "x' or '1'='1", 0);
assertThat(body.get("content")).isEmpty();
}
}
@@ -0,0 +1,72 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class StatKeyTest extends AbstractUnitTest {
@Test
void parseArg_KeyWithoutArg_AcceptsOnlyNothing() {
assertThat(StatKey.TOTAL.parseArg(null)).contains("");
assertThat(StatKey.TOTAL.parseArg(" ")).contains("");
assertThat(StatKey.TOTAL.parseArg("1")).isEmpty();
}
@Test
void parseArg_Status_IsTheEnum() {
assertThat(StatKey.STATUS.parseArg("BACKLOG")).contains(GameStatus.BACKLOG);
assertThat(StatKey.STATUS.parseArg("NOPE")).isEmpty();
assertThat(StatKey.STATUS.parseArg(null)).isEmpty();
}
@Test
void parseArg_Text_IsTrimmedAndAtMost100Chars() {
assertThat(StatKey.GENRE.parseArg(" RPG ")).contains("RPG");
assertThat(StatKey.GENRE.parseArg("x".repeat(100))).isPresent();
assertThat(StatKey.GENRE.parseArg("x".repeat(101))).isEmpty();
assertThat(StatKey.GENRE.parseArg(" ")).isEmpty();
assertThat(StatKey.GENRE.parseArg(null)).isEmpty();
}
@Test
void parseArg_Year_IsAnIntegerFrom1970To2100() {
assertThat(StatKey.FINISHED_YEAR.parseArg("2011")).contains(2011);
assertThat(StatKey.FINISHED_YEAR.parseArg("1969")).isEmpty();
assertThat(StatKey.FINISHED_YEAR.parseArg("2101")).isEmpty();
assertThat(StatKey.FINISHED_YEAR.parseArg("abc")).isEmpty();
assertThat(StatKey.FINISHED_YEAR.parseArg(null)).isEmpty();
}
@Test
void visibleOnProfile_AGroupNeedsTheStatsSectionAndTheSharedGroup() {
Set<StatGroup> shared = Set.of(StatGroup.TASTE);
assertThat(StatKey.GENRE.visibleOnProfile(Set.of(ProfileSection.STATS), shared)).isTrue();
assertThat(StatKey.GENRE.visibleOnProfile(Set.of(ProfileSection.SUMMARY), shared)).isFalse();
assertThat(StatKey.GENRE.visibleOnProfile(Set.of(ProfileSection.STATS), Set.of(StatGroup.PILE))).isFalse();
}
@Test
void visibleOnProfile_OverviewOnlyKeysAreNeverShown() {
Set<ProfileSection> all = Set.of(ProfileSection.values());
Set<StatGroup> everyShared = StatGroup.shared(Arrays.stream(StatGroup.values()).map(Enum::name).toList());
assertThat(StatKey.PLAYED.visibleOnProfile(all, everyShared)).isFalse();
assertThat(StatKey.NEVER_STARTED.visibleOnProfile(all, everyShared)).isFalse();
}
@Test
void visibleOnProfile_SectionKeysFollowTheirSection() {
assertThat(StatKey.TOTAL.visibleOnProfile(Set.of(ProfileSection.SUMMARY), Set.of())).isTrue();
assertThat(StatKey.TOTAL.visibleOnProfile(Set.of(ProfileSection.STATUS), Set.of())).isFalse();
assertThat(StatKey.STATUS.visibleOnProfile(Set.of(ProfileSection.STATUS), Set.of())).isTrue();
assertThat(StatKey.STATUS.visibleOnProfile(Set.of(ProfileSection.SUMMARY), Set.of())).isFalse();
assertThat(StatKey.PERFECT.visibleOnProfile(Set.of(ProfileSection.SUMMARY), Set.of())).isTrue();
assertThat(StatKey.FINISHED_ALL.visibleOnProfile(Set.of(ProfileSection.STATS), Set.of())).isFalse();
}
}
@@ -4,6 +4,7 @@ import core_service.modules.library.domain.DayMinutes;
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.StatKey;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserStats;
import core_service.shared.test.AbstractPersistenceTest;
@@ -359,4 +360,14 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER_ID, appid);
}
@Test
void games_LinkedSetCountsOnce() {
userGameRepository.saveAllAndFlush(List.of(game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (620, 1)");
assertThat(repository.countGames(USER_ID, StatKey.TOTAL, "", BERLIN)).isEqualTo(1);
assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1);
}
}
@@ -331,4 +331,36 @@ class RewardControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from blocked_user where user_id = ?", USER);
}
}
@Test
void progressGames_ListsTheGamesBehindTheTrack() throws Exception {
game(GAME, "STEAM", "COMPLETED", 100);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'COMPLETED', ?, 40, now())",
USER, String.valueOf(GAME));
mockMvc.perform(get("/api/rewards/progress/GAMES_COMPLETED/games"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.untracked").value(0))
.andExpect(jsonPath("$.content[0].appid").value(GAME))
.andExpect(jsonPath("$.content[0].name").value("Game " + GAME))
.andExpect(jsonPath("$.content[0].status").value("COMPLETED"));
}
@Test
void progressGames_PlayMoreMonths_Is400() throws Exception {
mockMvc.perform(get("/api/rewards/progress/PLAY_MORE_MONTHS/games"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("NOT_A_GAME_LIST"));
}
@Test
void historyGames_MonthAndStreak_AreLists_OtherKindsAre400() throws Exception {
mockMvc.perform(get("/api/rewards/history/MONTH/2025-03/games")).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(0));
mockMvc.perform(get("/api/rewards/history/STREAK/2025-W10/games")).andExpect(status().isOk());
mockMvc.perform(get("/api/rewards/history/BUY/BADGE/games")).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("NOT_A_GAME_LIST"));
mockMvc.perform(get("/api/rewards/history/MONTH/garbage/games")).andExpect(status().isBadRequest());
}
}
@@ -3,12 +3,14 @@ package core_service.modules.rewards.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RewardRulesTest extends AbstractUnitTest {
@@ -89,4 +91,24 @@ class RewardRulesTest extends AbstractUnitTest {
assertThat(RewardRules.weekRef(LocalDate.of(2026, 10, 8))).isEqualTo("2026-W41");
assertThat(RewardRules.weekRef(LocalDate.of(2027, 1, 1))).isEqualTo("2026-W53");
}
@Test
void weekOf_IsTheInverseOfWeekRef_AlsoAtTheIsoYearEnd() {
for (LocalDate monday : new LocalDate[]{LocalDate.of(2026, 10, 5), LocalDate.of(2026, 12, 28),
LocalDate.of(2027, 1, 4), LocalDate.of(2024, 12, 30)}) {
assertThat(RewardRules.weekOf(RewardRules.weekRef(monday))).isEqualTo(monday);
}
}
@Test
void weekOf_NoWeek_Throws() {
for (String ref : new String[]{"2026-W54", "2027-W53", "x", "2026-W", "2026"}) {
assertThatThrownBy(() -> RewardRules.weekOf(ref)).isInstanceOf(DateTimeException.class);
}
}
@Test
void monthOf_IsTheInverseOfMonthRef() {
assertThat(RewardRules.monthOf(RewardRules.monthRef(YearMonth.of(2026, 9)))).isEqualTo(YearMonth.of(2026, 9));
}
}
@@ -66,4 +66,21 @@ class UnlocksTest extends AbstractUnitTest {
assertThat(unlock.stream().map(item -> item.track() + ":" + item.target()).collect(Collectors.toSet()))
.hasSameSizeAs(unlock);
}
@Test
void longestWeekRunWeeks_TheLatestRunWinsATie() {
LocalDate monday = LocalDate.of(2026, 9, 7);
assertThat(Unlocks.longestWeekRunWeeks(List.of(monday, monday.plusWeeks(1), monday.plusWeeks(5),
monday.plusWeeks(6), monday.plusWeeks(9))))
.containsExactly(monday.plusWeeks(5), monday.plusWeeks(6));
assertThat(Unlocks.longestWeekRunWeeks(List.of())).isEmpty();
}
@Test
void longestMonthRunMonths_AreTheMonthsOfTheLongestRun() {
assertThat(Unlocks.longestMonthRunMonths(List.of(YearMonth.of(2026, 12), YearMonth.of(2027, 1),
YearMonth.of(2027, 2), YearMonth.of(2027, 5))))
.containsExactly(YearMonth.of(2026, 12), YearMonth.of(2027, 1), YearMonth.of(2027, 2));
}
}
@@ -0,0 +1,208 @@
package core_service.modules.rewards.service;
import core_service.modules.rewards.domain.RewardGame;
import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.Track;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.IsoFields;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RewardGamesTest extends AbstractIntegrationTest {
private static final long USER = 15L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
private static final LocalDate MONDAY =
LocalDate.now(BERLIN).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(30);
@Autowired
private RewardService rewardService;
@Autowired
private JdbcTemplate jdbc;
private int games = 0;
@BeforeEach
@AfterEach
void clean() {
jdbc.update("delete from reward_ledger where user_id = ?", USER);
}
private long row(RewardKind kind, LocalDate day) {
long appid = 6_000_000L + games++;
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 0, ?)",
USER, kind.name(), String.valueOf(appid), Timestamp.from(day.atTime(12, 0).atZone(BERLIN).toInstant()));
return appid;
}
private void bonus(RewardKind kind, String ref) {
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 0, now())",
USER, kind.name(), ref);
}
private RewardService.RewardGamesPage progress(Track track) {
return rewardService.progressGames(USER, track, 0).orElseThrow();
}
@Test
void gamesFinished_TotalPlusUntrackedIsTheProgress() {
row(RewardKind.FINISHED, MONDAY);
row(RewardKind.FINISHED, MONDAY.plusDays(2));
row(RewardKind.FINISHED_MANUAL, MONDAY.plusDays(3));
row(RewardKind.COMPLETED, MONDAY.plusDays(4));
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'starter', 40, now())",
USER);
int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.GAMES_FINISHED);
RewardService.RewardGamesPage page = progress(Track.GAMES_FINISHED);
assertThat(page.total() + page.untracked()).isEqualTo(progress);
assertThat(page.untracked()).isEqualTo(2);
assertThat(page.games()).hasSize(3);
}
@Test
void gamesCompleted_TotalIsTheProgress() {
row(RewardKind.COMPLETED, MONDAY);
row(RewardKind.COMPLETED, MONDAY.plusDays(1));
row(RewardKind.FINISHED, MONDAY.plusDays(1));
int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.GAMES_COMPLETED);
RewardService.RewardGamesPage page = progress(Track.GAMES_COMPLETED);
assertThat(page.total()).isEqualTo(progress).isEqualTo(2);
assertThat(page.untracked()).isZero();
}
@Test
void finishWeeks_ListsTheGamesOfTheLatestLongestRun_NewestFirst() {
row(RewardKind.FINISHED, MONDAY.minusWeeks(4));
row(RewardKind.FINISHED, MONDAY);
row(RewardKind.FINISHED, MONDAY.plusDays(3));
row(RewardKind.FINISHED, MONDAY.plusWeeks(1));
row(RewardKind.FINISHED_MANUAL, MONDAY.plusWeeks(2).plusDays(6));
row(RewardKind.FINISHED, MONDAY.plusWeeks(4));
int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.FINISH_WEEKS);
RewardService.RewardGamesPage page = progress(Track.FINISH_WEEKS);
assertThat(progress).isEqualTo(3);
assertThat(page.games()).hasSize(4);
assertThat(page.games().stream().map(g -> g.at().atZone(BERLIN).toLocalDate()
.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)).distinct()).hasSize(progress);
assertThat(page.games()).extracting(RewardGame::at).isSortedAccordingTo(java.util.Comparator.reverseOrder());
}
@Test
void pileDownMonths_ListsTheFinishedGamesOfTheMonthsOfTheRun() {
LocalDate may = LocalDate.now(BERLIN).minusMonths(8).withDayOfMonth(10);
row(RewardKind.FINISHED, may.minusMonths(1));
row(RewardKind.FINISHED, may);
row(RewardKind.FINISHED, may.plusMonths(1));
row(RewardKind.FINISHED, may.plusMonths(3));
bonus(RewardKind.MONTH, java.time.YearMonth.from(may).toString());
bonus(RewardKind.MONTH, java.time.YearMonth.from(may).plusMonths(1).toString());
int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.PILE_DOWN_MONTHS);
RewardService.RewardGamesPage page = progress(Track.PILE_DOWN_MONTHS);
assertThat(progress).isEqualTo(2);
assertThat(page.games()).hasSize(2);
assertThat(page.games().stream().map(g -> java.time.YearMonth.from(g.at().atZone(BERLIN))).distinct())
.hasSize(progress);
}
@Test
void noRunYet_IsAnEmptyPage() {
assertThat(progress(Track.FINISH_WEEKS).games()).isEmpty();
assertThat(progress(Track.PILE_DOWN_MONTHS).total()).isZero();
}
@Test
void playMoreMonths_IsNoListOfGames() {
assertThat(rewardService.progressGames(USER, Track.PLAY_MORE_MONTHS, 0)).isEmpty();
}
@Test
void aGameThatLeftTheLibrary_StaysInTheListWithoutAStatus() {
row(RewardKind.COMPLETED, MONDAY);
RewardGame game = progress(Track.GAMES_COMPLETED).games().getFirst();
assertThat(game.status()).isNull();
assertThat(game.name()).startsWith("Game ");
}
@Test
void historyMonth_ListsTheFinishedGamesOfThatMonth() {
LocalDate day = LocalDate.of(2025, 3, 10);
row(RewardKind.FINISHED, day);
row(RewardKind.FINISHED_MANUAL, day.plusDays(10));
row(RewardKind.FINISHED, day.plusMonths(1));
assertThat(rewardService.historyGames(USER, RewardKind.MONTH, "2025-03", 0).orElseThrow().games()).hasSize(2);
}
@Test
void historyStreak_ListsTheWeekAndTheWeekBefore() {
LocalDate monday = LocalDate.of(2025, 3, 10);
row(RewardKind.FINISHED, monday.minusWeeks(2));
row(RewardKind.FINISHED, monday.minusDays(1));
row(RewardKind.FINISHED, monday.plusDays(4));
row(RewardKind.FINISHED, monday.plusWeeks(1));
String ref = "%d-W%02d".formatted(monday.get(IsoFields.WEEK_BASED_YEAR),
monday.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
assertThat(rewardService.historyGames(USER, RewardKind.STREAK, ref, 0).orElseThrow().games()).hasSize(2);
}
@Test
void history_OtherKindsAndBadRefs_AreNoList() {
assertThat(rewardService.historyGames(USER, RewardKind.BUY, "BADGE", 0)).isEmpty();
assertThat(rewardService.historyGames(USER, RewardKind.FINISHED, "220", 0)).isEmpty();
assertThat(rewardService.historyGames(USER, RewardKind.MONTH, "not-a-month", 0)).isEmpty();
assertThat(rewardService.historyGames(USER, RewardKind.STREAK, "2026-W99", 0)).isEmpty();
assertThat(rewardService.historyGames(USER, RewardKind.STREAK, "x", 0)).isEmpty();
}
@Test
void anotherUsersRows_AreNeverListed() {
row(RewardKind.COMPLETED, MONDAY);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'COMPLETED', '6999999', 0, now())",
USER + 1);
try {
assertThat(progress(Track.GAMES_COMPLETED).games()).extracting(RewardGame::appid).doesNotContain(6_999_999L);
} finally {
jdbc.update("delete from reward_ledger where user_id = ?", USER + 1);
}
}
@Test
void list_Has40PerPage() {
for (int i = 0; i < 45; i++) {
row(RewardKind.COMPLETED, MONDAY.plusDays(i % 7));
}
assertThat(rewardService.progressGames(USER, Track.GAMES_COMPLETED, 0).orElseThrow().games()).hasSize(40);
assertThat(rewardService.progressGames(USER, Track.GAMES_COMPLETED, 1).orElseThrow().games()).hasSize(5);
assertThat(List.of(rewardService.progressGames(USER, Track.GAMES_COMPLETED, 999).orElseThrow().page()))
.containsExactly(250);
}
}
@@ -0,0 +1,156 @@
package core_service.modules.stats.api;
import com.github.benmanes.caffeine.cache.Cache;
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.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.cache.CacheManager;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasSize;
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;
@AutoConfigureMockMvc
class PublicStatsControllerTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private CacheManager cacheManager;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now())));
userGameRepository.saveAll(List.of(new UserGame(1L, 220L, 300, Instant.now(), Instant.now()),
new UserGame(2L, 220L, 100, Instant.now(), Instant.now())));
cacheManager.getCache("publicStatGames").clear();
cacheManager.getCache("publicStats").clear();
}
@Test
void games_WithoutLogin_ListsTheGamesWithTheirLibraries() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(220))
.andExpect(jsonPath("$.content[0].libraries").value(2))
.andExpect(jsonPath("$.content[0].minutes").value(400));
}
@Test
void games_EveryRowHasExactlyTheseKeys_SoNoUserDataCanLeakLater() throws Exception {
JsonNode body = JsonMapper.builder().build().readTree(mockMvc.perform(get("/api/stats/public/games")
.param("key", "ENTRIES")).andReturn().getResponse().getContentAsString());
Set<String> keys = new HashSet<>();
body.get("content").get(0).propertyNames().forEach(keys::add);
assertThat(keys).containsExactlyInAnyOrder("appid", "name", "imageUrl", "libraries", "minutes");
}
@Test
void games_BadArg_Is400() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "GENRE")).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_ARG"));
mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED").param("arg", "x"))
.andExpect(status().isBadRequest());
mockMvc.perform(get("/api/stats/public/games").param("key", "NOPE")).andExpect(status().isBadRequest());
}
@Test
void games_APageBeyondTheEnd_IsEmptyAndClamped() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED").param("page", "100000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(0)))
.andExpect(jsonPath("$.page").value(250));
}
@Test
void games_AreCached() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED")).andExpect(status().isOk());
userGameRepository.deleteAll();
mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED"))
.andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void games_UnknownTextArg_RunsNoQueryAndFillsNoCache() throws Exception {
for (int i = 0; i < 3; i++) {
mockMvc.perform(get("/api/stats/public/games").param("key", "PLATFORM").param("arg", "no-such-platform-" + i));
mockMvc.perform(get("/api/stats/public/games").param("key", "GENRE").param("arg", "no-such-genre-" + i));
}
Cache<?, ?> cache = (Cache<?, ?>) cacheManager.getCache("publicStatGames").getNativeCache();
cache.cleanUp();
assertThat(cache.estimatedSize()).as("cache entries made by made-up args").isZero();
}
@Test
void games_TextArgCheck_UsesTheCachedAggregate() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "PLATFORM").param("arg", "no-such-platform"));
assertThat(cacheManager.getCache("publicStats").get("global"))
.as("the check must read the cached aggregate, not run it again").isNotNull();
}
@Test
void games_CaseVariantsOfAListedPlatform_ShareOneCacheEntry() throws Exception {
for (String arg : List.of("Steam", "STEAM", "steam", "sTeAm")) {
mockMvc.perform(get("/api/stats/public/games").param("key", "PLATFORM").param("arg", arg))
.andExpect(status().isOk());
}
Cache<?, ?> cache = (Cache<?, ?>) cacheManager.getCache("publicStatGames").getNativeCache();
cache.cleanUp();
assertThat(cache.estimatedSize()).as("cache entries for one platform, page 0").isEqualTo(1);
}
@Test
void games_YearWithoutFinishedGames_RunsNoQueryAndFillsNoCache() throws Exception {
for (int year = 1971; year < 1974; year++) {
mockMvc.perform(get("/api/stats/public/games").param("key", "FINISHED_YEAR").param("arg", "" + year));
}
Cache<?, ?> cache = (Cache<?, ?>) cacheManager.getCache("publicStatGames").getNativeCache();
cache.cleanUp();
assertThat(cache.estimatedSize()).as("cache entries made by years nobody finished a game in").isZero();
}
@Test
void publicStats_Recomputed_DropsTheCachedLists() throws Exception {
mockMvc.perform(get("/api/stats/public/games").param("key", "ENTRIES"))
.andExpect(jsonPath("$.content[0].libraries").value(2));
userGameRepository.save(new UserGame(3L, 220L, 50, Instant.now(), Instant.now()));
cacheManager.getCache("publicStats").clear();
mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk());
mockMvc.perform(get("/api/stats/public/games").param("key", "ENTRIES"))
.andExpect(jsonPath("$.content[0].libraries").value(3));
}
}
@@ -0,0 +1,29 @@
package core_service.modules.stats.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CommunityKeyTest extends AbstractUnitTest {
@Test
void parseArg_KeyWithoutArg_AcceptsOnlyNothing() {
assertThat(CommunityKey.ENTRIES.parseArg(null)).contains("");
assertThat(CommunityKey.ENTRIES.parseArg("x")).isEmpty();
}
@Test
void parseArg_Text_IsTrimmedAndAtMost100Chars() {
assertThat(CommunityKey.GENRE.parseArg(" RPG ")).contains("RPG");
assertThat(CommunityKey.PLATFORM.parseArg("x".repeat(101))).isEmpty();
assertThat(CommunityKey.PLATFORM.parseArg(null)).isEmpty();
}
@Test
void parseArg_Year_IsAnIntegerFrom1970To2100() {
assertThat(CommunityKey.FINISHED_YEAR.parseArg("2011")).contains(2011);
assertThat(CommunityKey.FINISHED_YEAR.parseArg("1969")).isEmpty();
assertThat(CommunityKey.FINISHED_YEAR.parseArg("x")).isEmpty();
}
}
@@ -7,6 +7,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.stats.api.dto.PublicStatsDto;
import core_service.modules.stats.domain.CommunityKey;
import core_service.modules.stats.service.PublicStatsService;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
@@ -23,6 +24,7 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@Import(StatsRepository.class)
class StatsRepositoryTest extends AbstractPersistenceTest {
@@ -136,6 +138,49 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(1L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList());
}
private long sum(List<Map<String, Object>> rows, String column) {
return rows.stream().mapToLong(row -> ((Number) row.get(column)).longValue()).sum();
}
private List<Map<String, Object>> community(CommunityKey key, Object arg) {
return statsRepository.communityGames(key, arg, "Europe/Berlin", 0, 100);
}
@Test
void communityGames_ListEqualsTheNumberOfEveryKey_NeverDeletedUsers_NeverAUserColumn() {
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()),
new SteamGame(8003L, "C", null, Instant.now())));
userGameRepository.saveAllAndFlush(List.of(
new UserGame(5L, 220L, 100, Instant.now(), Instant.now()),
UserGame.manual(5L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(3L, 8003L, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220");
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now());
List<Map<String, Object>> entries = community(CommunityKey.ENTRIES, "");
assertEquals(statsRepository.countLibraryEntries(), sum(entries, "libraries"));
assertEquals(entries.size(), statsRepository.countCommunityGames(CommunityKey.ENTRIES, "", "Europe/Berlin"));
List<Map<String, Object>> played = community(CommunityKey.PLAYED, "");
assertEquals(statsRepository.countGamesPlayed(), sum(played, "libraries"));
List<Map<String, Object>> owned = community(CommunityKey.OWNED, "");
assertEquals(statsRepository.countDistinctGames(), owned.size());
assertEquals(statsRepository.countDistinctGames(),
statsRepository.countCommunityGames(CommunityKey.OWNED, "", "Europe/Berlin"));
assertEquals(appids(statsRepository.topPlayedGames(1)).getFirst(),
appids(community(CommunityKey.MOST_PLAYED, "")).getFirst());
assertEquals(400L, ((Number) community(CommunityKey.MOST_PLAYED, "").getFirst().get("minutes")).longValue());
assertFalse(appids(owned).contains(8003L));
List<Map<String, Object>> platform = community(CommunityKey.PLATFORM, " PS5 ");
assertEquals(1, sum(platform, "libraries"));
assertEquals(10, sum(platform, "minutes"));
List<Map<String, Object>> genre = community(CommunityKey.GENRE, "RPG");
assertEquals(2, sum(genre, "libraries"));
assertEquals(400, sum(genre, "minutes"));
assertEquals(1, sum(community(CommunityKey.FINISHED_YEAR, LocalDate.now().getYear()), "libraries"));
assertEquals(java.util.Set.of("appid", "name", "image_url", "libraries", "minutes"), owned.getFirst().keySet());
}
private static List<Long> appids(List<Map<String, Object>> rows) {
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
}
@@ -158,7 +203,7 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z"));
userGameRepository.saveAndFlush(estimated);
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620");
PublicStatsService service = new PublicStatsService(statsRepository);
PublicStatsService service = new PublicStatsService(statsRepository, new org.springframework.cache.concurrent.ConcurrentMapCacheManager("publicStatGames"));
ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin"));
PublicStatsDto dto = service.getPublicStats();