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..a88ad06 --- /dev/null +++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java @@ -0,0 +1,81 @@ +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.List; +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(); + + // 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 (submittedDuration.compareTo(bestDuration) != 0) { + return; + } + + // Find the previous best (second best duration after the current record) + 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()); + 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..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 @@ -1,9 +1,13 @@ 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.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -25,4 +29,9 @@ public interface GameSessionRepository extends JpaRepository @Transactional void deleteByUserIdAndGroupChatIdAndGameDay(Long UserId, Long chatId, LocalDate gameDay); + + 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") + List findDistinctDurationsOrderedAsc(@Param("chatId") Long chatId, @Param("game") 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 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())); } 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() { } 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..27d81e1 --- /dev/null +++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java @@ -0,0 +1,115 @@ +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.List; +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.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(best, submitted)); + + service.handleSessionRegistration(createEvent(submitted)); + + verifyNoInteractions(customTelegramClient); + } + + @Test + void shouldNotNotifyWhenNoBestDurationFound() { + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of()); + + service.handleSessionRegistration(createEvent(Duration.ofSeconds(60))); + + verifyNoInteractions(customTelegramClient); + } + + @Test + void shouldNotifyFirstRecordEver() { + Duration submitted = Duration.ofSeconds(90); + when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted)); + + 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.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted, 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.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted)); + + 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")); + } +}