Public Access
feat(library): add drill-down lists for stat numbers
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:
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();
|
||||
|
||||
Reference in new issue
Block a user