From e8e71b2f5e4058fd2de03f19eaf9c0600e8f5380 Mon Sep 17 00:00:00 2001 From: daimon Date: Sun, 15 Feb 2026 18:47:00 +0100 Subject: [PATCH 1/2] feat(#7): notify on chat when new record is established --- .../ldrbot/chat/NotificationService.java | 2 +- .../chat/RecordNotificationService.java | 66 +++++++ .../session/GameSessionRegistrationEvent.java | 4 +- .../ldrbot/session/GameSessionRepository.java | 3 + .../ldrbot/session/GameSessionService.java | 2 +- .../chat/RecordNotificationServiceTest.java | 161 ++++++++++++++++++ 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java create mode 100644 src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.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..3a8331c --- /dev/null +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java @@ -0,0 +1,66 @@ +package dev.rubasace.linkedin.games.ldrbot.chat; + +import dev.rubasace.linkedin.games.ldrbot.configuration.ExecutorsConfiguration; +import dev.rubasace.linkedin.games.ldrbot.session.GameSession; +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.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.Duration; +import java.util.List; + +@Service +public class RecordNotificationService { + + private static final String NEW_RECORD_MESSAGE = "🏆 New record! %s completed %s in %s, beating the previous record of %s!"; + private static final String FIRST_RECORD_MESSAGE = "🏆 New record! %s completed %s in %s!"; + + private final GameSessionRepository gameSessionRepository; + private final CustomTelegramClient customTelegramClient; + + RecordNotificationService(final GameSessionRepository gameSessionRepository, final CustomTelegramClient customTelegramClient) { + this.gameSessionRepository = gameSessionRepository; + this.customTelegramClient = customTelegramClient; + } + + @Order(NotificationService.USER_INTERACTION_NOTIFICATION_ORDER) + @Async(ExecutorsConfiguration.NOTIFICATION_LISTENER_EXECUTOR_NAME) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + void handleRecordNotification(final GameSessionRegistrationEvent event) { + List top2Sessions = gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc( + event.getChatId(), + event.getGameType() + ); + + if (top2Sessions.isEmpty()) { + return; + } + + GameSession bestSession = top2Sessions.get(0); + Duration bestDuration = bestSession.getDuration(); + + if (!bestDuration.equals(event.getDuration())) { + return; + } + + String userMention = FormatUtils.formatUserMention(event.getUserInfo()); + String gameName = event.getGameInfo().name(); + String formattedDuration = FormatUtils.formatDuration(bestDuration); + + String message; + if (top2Sessions.size() == 1) { + message = FIRST_RECORD_MESSAGE.formatted(userMention, gameName, formattedDuration); + } else { + Duration previousRecord = top2Sessions.get(1).getDuration(); + String formattedPreviousRecord = FormatUtils.formatDuration(previousRecord); + message = NEW_RECORD_MESSAGE.formatted(userMention, gameName, formattedDuration, formattedPreviousRecord); + } + + customTelegramClient.sendMessage(message, 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..79939dc 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 @@ -4,6 +4,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -25,4 +26,6 @@ public interface GameSessionRepository extends JpaRepository @Transactional void deleteByUserIdAndGroupChatIdAndGameDay(Long UserId, Long chatId, LocalDate gameDay); + + List findTop2ByGroupChatIdAndGameOrderByDurationAsc(Long chatId, GameType game); } 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 0857c0d..d7f2cd0 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 @@ -78,7 +78,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())); } 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..92cebd7 --- /dev/null +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -0,0 +1,161 @@ +package dev.rubasace.linkedin.games.ldrbot.chat; + +import dev.rubasace.linkedin.games.ldrbot.group.ChatInfo; +import dev.rubasace.linkedin.games.ldrbot.session.GameInfo; +import dev.rubasace.linkedin.games.ldrbot.session.GameSession; +import dev.rubasace.linkedin.games.ldrbot.session.GameSessionRegistrationEvent; +import dev.rubasace.linkedin.games.ldrbot.session.GameSessionRepository; +import dev.rubasace.linkedin.games.ldrbot.session.GameType; +import dev.rubasace.linkedin.games.ldrbot.user.UserInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RecordNotificationServiceTest { + + @Mock + private GameSessionRepository gameSessionRepository; + + @Mock + private CustomTelegramClient customTelegramClient; + + @InjectMocks + private RecordNotificationService recordNotificationService; + + @Test + void shouldNotNotifyWhenNotARecord() { + GameSessionRegistrationEvent event = createEvent(Duration.ofMinutes(2)); + GameSession bestSession = createGameSession(Duration.ofMinutes(1)); + + when(gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc(anyLong(), eq(GameType.QUEENS))) + .thenReturn(List.of(bestSession)); + + recordNotificationService.handleRecordNotification(event); + + verify(customTelegramClient, never()).sendMessage(org.mockito.ArgumentMatchers.anyString(), anyLong()); + } + + @Test + void shouldNotifyWhenFirstRecord() { + Duration recordDuration = Duration.ofMinutes(1); + GameSessionRegistrationEvent event = createEvent(recordDuration); + GameSession bestSession = createGameSession(recordDuration); + + when(gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc(anyLong(), eq(GameType.QUEENS))) + .thenReturn(List.of(bestSession)); + + recordNotificationService.handleRecordNotification(event); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(123L)); + + String message = messageCaptor.getValue(); + assertTrue(message.contains("🏆")); + assertTrue(message.contains("New record")); + assertTrue(message.contains("@testuser")); + assertTrue(message.contains("Queens")); + assertTrue(message.contains("01:00")); + assertFalse(message.contains("beating")); + } + + @Test + void shouldNotifyWhenBrokenRecord() { + Duration recordDuration = Duration.ofMinutes(1); + Duration previousRecord = Duration.ofMinutes(2); + GameSessionRegistrationEvent event = createEvent(recordDuration); + GameSession bestSession = createGameSession(recordDuration); + GameSession secondBestSession = createGameSession(previousRecord); + + when(gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc(anyLong(), eq(GameType.QUEENS))) + .thenReturn(List.of(bestSession, secondBestSession)); + + recordNotificationService.handleRecordNotification(event); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(123L)); + + String message = messageCaptor.getValue(); + assertTrue(message.contains("🏆")); + assertTrue(message.contains("New record")); + assertTrue(message.contains("@testuser")); + assertTrue(message.contains("Queens")); + assertTrue(message.contains("01:00")); + assertTrue(message.contains("beating")); + assertTrue(message.contains("02:00")); + } + + @Test + void shouldHandleUserWithoutUsername() { + Duration recordDuration = Duration.ofMinutes(1); + UserInfo userWithoutUsername = new UserInfo(456L, null, "John", "Doe"); + GameSessionRegistrationEvent event = createEventWithUser(recordDuration, userWithoutUsername); + GameSession bestSession = createGameSession(recordDuration); + + when(gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc(anyLong(), eq(GameType.QUEENS))) + .thenReturn(List.of(bestSession)); + + recordNotificationService.handleRecordNotification(event); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(123L)); + + String message = messageCaptor.getValue(); + assertTrue(message.contains("")); + assertTrue(message.contains("John Doe")); + } + + @Test + void shouldNotNotifyWhenNoSessions() { + GameSessionRegistrationEvent event = createEvent(Duration.ofMinutes(1)); + + when(gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc(anyLong(), eq(GameType.QUEENS))) + .thenReturn(Collections.emptyList()); + + recordNotificationService.handleRecordNotification(event); + + verify(customTelegramClient, never()).sendMessage(org.mockito.ArgumentMatchers.anyString(), anyLong()); + } + + private GameSessionRegistrationEvent createEvent(final Duration duration) { + UserInfo userInfo = new UserInfo(456L, "testuser", "Test", "User"); + return createEventWithUser(duration, userInfo); + } + + private GameSessionRegistrationEvent createEventWithUser(final Duration duration, final UserInfo userInfo) { + ChatInfo chatInfo = new ChatInfo(123L, "Test Group", true); + GameInfo gameInfo = new GameInfo("Queens", "👑"); + return new GameSessionRegistrationEvent( + this, + chatInfo, + userInfo, + gameInfo, + GameType.QUEENS, + duration, + LocalDate.now(), + 123L + ); + } + + private GameSession createGameSession(final Duration duration) { + GameSession session = new GameSession(); + session.setDuration(duration); + return session; + } +} From 031aeade21095d89bdaf1af499e24748cbc709a3 Mon Sep 17 00:00:00 2001 From: Daimon Date: Tue, 24 Feb 2026 17:10:13 +0100 Subject: [PATCH 2/2] refactor: include GameType in GameInfo to eliminate redundancy GameInfo now contains the GameType as its first field, eliminating the need to pass both gameInfo and gameType separately to events and other components. Changes: - Updated GameInfo record to include GameType as first parameter - Modified GameTypeAdapter to populate type field - Simplified GameSessionRegistrationEvent constructor - Updated all event consumers to use gameInfo.type() - Fixed test fixtures to use new GameInfo constructor --- .../linkedin/games/ldrbot/chat/RecordNotificationService.java | 2 +- .../dev/rubasace/linkedin/games/ldrbot/session/GameInfo.java | 2 +- .../games/ldrbot/session/GameSessionRegistrationEvent.java | 4 +--- .../linkedin/games/ldrbot/session/GameSessionService.java | 2 +- .../linkedin/games/ldrbot/session/GameTypeAdapter.java | 2 +- .../games/ldrbot/chat/RecordNotificationServiceTest.java | 3 +-- 6 files changed, 6 insertions(+), 9 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 3a8331c..327b2a3 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 @@ -34,7 +34,7 @@ public class RecordNotificationService { void handleRecordNotification(final GameSessionRegistrationEvent event) { List top2Sessions = gameSessionRepository.findTop2ByGroupChatIdAndGameOrderByDurationAsc( event.getChatId(), - event.getGameType() + event.getGameInfo().type() ); if (top2Sessions.isEmpty()) { diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameInfo.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameInfo.java index 5eaef5d..2ddb025 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameInfo.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameInfo.java @@ -1,5 +1,5 @@ package dev.rubasace.linkedin.games.ldrbot.session; -public record GameInfo(String name, String icon) { +public record GameInfo(GameType type, String name, String icon) { } 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 581dd16..c44f0b5 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,17 +14,15 @@ 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 GameType gameType, final Duration duration, final LocalDate gameDay, 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) { 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/GameSessionService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java index d7f2cd0..0857c0d 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 @@ -78,7 +78,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.getGame(), gameSession.getDuration(), gameDay, + applicationEventPublisher.publishEvent(new GameSessionRegistrationEvent(this, chatInfo, userInfo, gameInfo, gameSession.getDuration(), gameDay, telegramGroup.getChatId())); } diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameTypeAdapter.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameTypeAdapter.java index 6a2f277..598d947 100644 --- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameTypeAdapter.java +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameTypeAdapter.java @@ -7,7 +7,7 @@ public class GameTypeAdapter { public GameInfo adapt(final GameType gameType) { - return new GameInfo(StringUtils.capitalize(gameType.name().toLowerCase()), gameIcon(gameType)); + return new GameInfo(gameType, StringUtils.capitalize(gameType.name().toLowerCase()), gameIcon(gameType)); } private static String gameIcon(final GameType gameType) { 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 92cebd7..6c2d653 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 @@ -140,13 +140,12 @@ private GameSessionRegistrationEvent createEvent(final Duration duration) { private GameSessionRegistrationEvent createEventWithUser(final Duration duration, final UserInfo userInfo) { ChatInfo chatInfo = new ChatInfo(123L, "Test Group", true); - GameInfo gameInfo = new GameInfo("Queens", "👑"); + GameInfo gameInfo = new GameInfo(GameType.QUEENS, "Queens", "👑"); return new GameSessionRegistrationEvent( this, chatInfo, userInfo, gameInfo, - GameType.QUEENS, duration, LocalDate.now(), 123L