Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the reason for this change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The visibility was changed from private to package-private so that RecordNotificationService (in the same package) can reference USER_INTERACTION_NOTIFICATION_ORDER to define its own RECORD_NOTIFICATION_ORDER = USER_INTERACTION_NOTIFICATION_ORDER + 1. This ensures the record notification is sent after the user interaction notification, maintaining a consistent ordering.

private static final int DAILY_RANKING_NOTIFICATION_ORDER = 0;

private final CustomTelegramClient customTelegramClient;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <b>New %s record!</b>\n%s set a new group record with a time of <b>%s</b>! \uD83C\uDF89";
private static final String RECORD_BROKEN_MESSAGE_TEMPLATE = "\uD83C\uDFC6 <b>New %s record!</b>\n%s set a new group record with a time of <b>%s</b> (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<Duration> 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<Duration> 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()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,4 +29,9 @@ public interface GameSessionRepository extends JpaRepository<GameSession, UUID>

@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<Duration> findDistinctDurationsOrderedAsc(@Param("chatId") Long chatId, @Param("game") GameType game);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +12,9 @@ class ApplicationTests {
@MockitoBean
private TelegramBotInitializer telegramBotInitializer;

@MockitoBean
private CustomTelegramClient customTelegramClient;

@Test
void contextLoads() {
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> 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"));
}
}