From 95fe92db126fa6d7dc41de3a20eb1829c009176a Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 01:51:26 +0100 Subject: [PATCH 1/7] feat: notify on chat when new record is established Add RecordNotificationService that listens to GameSessionRegistrationEvent and checks if the submitted duration is the best ever for that game in the group. If so, sends a notification to the group chat. Changes: - Add RecordNotificationService in chat package - Add findBestDuration, findSecondBestDuration, countByGroupChatIdAndGame queries to GameSessionRepository - Add gameType field to GameSessionRegistrationEvent for easier querying - Make USER_INTERACTION_NOTIFICATION_ORDER package-private in NotificationService Closes #7 --- .../ldrbot/chat/NotificationService.java | 2 +- .../chat/RecordNotificationService.java | 71 +++++++++++++++++++ .../session/GameSessionRegistrationEvent.java | 4 +- .../ldrbot/session/GameSessionRepository.java | 11 +++ .../ldrbot/session/GameSessionService.java | 2 +- 5 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java index d0b4991..5faa8f9 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java @@ -50,7 +50,7 @@ public class NotificationService { """ + ChatConstants.HELP_SUGGESTION; private static final int GREETING_NOTIFICATION_ORDER = Ordered.HIGHEST_PRECEDENCE; - private static final int USER_INTERACTION_NOTIFICATION_ORDER = GREETING_NOTIFICATION_ORDER + 1000; + static final int USER_INTERACTION_NOTIFICATION_ORDER = GREETING_NOTIFICATION_ORDER + 1000; private static final int DAILY_RANKING_NOTIFICATION_ORDER = 0; private final CustomTelegramClient customTelegramClient; diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java new file mode 100644 index 0000000..2ddbdde --- /dev/null +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java @@ -0,0 +1,71 @@ +package dev.rubasace.linkedin.games.ldrbot.chat; + +import dev.rubasace.linkedin.games.ldrbot.configuration.ExecutorsConfiguration; +import dev.rubasace.linkedin.games.ldrbot.session.GameSessionRegistrationEvent; +import dev.rubasace.linkedin.games.ldrbot.session.GameSessionRepository; +import dev.rubasace.linkedin.games.ldrbot.util.FormatUtils; +import org.springframework.core.annotation.Order; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.Duration; +import java.util.Optional; + +/** + * Sends a notification to the group chat when a user establishes a new record + * (i.e., the fastest time ever registered for a given game in the group). + */ +@Component +public class RecordNotificationService { + + private static final String NEW_RECORD_MESSAGE_TEMPLATE = "\uD83C\uDFC6 New %s record!\n%s set a new group record with a time of %s! \uD83C\uDF89"; + private static final String RECORD_BROKEN_MESSAGE_TEMPLATE = "\uD83C\uDFC6 New %s record!\n%s set a new group record with a time of %s (previous record: %s)! \uD83C\uDF89"; + + private static final int RECORD_NOTIFICATION_ORDER = NotificationService.USER_INTERACTION_NOTIFICATION_ORDER + 1; + + private final CustomTelegramClient customTelegramClient; + private final GameSessionRepository gameSessionRepository; + + RecordNotificationService(final CustomTelegramClient customTelegramClient, final GameSessionRepository gameSessionRepository) { + this.customTelegramClient = customTelegramClient; + this.gameSessionRepository = gameSessionRepository; + } + + @Order(RECORD_NOTIFICATION_ORDER) + @Async(ExecutorsConfiguration.NOTIFICATION_LISTENER_EXECUTOR_NAME) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + void handleSessionRegistration(final GameSessionRegistrationEvent event) { + Duration submittedDuration = event.getDuration(); + Long chatId = event.getChatId(); + + Optional bestDuration = gameSessionRepository.findBestDuration(chatId, event.getGameType()); + + // If the best duration in the DB is not the submitted one, this is not a record + if (bestDuration.isEmpty() || submittedDuration.compareTo(bestDuration.get()) != 0) { + return; + } + + // Find the previous best (next best duration strictly greater than current) + Optional previousBest = gameSessionRepository.findSecondBestDuration(chatId, event.getGameType(), submittedDuration); + + String gameName = event.getGameInfo().name(); + String userMention = FormatUtils.formatUserMention(event.getUserInfo()); + String formattedDuration = FormatUtils.formatDuration(submittedDuration); + + if (previousBest.isPresent()) { + // There was a previous record — show it + customTelegramClient.sendMessage( + RECORD_BROKEN_MESSAGE_TEMPLATE.formatted(gameName, userMention, formattedDuration, FormatUtils.formatDuration(previousBest.get())), + event.getChatInfo().chatId() + ); + } else { + // First ever submission for this game — it's the first record + customTelegramClient.sendMessage( + NEW_RECORD_MESSAGE_TEMPLATE.formatted(gameName, userMention, formattedDuration), + event.getChatInfo().chatId() + ); + } + } +} diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java index c44f0b5..581dd16 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java @@ -14,15 +14,17 @@ public class GameSessionRegistrationEvent extends ApplicationEvent { private final ChatInfo chatInfo; private final UserInfo userInfo; private final GameInfo gameInfo; + private final GameType gameType; private final Duration duration; private final LocalDate gameDay; private final Long chatId; - public GameSessionRegistrationEvent(final Object source, final ChatInfo chatInfo, final UserInfo userInfo, final GameInfo gameInfo, final Duration duration, final LocalDate gameDay, final Long chatId) { + public GameSessionRegistrationEvent(final Object source, final ChatInfo chatInfo, final UserInfo userInfo, final GameInfo gameInfo, final GameType gameType, final Duration duration, final LocalDate gameDay, final Long chatId) { super(source); this.chatInfo = chatInfo; this.userInfo = userInfo; this.gameInfo = gameInfo; + this.gameType = gameType; this.duration = duration; this.gameDay = gameDay; this.chatId = chatId; diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java index 320aeb8..4ca1f1e 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java @@ -1,8 +1,11 @@ package dev.rubasace.linkedin.games.ldrbot.session; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.LocalDate; import java.util.Optional; import java.util.Set; @@ -25,4 +28,12 @@ public interface GameSessionRepository extends JpaRepository @Transactional void deleteByUserIdAndGroupChatIdAndGameDay(Long UserId, Long chatId, LocalDate gameDay); + + @Query("SELECT MIN(gs.duration) FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game") + Optional findBestDuration(@Param("chatId") Long chatId, @Param("game") GameType game); + + long countByGroupChatIdAndGame(Long chatId, GameType game); + + @Query("SELECT MIN(gs.duration) FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game AND gs.duration > :bestDuration") + Optional findSecondBestDuration(@Param("chatId") Long chatId, @Param("game") GameType game, @Param("bestDuration") Duration bestDuration); } diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java index 61be401..150ce97 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java @@ -73,7 +73,7 @@ public void recordGameSession(final ChatInfo chatInfo, final UserInfo userInfo, private void saveSession(final ChatInfo chatInfo, final UserInfo userInfo, final LocalDate gameDay, final GameSession gameSession, final GameInfo gameInfo, final TelegramGroup telegramGroup) { gameSessionRepository.saveAndFlush(gameSession); - applicationEventPublisher.publishEvent(new GameSessionRegistrationEvent(this, chatInfo, userInfo, gameInfo, gameSession.getDuration(), gameDay, + applicationEventPublisher.publishEvent(new GameSessionRegistrationEvent(this, chatInfo, userInfo, gameInfo, gameSession.getGame(), gameSession.getDuration(), gameDay, telegramGroup.getChatId())); } From 65189b1f194ab5897effffa108f7d74073944f15 Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 02:14:55 +0100 Subject: [PATCH 2/7] test: add unit tests for RecordNotificationService --- .../chat/RecordNotificationServiceTest.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java new file mode 100644 index 0000000..811dbd0 --- /dev/null +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -0,0 +1,117 @@ +package dev.rubasace.linkedin.games.ldrbot.chat; + +import dev.rubasace.linkedin.games.ldrbot.group.ChatInfo; +import dev.rubasace.linkedin.games.ldrbot.session.*; +import dev.rubasace.linkedin.games.ldrbot.user.UserInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RecordNotificationServiceTest { + + @Mock + private CustomTelegramClient customTelegramClient; + + @Mock + private GameSessionRepository gameSessionRepository; + + private RecordNotificationService service; + + private static final Long CHAT_ID = 123L; + private static final GameType GAME = GameType.QUEENS; + private static final ChatInfo CHAT_INFO = new ChatInfo(CHAT_ID, "Test Group", true); + private static final UserInfo USER_INFO = new UserInfo(1L, "testuser", "Test", "User"); + private static final GameInfo GAME_INFO = new GameInfo("Queens", "👑"); + + @BeforeEach + void setUp() { + service = new RecordNotificationService(customTelegramClient, gameSessionRepository); + } + + private GameSessionRegistrationEvent createEvent(Duration duration) { + return new GameSessionRegistrationEvent(this, CHAT_INFO, USER_INFO, GAME_INFO, GAME, duration, LocalDate.now(), CHAT_ID); + } + + @Test + void shouldNotNotifyWhenSubmittedDurationIsNotTheBest() { + Duration submitted = Duration.ofSeconds(120); + Duration best = Duration.ofSeconds(60); + when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(best)); + + service.handleSessionRegistration(createEvent(submitted)); + + verifyNoInteractions(customTelegramClient); + } + + @Test + void shouldNotNotifyWhenNoBestDurationFound() { + when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.empty()); + + service.handleSessionRegistration(createEvent(Duration.ofSeconds(60))); + + verifyNoInteractions(customTelegramClient); + } + + @Test + void shouldNotifyFirstRecordEver() { + Duration submitted = Duration.ofSeconds(90); + when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); + when(gameSessionRepository.findSecondBestDuration(CHAT_ID, GAME, submitted)).thenReturn(Optional.empty()); + + service.handleSessionRegistration(createEvent(submitted)); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(CHAT_ID)); + String message = messageCaptor.getValue(); + assertTrue(message.contains("New Queens record!")); + assertTrue(message.contains("@testuser")); + assertTrue(message.contains("01:30")); + assertFalse(message.contains("previous record")); + } + + @Test + void shouldNotifyRecordBrokenWithPreviousBest() { + Duration submitted = Duration.ofSeconds(45); + Duration previous = Duration.ofSeconds(90); + when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); + when(gameSessionRepository.findSecondBestDuration(CHAT_ID, GAME, submitted)).thenReturn(Optional.of(previous)); + + service.handleSessionRegistration(createEvent(submitted)); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(CHAT_ID)); + String message = messageCaptor.getValue(); + assertTrue(message.contains("New Queens record!")); + assertTrue(message.contains("@testuser")); + assertTrue(message.contains("00:45")); + assertTrue(message.contains("previous record: 01:30")); + } + + @Test + void shouldFormatUserWithoutUsernameAsMention() { + UserInfo noUsername = new UserInfo(42L, null, "John", "Doe"); + Duration submitted = Duration.ofSeconds(30); + GameSessionRegistrationEvent event = new GameSessionRegistrationEvent(this, CHAT_INFO, noUsername, GAME_INFO, GAME, submitted, LocalDate.now(), CHAT_ID); + when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); + when(gameSessionRepository.findSecondBestDuration(CHAT_ID, GAME, submitted)).thenReturn(Optional.empty()); + + service.handleSessionRegistration(event); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(CHAT_ID)); + String message = messageCaptor.getValue(); + assertTrue(message.contains("tg://user?id=42")); + assertTrue(message.contains("John Doe")); + } +} From 63c63b957c63172bb0a451fe797ce09c61181206 Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 04:46:38 +0100 Subject: [PATCH 3/7] fix: use Stream-based query for previous best duration Replaced JPQL comparison (`gs.duration > :bestDuration`) with Stream-based approach to avoid JPA Duration comparison issues that caused ApplicationTests.contextLoads to fail. Changes: - GameSessionRepository: replaced `findSecondBestDuration` with `findDistinctDurationsOrderedAsc` returning ordered Stream - RecordNotificationService: use .skip(1).findFirst() to get second-best duration - RecordNotificationServiceTest: updated mocks to use Stream API This approach avoids JPQL Duration comparison while maintaining the same functionality. --- .../games/ldrbot/chat/RecordNotificationService.java | 6 ++++-- .../games/ldrbot/session/GameSessionRepository.java | 4 ++-- .../games/ldrbot/chat/RecordNotificationServiceTest.java | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java index 2ddbdde..477defa 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java @@ -47,8 +47,10 @@ void handleSessionRegistration(final GameSessionRegistrationEvent event) { return; } - // Find the previous best (next best duration strictly greater than current) - Optional previousBest = gameSessionRepository.findSecondBestDuration(chatId, event.getGameType(), submittedDuration); + // Find the previous best (second best duration after the current record) + Optional previousBest = gameSessionRepository.findDistinctDurationsOrderedAsc(chatId, event.getGameType()) + .skip(1) // Skip the first (current best) + .findFirst(); // Get the second one (previous best) String gameName = event.getGameInfo().name(); String userMention = FormatUtils.formatUserMention(event.getUserInfo()); diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java index 4ca1f1e..8a40486 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java @@ -34,6 +34,6 @@ public interface GameSessionRepository extends JpaRepository long countByGroupChatIdAndGame(Long chatId, GameType game); - @Query("SELECT MIN(gs.duration) FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game AND gs.duration > :bestDuration") - Optional findSecondBestDuration(@Param("chatId") Long chatId, @Param("game") GameType game, @Param("bestDuration") Duration bestDuration); + @Query("SELECT DISTINCT gs.duration FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game ORDER BY gs.duration ASC") + Stream findDistinctDurationsOrderedAsc(@Param("chatId") Long chatId, @Param("game") GameType game); } diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java index 811dbd0..b84b624 100644 --- a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -67,7 +67,7 @@ void shouldNotNotifyWhenNoBestDurationFound() { void shouldNotifyFirstRecordEver() { Duration submitted = Duration.ofSeconds(90); when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); - when(gameSessionRepository.findSecondBestDuration(CHAT_ID, GAME, submitted)).thenReturn(Optional.empty()); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(Stream.of(submitted)); service.handleSessionRegistration(createEvent(submitted)); @@ -85,7 +85,7 @@ void shouldNotifyRecordBrokenWithPreviousBest() { Duration submitted = Duration.ofSeconds(45); Duration previous = Duration.ofSeconds(90); when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); - when(gameSessionRepository.findSecondBestDuration(CHAT_ID, GAME, submitted)).thenReturn(Optional.of(previous)); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(Stream.of(submitted, previous)); service.handleSessionRegistration(createEvent(submitted)); @@ -104,7 +104,7 @@ void shouldFormatUserWithoutUsernameAsMention() { Duration submitted = Duration.ofSeconds(30); GameSessionRegistrationEvent event = new GameSessionRegistrationEvent(this, CHAT_INFO, noUsername, GAME_INFO, GAME, submitted, LocalDate.now(), CHAT_ID); when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); - when(gameSessionRepository.findSecondBestDuration(CHAT_ID, GAME, submitted)).thenReturn(Optional.empty()); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(Stream.of(submitted)); service.handleSessionRegistration(event); From 78522d9e022e19a8dedafefeaa82534f40c3eb42 Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 04:52:47 +0100 Subject: [PATCH 4/7] fix: add missing Stream import in RecordNotificationServiceTest --- .../games/ldrbot/chat/RecordNotificationServiceTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java index b84b624..cc06c22 100644 --- a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -13,6 +13,7 @@ import java.time.Duration; import java.time.LocalDate; import java.util.Optional; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; From 4081ec53069977f2211356d7f1f24f21b654d4da Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 07:36:15 +0100 Subject: [PATCH 5/7] test: mock CustomTelegramClient in ApplicationTests RecordNotificationService depends on CustomTelegramClient. Without this mock, the application context fails to load in tests. --- .../dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java index 561e204..f7c36a3 100644 --- a/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java @@ -1,5 +1,6 @@ package dev.rubasace.linkedin.games.ldrbot; +import dev.rubasace.linkedin.games.ldrbot.chat.CustomTelegramClient; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -11,6 +12,9 @@ class ApplicationTests { @MockitoBean private TelegramBotInitializer telegramBotInitializer; + @MockitoBean + private CustomTelegramClient customTelegramClient; + @Test void contextLoads() { } From a79075be0bec042e17ddff276f32a3439c9c3b18 Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 09:46:00 +0100 Subject: [PATCH 6/7] fix: replace Stream with List in findDistinctDurationsOrderedAsc JPA Streams require an active transaction to be consumed. The RecordNotificationService listener runs AFTER_COMMIT (outside transaction), causing LazyInitializationException or similar errors. Changes: - GameSessionRepository.findDistinctDurationsOrderedAsc now returns List instead of Stream - RecordNotificationService consumes the list using index access - Tests updated to mock List.of() instead of Stream.of() Resolves the Drone CI build failure. --- .../games/ldrbot/chat/RecordNotificationService.java | 8 +++++--- .../games/ldrbot/session/GameSessionRepository.java | 3 ++- .../games/ldrbot/chat/RecordNotificationServiceTest.java | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java index 477defa..2a6a8f2 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java @@ -11,6 +11,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import java.time.Duration; +import java.util.List; import java.util.Optional; /** @@ -48,9 +49,10 @@ void handleSessionRegistration(final GameSessionRegistrationEvent event) { } // Find the previous best (second best duration after the current record) - Optional previousBest = gameSessionRepository.findDistinctDurationsOrderedAsc(chatId, event.getGameType()) - .skip(1) // Skip the first (current best) - .findFirst(); // Get the second one (previous best) + List distinctDurations = gameSessionRepository.findDistinctDurationsOrderedAsc(chatId, event.getGameType()); + Optional previousBest = distinctDurations.size() > 1 + ? Optional.of(distinctDurations.get(1)) // Get the second one (previous best) + : Optional.empty(); String gameName = event.getGameInfo().name(); String userMention = FormatUtils.formatUserMention(event.getUserInfo()); diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java index 8a40486..2851b1a 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java @@ -7,6 +7,7 @@ import java.time.Duration; import java.time.LocalDate; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -35,5 +36,5 @@ public interface GameSessionRepository extends JpaRepository long countByGroupChatIdAndGame(Long chatId, GameType game); @Query("SELECT DISTINCT gs.duration FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game ORDER BY gs.duration ASC") - Stream findDistinctDurationsOrderedAsc(@Param("chatId") Long chatId, @Param("game") GameType game); + List findDistinctDurationsOrderedAsc(@Param("chatId") Long chatId, @Param("game") GameType game); } diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java index cc06c22..ae07485 100644 --- a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -12,8 +12,8 @@ import java.time.Duration; import java.time.LocalDate; +import java.util.List; import java.util.Optional; -import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -68,7 +68,7 @@ void shouldNotNotifyWhenNoBestDurationFound() { void shouldNotifyFirstRecordEver() { Duration submitted = Duration.ofSeconds(90); when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); - when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(Stream.of(submitted)); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted)); service.handleSessionRegistration(createEvent(submitted)); @@ -86,7 +86,7 @@ void shouldNotifyRecordBrokenWithPreviousBest() { Duration submitted = Duration.ofSeconds(45); Duration previous = Duration.ofSeconds(90); when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); - when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(Stream.of(submitted, previous)); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted, previous)); service.handleSessionRegistration(createEvent(submitted)); @@ -105,7 +105,7 @@ void shouldFormatUserWithoutUsernameAsMention() { Duration submitted = Duration.ofSeconds(30); GameSessionRegistrationEvent event = new GameSessionRegistrationEvent(this, CHAT_INFO, noUsername, GAME_INFO, GAME, submitted, LocalDate.now(), CHAT_ID); when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); - when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(Stream.of(submitted)); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted)); service.handleSessionRegistration(event); From 4d740389f39230f1218e8dbd76dde41e7a7206bf Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 12 Feb 2026 16:18:20 +0100 Subject: [PATCH 7/7] fix: replace MIN() aggregate with list-based query in GameSessionRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hibernate doesn't support aggregate functions (MIN, MAX) on Duration fields in JPQL. The query 'SELECT MIN(gs.duration)' was causing ApplicationTests.contextLoads to fail with: FunctionArgumentException: Parameter 1 of function 'min()' has type 'COMPARABLE', but argument is of type 'java.time.Duration' Solution: - Removed findBestDuration() method from GameSessionRepository - Modified RecordNotificationService to use existing findDistinctDurationsOrderedAsc() and get the first element (best) - Updated all tests to mock List.of() instead of Optional.of() The approach is consistent with the previous Stream → List fix and avoids Hibernate aggregate function limitations. Ref #7 --- .../games/ldrbot/chat/RecordNotificationService.java | 12 +++++++++--- .../games/ldrbot/session/GameSessionRepository.java | 3 --- .../ldrbot/chat/RecordNotificationServiceTest.java | 7 ++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java index 2a6a8f2..a88ad06 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java @@ -41,15 +41,21 @@ void handleSessionRegistration(final GameSessionRegistrationEvent event) { Duration submittedDuration = event.getDuration(); Long chatId = event.getChatId(); - Optional bestDuration = gameSessionRepository.findBestDuration(chatId, event.getGameType()); + // Get all distinct durations ordered ascending (best first) + List distinctDurations = gameSessionRepository.findDistinctDurationsOrderedAsc(chatId, event.getGameType()); + + if (distinctDurations.isEmpty()) { + return; // No durations found (should not happen) + } + + Duration bestDuration = distinctDurations.get(0); // If the best duration in the DB is not the submitted one, this is not a record - if (bestDuration.isEmpty() || submittedDuration.compareTo(bestDuration.get()) != 0) { + if (submittedDuration.compareTo(bestDuration) != 0) { return; } // Find the previous best (second best duration after the current record) - List distinctDurations = gameSessionRepository.findDistinctDurationsOrderedAsc(chatId, event.getGameType()); Optional previousBest = distinctDurations.size() > 1 ? Optional.of(distinctDurations.get(1)) // Get the second one (previous best) : Optional.empty(); diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java index 2851b1a..ee0b41a 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java @@ -30,9 +30,6 @@ public interface GameSessionRepository extends JpaRepository @Transactional void deleteByUserIdAndGroupChatIdAndGameDay(Long UserId, Long chatId, LocalDate gameDay); - @Query("SELECT MIN(gs.duration) FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game") - Optional findBestDuration(@Param("chatId") Long chatId, @Param("game") GameType game); - long countByGroupChatIdAndGame(Long chatId, GameType game); @Query("SELECT DISTINCT gs.duration FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game ORDER BY gs.duration ASC") diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java index ae07485..27d81e1 100644 --- a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -48,7 +48,7 @@ private GameSessionRegistrationEvent createEvent(Duration duration) { void shouldNotNotifyWhenSubmittedDurationIsNotTheBest() { Duration submitted = Duration.ofSeconds(120); Duration best = Duration.ofSeconds(60); - when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(best)); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(best, submitted)); service.handleSessionRegistration(createEvent(submitted)); @@ -57,7 +57,7 @@ void shouldNotNotifyWhenSubmittedDurationIsNotTheBest() { @Test void shouldNotNotifyWhenNoBestDurationFound() { - when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.empty()); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of()); service.handleSessionRegistration(createEvent(Duration.ofSeconds(60))); @@ -67,7 +67,6 @@ void shouldNotNotifyWhenNoBestDurationFound() { @Test void shouldNotifyFirstRecordEver() { Duration submitted = Duration.ofSeconds(90); - when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted)); service.handleSessionRegistration(createEvent(submitted)); @@ -85,7 +84,6 @@ void shouldNotifyFirstRecordEver() { void shouldNotifyRecordBrokenWithPreviousBest() { Duration submitted = Duration.ofSeconds(45); Duration previous = Duration.ofSeconds(90); - when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted, previous)); service.handleSessionRegistration(createEvent(submitted)); @@ -104,7 +102,6 @@ void shouldFormatUserWithoutUsernameAsMention() { UserInfo noUsername = new UserInfo(42L, null, "John", "Doe"); Duration submitted = Duration.ofSeconds(30); GameSessionRegistrationEvent event = new GameSessionRegistrationEvent(this, CHAT_INFO, noUsername, GAME_INFO, GAME, submitted, LocalDate.now(), CHAT_ID); - when(gameSessionRepository.findBestDuration(CHAT_ID, GAME)).thenReturn(Optional.of(submitted)); when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted)); service.handleSessionRegistration(event);