feat(library): hide games from lists and stats
Build, Test & Push Core Service / quality-check (push) Successful in 58s
Build, Test & Push Core Service / docker (push) Successful in 1m25s

This commit is contained in:
lobo committed 2026-09-26 12:27:58 +02:00
1 parent 246e255058
commit c2d17d0bc7
27 files changed
+559 -56

No files matched your search

@@ -5,6 +5,7 @@ import core_service.modules.library.domain.BrokenImageId;
import core_service.modules.library.domain.ImageKind;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -71,6 +72,19 @@ class LibraryControllerBrokenImageTest extends AbstractIntegrationTest {
assertThat(brokenImageRepository.count()).isZero();
}
@Test
void reportBrokenImage_ForAHiddenGameStoresNothing() throws Exception {
UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, APPID)).orElseThrow();
hidden.setHiddenAt(Instant.now());
userGameRepository.save(hidden);
mockMvc.perform(post("/api/library/broken-images").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"appid\":220,\"kind\":\"COVER\"}"))
.andExpect(status().isNoContent());
assertThat(brokenImageRepository.count()).isZero();
}
@ParameterizedTest
@ValueSource(strings = {"{\"appid\":220,\"kind\":\"BANNER\"}", "{\"appid\":220}", "{\"kind\":\"COVER\"}"})
void reportBrokenImage_BadBodyIs400(String body) throws Exception {
@@ -0,0 +1,173 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerHiddenTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(1000L, "Some Playtest", null, Instant.now()));
steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 60, Instant.now()));
}
private ResultActions setHidden(long appid, String body) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/hidden").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private UserGame stored(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
@Test
void hideAndUnhide_MoveTheGameBetweenTheLists() throws Exception {
setHidden(1000L, "{\"hidden\":true}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(true));
mockMvc.perform(get("/api/library/games"))
.andExpect(jsonPath("$.content", hasSize(2)))
.andExpect(jsonPath("$.content[?(@.appid == 1000)]").isEmpty());
mockMvc.perform(get("/api/library/games?hidden=true"))
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].appid").value(1000))
.andExpect(jsonPath("$.content[0].hidden").value(true));
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.count").value(2));
mockMvc.perform(get("/api/library/summary")).andExpect(jsonPath("$.total").value(2));
mockMvc.perform(get("/api/library/games/1000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(true));
setHidden(1000L, "{\"hidden\":false}").andExpect(status().isOk());
assertNull(stored(1000L).getHiddenAt());
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.count").value(3));
}
@Test
void hide_AHiddenGameKeepsTheFirstTime() throws Exception {
Instant first = Instant.now().minus(3, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MICROS);
UserGame game = stored(1000L);
game.setHiddenAt(first);
userGameRepository.save(game);
setHidden(1000L, "{\"hidden\":true}").andExpect(status().isOk());
assertEquals(first, stored(1000L).getHiddenAt());
}
@Test
void hide_UnknownGameIs404() throws Exception {
setHidden(620L, "{\"hidden\":true}")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void hide_RemovedGameIs404() throws Exception {
UserGame game = stored(1000L);
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
setHidden(1000L, "{\"hidden\":true}").andExpect(status().isNotFound());
}
@Test
void hide_GameAddedByHandIs409() throws Exception {
setHidden(-1942L, "{\"hidden\":true}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("MANUAL_GAME"));
assertNull(stored(-1942L).getHiddenAt());
}
@Test
void unhide_WorksForAnyGame() throws Exception {
UserGame manual = stored(-1942L);
manual.setHiddenAt(Instant.now());
userGameRepository.save(manual);
setHidden(-1942L, "{\"hidden\":false}").andExpect(status().isOk());
assertNull(stored(-1942L).getHiddenAt());
}
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"hidden\":null}", "{\"hidden\":\"maybe\"}"})
void hide_BadBodyIs400(String body) throws Exception {
setHidden(1000L, body).andExpect(status().isBadRequest());
assertNull(stored(1000L).getHiddenAt());
}
@Test
void hide_WithoutClientHeaderIs403() throws Exception {
mockMvc.perform(put("/api/library/games/1000/hidden")
.contentType(MediaType.APPLICATION_JSON).content("{\"hidden\":true}"))
.andExpect(status().isForbidden());
assertNull(stored(1000L).getHiddenAt());
}
@Test
void hide_KeepsStatusAndNotes() throws Exception {
UserGame game = stored(220L);
game.setNotes("keep me");
userGameRepository.save(game);
setHidden(220L, "{\"hidden\":true}").andExpect(status().isOk());
UserGame after = stored(220L);
assertNotNull(after.getHiddenAt());
assertEquals(GameStatus.PLAYING, after.getStatus());
assertEquals("keep me", after.getNotes());
}
}
@@ -174,6 +174,19 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
assertEquals(600, back.getPlaytimeMinutes());
}
@Test
void add_HiddenSteamGameRemovedBefore_ComesBackShown() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
UserGame refunded = new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now());
refunded.setHiddenAt(Instant.now());
refunded.setRemovedAt(Instant.now());
userGameRepository.save(refunded);
add(119133, "PS5").andExpect(status().isCreated());
assertNull(stored(1245620).getHiddenAt());
}
@Test
void add_UnknownIgdbGame_Is400() throws Exception {
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
@@ -1,10 +1,13 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -18,6 +21,12 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PlaytimeDayRepository playtimeDayRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Test
void addMinutes_SameGameAndDay_AddsUp() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
@@ -37,4 +46,25 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(new DayMinutes(DAY, 50), new DayMinutes(DAY.plusDays(2), 10)),
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void minutesPerDay_LeavesOutHiddenGames() {
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now())));
UserGame hidden = new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now());
hidden.setHiddenAt(Instant.now());
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 30, Instant.now(), Instant.now()), hidden,
// Another user's hidden flag does not hide the game for this user.
hiddenFor(2L, 220L)));
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 60);
assertEquals(List.of(new DayMinutes(DAY, 30)), playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
private static UserGame hiddenFor(long userId, long appid) {
UserGame game = new UserGame(userId, appid, 0, Instant.now(), Instant.now());
game.setHiddenAt(Instant.now());
return game;
}
}
@@ -61,7 +61,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), null, PAGE)
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE)
.map(UserGame::getAppid).getContent();
}
@@ -136,7 +136,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), null,
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -144,7 +144,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
}
@@ -195,7 +195,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@@ -210,6 +210,65 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID));
}
private void hide(long... appids) {
for (long appid : appids) {
userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().setHiddenAt(Instant.now());
}
userGameRepository.flush();
}
@Test
void hiddenGames_LeaveTheListAndShowOnlyUnderTheHiddenFilter() {
hide(620L, 700L);
assertEquals(List.of(220L, 380L), appids(null, null));
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
}
@Test
void hiddenGames_LeaveEveryCountAndSum() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(380L, 100, 200, 300, Instant.now()),
new GameTimeToBeat(700L, 100, 150, 300, Instant.now())));
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(60);
hide(620L, 700L);
assertEquals(2, userGameRepository.countListed(USER_ID));
assertEquals(300, userGameRepository.sumPlaytime(USER_ID));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
assertEquals(200, userGameRepository.sumBacklogMinutes(USER_ID));
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
.stream().map(p -> p.appid()).toList()));
}
@Test
void hiddenGames_AreNeverPicked() {
hide(700L);
for (int i = 0; i < 5; i++) {
assertEquals(380L, userGameRepository.findRandomBacklogGame(USER_ID, -1).orElseThrow().getAppid());
assertEquals(List.of(380L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)
.stream().map(UserGame::getAppid).toList());
}
}
@Test
void findActiveAppids_StillSeesHiddenGames() {
hide(700L);
assertEquals(List.of(700L), userGameRepository.findActiveAppids(USER_ID, List.of(700L)));
}
@Test
void containsPattern_BlankIsNoFilter() {
assertNull(UserGameRepository.containsPattern(" "));
@@ -235,7 +294,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.save(new UserGame(2L, 380L, 0, Instant.now(), Instant.now()));
userGameRepository.flush();
assertEquals(1, userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(USER_ID, 0));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
}
@Test
@@ -124,6 +124,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verifyNoInteractions(steamApiService);
}
@Test
void syncLibrary_HiddenGame_AsksSteamNothing() {
UserGame hidden = game(620L, 120);
hidden.setHiddenAt(Instant.now());
library(List.of(hidden), List.of(), List.of());
service.syncLibrary(USER_ID, STEAM_ID);
verifyNoInteractions(steamApiService);
}
@Test
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -104,6 +105,21 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
assertEquals(false, detail.hasAchievements());
}
@Test
void getGameDetail_HiddenGame_ShowsStoredDataAndAsksNobody() {
UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
hidden.setHiddenAt(Instant.now());
userGameRepository.save(hidden);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now().minus(Duration.ofDays(31))));
schemaRepository.save(new GameAchievementSchema(620L, "A", "A", null, "icon", "gray", false));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
org.mockito.Mockito.verifyNoInteractions(steamApiService, speedrunService, timeToBeatService, moodService);
assertEquals(true, detail.hidden());
assertEquals(1, detail.achievementsTotal());
}
@Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
@@ -80,7 +80,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Test
void report_StoresAReportForAnOwnedSteamGame() {
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of(620L));
when(userGameRepository.findListedAppids(1L, List.of(620L))).thenReturn(List.of(620L));
service.report(1L, 620L, ImageKind.COVER);
@@ -89,7 +89,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Test
void report_DropsGamesTheUserDoesNotOwn() {
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of());
when(userGameRepository.findListedAppids(1L, List.of(620L))).thenReturn(List.of());
service.report(1L, 620L, ImageKind.COVER);
@@ -419,6 +419,31 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(moodService).refresh(List.of(220L, -1025L));
}
@Test
void runSync_KeepsTheHiddenFlagAndUpdatesThePlaytime() {
UserGame hidden = owned(60);
Instant hiddenAt = Instant.parse("2026-09-01T10:00:00Z");
hidden.setHiddenAt(hiddenAt);
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
assertEquals(hiddenAt, hidden.getHiddenAt());
assertEquals(90, hidden.getPlaytimeMinutes());
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30);
}
@Test
void runSync_HiddenGamesGetNoTimeToBeatOrMoods() {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findHiddenAppids(USER_ID)).thenReturn(List.of(220L));
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of());
verify(moodService).refresh(List.of());
}
@Test
void runSync_GameWithoutName_GetsTheAppidAsName() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
@@ -0,0 +1,64 @@
package core_service.modules.stats.repo;
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.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Import(StatsRepository.class)
class StatsRepositoryTest extends AbstractPersistenceTest {
@Autowired
private StatsRepository statsRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(1000L, "Playtest", null, Instant.now())));
UserGame hiddenPlaytest = new UserGame(1L, 1000L, 5000, Instant.now(), Instant.now());
hiddenPlaytest.setHiddenAt(Instant.now());
UserGame hiddenNeverPlayed = new UserGame(2L, 1000L, 0, Instant.now(), Instant.now());
hiddenNeverPlayed.setHiddenAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(new UserGame(1L, 220L, 300, Instant.now(), Instant.now()),
hiddenPlaytest, hiddenNeverPlayed));
}
@Test
void hiddenGames_LeaveEveryNumber() {
assertEquals(1, statsRepository.countDistinctGames());
assertEquals(1, statsRepository.countLibraryEntries());
assertEquals(1, statsRepository.countGamesPlayed());
assertEquals(300, statsRepository.sumPlaytimeMinutes());
assertEquals(1, statsRepository.countUsersWithLibrary());
assertEquals(1, statsRepository.biggestLibrary());
assertEquals(0, statsRepository.biggestBacklog());
}
@Test
void hiddenGames_LeaveTheTopLists() {
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));
assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5)));
}
private static List<Long> appids(List<Map<String, Object>> rows) {
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
}
}