From 26aeac54892a9defaf3954f4c15ac9b2083d78d3 Mon Sep 17 00:00:00 2001 From: Havlli Date: Wed, 1 Jul 2026 22:06:56 +0200 Subject: [PATCH 1/2] test: harden Discord interaction coverage --- .../command/EventSignupCommandTest.java | 155 ++++++++---------- .../onreadyevent/ScheduledTaskTest.java | 75 ++++++++- .../core/GlobalCommandRegistrarTest.java | 53 ++++++ .../entity/event/EventRepositoryIT.java | 30 ++-- 4 files changed, 200 insertions(+), 113 deletions(-) diff --git a/src/test/java/com/github/havlli/EventPilot/command/EventSignupCommandTest.java b/src/test/java/com/github/havlli/EventPilot/command/EventSignupCommandTest.java index 337a1d1..51558c7 100644 --- a/src/test/java/com/github/havlli/EventPilot/command/EventSignupCommandTest.java +++ b/src/test/java/com/github/havlli/EventPilot/command/EventSignupCommandTest.java @@ -21,7 +21,11 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.springframework.context.MessageSource; import reactor.core.publisher.Mono; @@ -64,26 +68,19 @@ void tearDown() throws Exception { autoCloseable.close(); } - @Test - void handle_ReturnsEmptyMono_WhenButtonIdIsNotSignupId() { - // Arrange - when(buttonEvent.getCustomId()).thenReturn("confirm"); - - // Act - Mono actual = underTest.handle(buttonEvent) - .cast(Message.class); - - // Assert - StepVerifier.create(actual) - .expectSubscription() - .verifyComplete(); - verifyNoInteractions(eventSignupService); - } - - @Test - void handle_ReturnsEmptyMono_WhenCommaButtonIdIsNotSignupId() { + @ParameterizedTest + @ValueSource(strings = { + "confirm", + "confirm,1", + "12345", + "12345,", + "12345,role", + "12345,1,2", + ",1" + }) + void handle_ReturnsEmptyMono_WhenButtonIdIsNotSignupId(String customId) { // Arrange - when(buttonEvent.getCustomId()).thenReturn("confirm,1"); + when(buttonEvent.getCustomId()).thenReturn(customId); // Act Mono actual = underTest.handle(buttonEvent) @@ -96,18 +93,15 @@ void handle_ReturnsEmptyMono_WhenCommaButtonIdIsNotSignupId() { verifyNoInteractions(eventSignupService); } - @Test - void handle_AppliesSignupAndEditsReply_WhenSignupIsSuccessful() { + @ParameterizedTest + @EnumSource(value = EventSignupResult.Outcome.class, names = {"ADDED", "UPDATED", "WAITLISTED"}) + void handle_AppliesSignupAndEditsReply_WhenSignupUpdatesEventMessage(EventSignupResult.Outcome outcome) { // Arrange Event event = createEvent(); Message message = mock(Message.class); - when(buttonEvent.getCustomId()).thenReturn("12345,1"); - when(buttonEvent.getInteraction()).thenReturn(interaction); - when(interaction.getUser()).thenReturn(user); - when(user.getId()).thenReturn(Snowflake.of("999")); - when(user.getUsername()).thenReturn("player"); + stubSignupRequest(); when(eventSignupService.applySignup("12345", "999", "player", 1)) - .thenReturn(EventSignupResult.added(event)); + .thenReturn(signupResult(outcome, event)); when(buttonEvent.deferEdit()).thenReturn(InteractionCallbackSpecDeferEditMono.of(buttonEvent)); when(buttonEvent.deferEdit(any(InteractionCallbackSpec.class))).thenReturn(Mono.empty()); when(buttonEvent.editReply(any(InteractionReplyEditSpec.class))).thenReturn(Mono.just(message)); @@ -125,20 +119,27 @@ void handle_AppliesSignupAndEditsReply_WhenSignupIsSuccessful() { verify(eventSignupService, times(1)).applySignup("12345", "999", "player", 1); verify(embedGenerator, times(1)).generateEmbed(event); verify(embedGenerator, times(1)).generateComponents(event); + verifyNoInteractions(messageSource); } - @Test - void handle_RepliesEphemerally_WhenEventIsFull() { + @ParameterizedTest + @EnumSource(value = EventSignupResult.Outcome.class, names = { + "EVENT_FULL", + "EVENT_NOT_FOUND", + "ROLE_NOT_FOUND", + "INVALID_CAPACITY", + "EVENT_CLOSED", + "EVENT_CANCELLED", + "EVENT_EXPIRED" + }) + void handle_RepliesEphemerally_WhenSignupIsBlocked(EventSignupResult.Outcome outcome) { // Arrange - when(buttonEvent.getCustomId()).thenReturn("12345,1"); - when(buttonEvent.getInteraction()).thenReturn(interaction); - when(interaction.getUser()).thenReturn(user); - when(user.getId()).thenReturn(Snowflake.of("999")); - when(user.getUsername()).thenReturn("player"); + String messageKey = messageKeyFor(outcome); + String message = "message for " + outcome; + stubSignupRequest(); when(eventSignupService.applySignup("12345", "999", "player", 1)) - .thenReturn(EventSignupResult.withoutEvent(EventSignupResult.Outcome.EVENT_FULL)); - when(messageSource.getMessage("interaction.signup.event-full", null, Locale.ENGLISH)) - .thenReturn("This event is already full."); + .thenReturn(EventSignupResult.withoutEvent(outcome)); + when(messageSource.getMessage(messageKey, null, Locale.ENGLISH)).thenReturn(message); when(buttonEvent.reply()).thenReturn(InteractionApplicationCommandCallbackReplyMono.of(buttonEvent)); when(buttonEvent.reply(any(InteractionApplicationCommandCallbackSpec.class))).thenReturn(Mono.empty()); @@ -149,70 +150,48 @@ void handle_RepliesEphemerally_WhenEventIsFull() { StepVerifier.create(actual) .expectSubscription() .verifyComplete(); - verify(messageSource, times(1)).getMessage("interaction.signup.event-full", null, Locale.ENGLISH); - verify(buttonEvent, times(1)).reply(any(InteractionApplicationCommandCallbackSpec.class)); + verify(eventSignupService, times(1)).applySignup("12345", "999", "player", 1); + verify(messageSource, times(1)).getMessage(messageKey, null, Locale.ENGLISH); + verify(buttonEvent, times(1)).reply(Mockito.argThat(spec -> + spec.ephemeral().toOptional().orElse(false) + && spec.content().toOptional().orElse("").equals(message) + )); + verifyNoInteractions(embedGenerator); } @Test - void handle_AppliesSignupAndEditsReply_WhenSignupIsWaitlisted() { - // Arrange - Event event = createEvent(); - Message message = mock(Message.class); - when(buttonEvent.getCustomId()).thenReturn("12345,1"); - when(buttonEvent.getInteraction()).thenReturn(interaction); - when(interaction.getUser()).thenReturn(user); - when(user.getId()).thenReturn(Snowflake.of("999")); - when(user.getUsername()).thenReturn("player"); - when(eventSignupService.applySignup("12345", "999", "player", 1)) - .thenReturn(EventSignupResult.waitlisted(event)); - when(buttonEvent.deferEdit()).thenReturn(InteractionCallbackSpecDeferEditMono.of(buttonEvent)); - when(buttonEvent.deferEdit(any(InteractionCallbackSpec.class))).thenReturn(Mono.empty()); - when(buttonEvent.editReply(any(InteractionReplyEditSpec.class))).thenReturn(Mono.just(message)); - when(embedGenerator.generateEmbed(event)).thenReturn(EmbedCreateSpec.builder().build()); - when(embedGenerator.generateComponents(event)).thenReturn(List.of()); - - // Act - Mono actual = underTest.handle(buttonEvent) - .cast(Message.class); - - // Assert - StepVerifier.create(actual) - .expectNext(message) - .verifyComplete(); - verify(eventSignupService, times(1)).applySignup("12345", "999", "player", 1); - verify(embedGenerator, times(1)).generateEmbed(event); - verify(embedGenerator, times(1)).generateComponents(event); + void getName_ReturnsEventSignup() { + assertThat(underTest.getName()).isEqualTo("event-signup"); } - @Test - void handle_RepliesEphemerally_WhenEventIsClosed() { - // Arrange + private void stubSignupRequest() { when(buttonEvent.getCustomId()).thenReturn("12345,1"); when(buttonEvent.getInteraction()).thenReturn(interaction); when(interaction.getUser()).thenReturn(user); when(user.getId()).thenReturn(Snowflake.of("999")); when(user.getUsername()).thenReturn("player"); - when(eventSignupService.applySignup("12345", "999", "player", 1)) - .thenReturn(EventSignupResult.withoutEvent(EventSignupResult.Outcome.EVENT_CLOSED)); - when(messageSource.getMessage("interaction.signup.event-closed", null, Locale.ENGLISH)) - .thenReturn("This event is closed."); - when(buttonEvent.reply()).thenReturn(InteractionApplicationCommandCallbackReplyMono.of(buttonEvent)); - when(buttonEvent.reply(any(InteractionApplicationCommandCallbackSpec.class))).thenReturn(Mono.empty()); - - // Act - Mono actual = underTest.handle(buttonEvent); + } - // Assert - StepVerifier.create(actual) - .expectSubscription() - .verifyComplete(); - verify(messageSource, times(1)).getMessage("interaction.signup.event-closed", null, Locale.ENGLISH); - verify(buttonEvent, times(1)).reply(any(InteractionApplicationCommandCallbackSpec.class)); + private EventSignupResult signupResult(EventSignupResult.Outcome outcome, Event event) { + return switch (outcome) { + case ADDED -> EventSignupResult.added(event); + case UPDATED -> EventSignupResult.updated(event); + case WAITLISTED -> EventSignupResult.waitlisted(event); + default -> throw new IllegalArgumentException("Unexpected success outcome: " + outcome); + }; } - @Test - void getName_ReturnsEventSignup() { - assertThat(underTest.getName()).isEqualTo("event-signup"); + private String messageKeyFor(EventSignupResult.Outcome outcome) { + return switch (outcome) { + case EVENT_FULL -> "interaction.signup.event-full"; + case EVENT_NOT_FOUND -> "interaction.signup.event-not-found"; + case ROLE_NOT_FOUND -> "interaction.signup.role-not-found"; + case INVALID_CAPACITY -> "interaction.signup.invalid-capacity"; + case EVENT_CLOSED -> "interaction.signup.event-closed"; + case EVENT_CANCELLED -> "interaction.signup.event-cancelled"; + case EVENT_EXPIRED -> "interaction.signup.event-expired"; + default -> throw new IllegalArgumentException("Unexpected blocked outcome: " + outcome); + }; } private Event createEvent() { diff --git a/src/test/java/com/github/havlli/EventPilot/command/onreadyevent/ScheduledTaskTest.java b/src/test/java/com/github/havlli/EventPilot/command/onreadyevent/ScheduledTaskTest.java index 22fe47e..357573f 100644 --- a/src/test/java/com/github/havlli/EventPilot/command/onreadyevent/ScheduledTaskTest.java +++ b/src/test/java/com/github/havlli/EventPilot/command/onreadyevent/ScheduledTaskTest.java @@ -9,6 +9,7 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.test.StepVerifier; import reactor.test.scheduler.VirtualTimeScheduler; @@ -49,14 +50,11 @@ public void tearDown() throws Exception { } @Test - public void getFlux_InvokesServicesTwiceIn13Seconds_WhenIntervalIs5Seconds() { + public void getFlux_InvokesExpiryAndReminderServicesTwiceIn13Seconds_WhenIntervalIs5Seconds() { // Arrange List expiredEvents = new ArrayList<>(); List reminderEvents = new ArrayList<>(); - when(eventServiceMock.getExpiredEvents()).thenReturn(expiredEvents); - when(discordServiceMock.deactivateEvents(expiredEvents)).thenReturn(Flux.empty()); - when(eventServiceMock.getReminderCandidates(Duration.ofMinutes(60))).thenReturn(reminderEvents); - when(discordServiceMock.sendEventReminders(reminderEvents)).thenReturn(Flux.empty()); + stubScheduledCycle(expiredEvents, reminderEvents); // Assert StepVerifier.withVirtualTime( @@ -75,4 +73,71 @@ public void getFlux_InvokesServicesTwiceIn13Seconds_WhenIntervalIs5Seconds() { verify(discordServiceMock, times(2)).sendEventReminders(reminderEvents); } + @Test + public void getFlux_SwallowsTransientCycleErrorAndContinuesOnNextTick() { + // Arrange + List expiredEvents = new ArrayList<>(); + List reminderEvents = new ArrayList<>(); + when(eventServiceMock.getExpiredEvents()) + .thenThrow(new RuntimeException("database unavailable")) + .thenReturn(expiredEvents); + when(discordServiceMock.deactivateEvents(expiredEvents)).thenReturn(Flux.empty()); + when(eventServiceMock.getReminderCandidates(Duration.ofMinutes(60))).thenReturn(reminderEvents); + when(discordServiceMock.sendEventReminders(reminderEvents)).thenReturn(Flux.empty()); + + // Assert + StepVerifier.withVirtualTime( + underTest::getSchedulersFlux, + () -> virtualTimeScheduler, + Long.MAX_VALUE + ) + .expectSubscription() + .thenAwait(Duration.ofSeconds(11)) + .thenCancel() + .verify(); + + verify(eventServiceMock, times(2)).getExpiredEvents(); + verify(discordServiceMock, times(1)).deactivateEvents(expiredEvents); + verify(eventServiceMock, times(1)).getReminderCandidates(Duration.ofMinutes(60)); + verify(discordServiceMock, times(1)).sendEventReminders(reminderEvents); + } + + @Test + public void start_IsIdempotentAndStopDisposesSchedulerSubscription() { + // Arrange + List expiredEvents = new ArrayList<>(); + List reminderEvents = new ArrayList<>(); + stubScheduledCycle(expiredEvents, reminderEvents); + + // Act + Mono firstStart = underTest.start(); + Mono secondStart = underTest.start(); + + // Assert + StepVerifier.create(firstStart) + .verifyComplete(); + StepVerifier.create(secondStart) + .verifyComplete(); + + virtualTimeScheduler.advanceTimeBy(Duration.ofSeconds(5)); + verify(eventServiceMock, times(1)).getExpiredEvents(); + + underTest.stop(); + virtualTimeScheduler.advanceTimeBy(Duration.ofSeconds(15)); + verify(eventServiceMock, times(1)).getExpiredEvents(); + + StepVerifier.create(underTest.start()) + .verifyComplete(); + virtualTimeScheduler.advanceTimeBy(Duration.ofSeconds(5)); + verify(eventServiceMock, times(2)).getExpiredEvents(); + + underTest.stop(); + } + + private void stubScheduledCycle(List expiredEvents, List reminderEvents) { + when(eventServiceMock.getExpiredEvents()).thenReturn(expiredEvents); + when(discordServiceMock.deactivateEvents(expiredEvents)).thenReturn(Flux.empty()); + when(eventServiceMock.getReminderCandidates(Duration.ofMinutes(60))).thenReturn(reminderEvents); + when(discordServiceMock.sendEventReminders(reminderEvents)).thenReturn(Flux.empty()); + } } diff --git a/src/test/java/com/github/havlli/EventPilot/core/GlobalCommandRegistrarTest.java b/src/test/java/com/github/havlli/EventPilot/core/GlobalCommandRegistrarTest.java index a400b2c..fa7aaf6 100644 --- a/src/test/java/com/github/havlli/EventPilot/core/GlobalCommandRegistrarTest.java +++ b/src/test/java/com/github/havlli/EventPilot/core/GlobalCommandRegistrarTest.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import discord4j.common.JacksonResources; +import discord4j.discordjson.json.ApplicationCommandOptionChoiceData; +import discord4j.discordjson.json.ApplicationCommandOptionData; import discord4j.discordjson.json.ApplicationCommandRequest; import discord4j.rest.RestClient; import discord4j.rest.service.ApplicationService; @@ -20,6 +22,9 @@ import java.io.IOException; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -70,6 +75,9 @@ void run_ShouldBulkOverwriteGlobalApplicationCommands() throws IOException { .bulkOverwriteGlobalApplicationCommand(eq(123L), anyList()); List capturedCommands = commandsCaptor.getValue(); + Map commandsByName = capturedCommands.stream() + .collect(Collectors.toMap(ApplicationCommandRequest::name, command -> command)); + assertThat(capturedCommands) .extracting(ApplicationCommandRequest::name) .containsExactlyInAnyOrder( @@ -83,8 +91,15 @@ void run_ShouldBulkOverwriteGlobalApplicationCommands() throws IOException { "list-events", "reopen-event" ); + assertThat(commandsByName).hasSize(9); assertThat(capturedCommands) .allSatisfy(command -> assertThat(command.defaultMemberPermissions()).contains("16")); + assertMessageIdOption(commandsByName.get("cancel-event")); + assertMessageIdOption(commandsByName.get("close-event")); + assertMessageIdOption(commandsByName.get("delete-event")); + assertMessageIdOption(commandsByName.get("event-info")); + assertMessageIdOption(commandsByName.get("reopen-event")); + assertListEventsOptions(commandsByName.get("list-events")); } @Test @@ -146,4 +161,42 @@ private DiscordProperties discordProperties(String commandsFolder) { new DiscordProperties.Scheduler(60, 60) ); } + + private void assertMessageIdOption(ApplicationCommandRequest command) { + ApplicationCommandOptionData option = findOption(command, "message-id"); + + assertThat(option.type()).isEqualTo(3); + assertThat(option.required().toOptional()).contains(true); + assertThat(option.description()).isEqualTo("Discord message ID of the event signup"); + } + + private void assertListEventsOptions(ApplicationCommandRequest command) { + ApplicationCommandOptionData statusOption = findOption(command, "status"); + ApplicationCommandOptionData limitOption = findOption(command, "limit"); + + assertThat(statusOption.type()).isEqualTo(3); + assertThat(statusOption.required().toOptional()).contains(false); + assertThat(statusOption.choices().toOptional().orElse(List.of())) + .extracting(ApplicationCommandOptionChoiceData::value) + .containsExactly("active", "open", "closed", "cancelled", "expired", "all"); + + assertThat(limitOption.type()).isEqualTo(4); + assertThat(limitOption.required().toOptional()).contains(false); + assertThat(limitOption.minValue().toOptional()).contains(1.0); + assertThat(limitOption.maxValue().toOptional()).contains(10.0); + } + + private ApplicationCommandOptionData findOption(ApplicationCommandRequest command, String optionName) { + assertThat(command).isNotNull(); + List options = command.options().toOptional().orElse(List.of()); + Set optionNames = options.stream() + .map(ApplicationCommandOptionData::name) + .collect(Collectors.toSet()); + + assertThat(optionNames).contains(optionName); + return options.stream() + .filter(option -> option.name().equals(optionName)) + .findFirst() + .orElseThrow(); + } } diff --git a/src/test/java/com/github/havlli/EventPilot/entity/event/EventRepositoryIT.java b/src/test/java/com/github/havlli/EventPilot/entity/event/EventRepositoryIT.java index 114278b..14f6306 100644 --- a/src/test/java/com/github/havlli/EventPilot/entity/event/EventRepositoryIT.java +++ b/src/test/java/com/github/havlli/EventPilot/entity/event/EventRepositoryIT.java @@ -33,7 +33,6 @@ class EventRepositoryIT extends TestDatabaseContainer { private static final Logger LOG = LoggerFactory.getLogger(EventRepositoryIT.class); - private static final int TIMESTAMP_CLOCK_SKEW_TOLERANCE_SECONDS = 2; @Autowired private GuildRepository guildRepository; @Autowired @@ -55,31 +54,22 @@ public void setUp() { } @Test - public void jpaQueryCurrentTimestamp_SatisfiesExecutionTimeTolerance() throws SQLException { - // Arrange - Instant beforeQuery = Instant.now(); - + public void jpaQueryCurrentTimestamp_ReturnsStableTransactionTimestamp() throws SQLException { // Act - Instant actualJdbcQuery = timeTester.getCurrentTimestampUsingJdbc(); Instant actualNativeQuery = timeTester.getCurrentTimestampUsingPersistence(); Instant actualJPQLQuery = testRepository.selectCurrentTimestamp(); - Instant afterQuery = Instant.now(); + Instant actualSecondNativeQuery = timeTester.getCurrentTimestampUsingPersistence(); // Assert - assertTimestampWithinQueryWindow(actualNativeQuery, beforeQuery, afterQuery); - assertTimestampWithinQueryWindow(actualJPQLQuery, beforeQuery, afterQuery); - assertTimestampWithinQueryWindow(actualJdbcQuery, beforeQuery, afterQuery); - } - - private static void assertTimestampWithinQueryWindow(Instant actual, Instant beforeQuery, Instant afterQuery) { - assertThat(actual).isAfterOrEqualTo(beforeQuery.minus(TIMESTAMP_CLOCK_SKEW_TOLERANCE_SECONDS, ChronoUnit.SECONDS)); - assertThat(actual).isBeforeOrEqualTo(afterQuery.plus(TIMESTAMP_CLOCK_SKEW_TOLERANCE_SECONDS, ChronoUnit.SECONDS)); + assertThat(actualJPQLQuery).isEqualTo(actualNativeQuery); + assertThat(actualSecondNativeQuery).isEqualTo(actualNativeQuery); + assertThat(timeTester.getCurrentTimestampUsingJdbc()).isNotNull(); } @Test public void findAllWithDatetimeBeforeCurrentTime_ReturnsListOfExpiredEvents_WhenOffsetOneMinute() { // Arrange - Instant instantNow = timeTester.getInstantNowFromSystem(); + Instant instantNow = timeTester.getCurrentTimestampUsingPersistence(); Guild guild = new Guild("1", "guild"); @@ -150,7 +140,7 @@ public void findAllWithDatetimeBeforeCurrentTime_ReturnsListOfExpiredEvents_When @Test public void findAllWithDatetimeBeforeCurrentTime_ReturnsListOfExpiredEvents_WhenOffsetThreeSeconds() throws SQLException { // Arrange - Instant instantNow = timeTester.getInstantNowFromSystem(); + Instant instantNow = timeTester.getCurrentTimestampUsingPersistence(); Guild guild = new Guild("1", "guild"); @@ -221,7 +211,7 @@ public void findAllWithDatetimeBeforeCurrentTime_ReturnsListOfExpiredEvents_When @Test public void findAllWithDatetimeBeforeCurrentTime_ReturnsListOfExpiredEvents_WhenOffsetTwoSeconds() throws SQLException { // Arrange - Instant instantNow = timeTester.getInstantNowFromSystem(); + Instant instantNow = timeTester.getCurrentTimestampUsingPersistence(); Guild guild = new Guild("1", "guild"); @@ -291,7 +281,7 @@ public void findAllWithDatetimeBeforeCurrentTime_ReturnsListOfExpiredEvents_When @Test public void findAllWithDatetimeBeforeCurrentTime_ReturnsOnlyOpenAndClosedExpiredEvents() { // Arrange - Instant instantNow = timeTester.getInstantNowFromSystem(); + Instant instantNow = timeTester.getCurrentTimestampUsingPersistence(); Guild guild = new Guild("1", "guild"); Instant expiredDateTime = instantNow.minus(1, ChronoUnit.MINUTES); @@ -374,7 +364,7 @@ public void findAllWithDatetimeBeforeCurrentTime_ReturnsOnlyOpenAndClosedExpired @Test public void findReminderCandidates_ReturnsOpenAndClosedUnremindedFutureEventsInsideCutoff() { // Arrange - Instant instantNow = timeTester.getInstantNowFromSystem(); + Instant instantNow = timeTester.getCurrentTimestampUsingPersistence(); Instant reminderCutoff = instantNow.plus(60, ChronoUnit.MINUTES); Guild guild = new Guild("1", "guild"); From b505a8ee4532b4848a5d7490726c1b3ab0e7d1f2 Mon Sep 17 00:00:00 2001 From: Havlli Date: Wed, 1 Jul 2026 22:35:00 +0200 Subject: [PATCH 2/2] docs: add live smoke preflight --- docs/live-smoke-test.md | 15 +++- scripts/live-smoke-preflight.sh | 150 ++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100755 scripts/live-smoke-preflight.sh diff --git a/docs/live-smoke-test.md b/docs/live-smoke-test.md index a671d25..ea837c4 100644 --- a/docs/live-smoke-test.md +++ b/docs/live-smoke-test.md @@ -30,6 +30,17 @@ DISCORD_SCHEDULER_INTERVAL_SECONDS=10 DISCORD_REMINDER_LEAD_MINUTES=5 ``` +## Preflight + +Run the local preflight before starting the bot: + +```shell +scripts/live-smoke-preflight.sh +``` + +The script checks Docker, `.env`, required variable names, and command resources. It prints key names +only and never prints secret values. + ## Startup 1. Start the app and dependencies: @@ -200,8 +211,8 @@ Expected result: Run after the live pass: ```shell -mvn -ntp -Dmaven.repo.local=.m2/repository test -mvn -ntp -Dmaven.repo.local=.m2/repository verify +mise exec -- mvn -ntp -Dmaven.repo.local=.m2/repository test +mise exec -- mvn -ntp -Dmaven.repo.local=.m2/repository verify ``` `verify` requires Docker for Testcontainers. diff --git a/scripts/live-smoke-preflight.sh b/scripts/live-smoke-preflight.sh new file mode 100755 index 0000000..97f7c92 --- /dev/null +++ b/scripts/live-smoke-preflight.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash + +set -u + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env" +COMMAND_DIR="${ROOT_DIR}/src/main/resources/commands" +FAILURES=0 + +required_keys=( + "DISCORD_BOT_TOKEN" + "JWT_SECRET" + "POSTGRES_DB" + "POSTGRES_USER" + "POSTGRES_PASSWORD" +) + +optional_keys=( + "DISCORD_SCHEDULER_INTERVAL_SECONDS" + "DISCORD_REMINDER_LEAD_MINUTES" +) + +pass() { + printf '[ok] %s\n' "$1" +} + +warn() { + printf '[warn] %s\n' "$1" +} + +fail() { + printf '[fail] %s\n' "$1" + FAILURES=$((FAILURES + 1)) +} + +env_line_for() { + local key="$1" + + if [ ! -f "$ENV_FILE" ]; then + return 1 + fi + + grep -E "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$ENV_FILE" | tail -n 1 +} + +has_non_empty_env_value() { + local key="$1" + local line + + line="$(env_line_for "$key" || true)" + if [ -z "$line" ]; then + return 1 + fi + + printf '%s' "$line" | grep -Eq '=[[:space:]]*[^[:space:]#]' +} + +check_docker() { + if command -v docker >/dev/null 2>&1; then + pass "Docker CLI is installed" + else + fail "Docker CLI is not installed" + return + fi + + if docker info >/dev/null 2>&1; then + pass "Docker daemon is reachable" + else + fail "Docker daemon is not reachable; start Docker Desktop" + fi +} + +check_env_file() { + if [ -f "$ENV_FILE" ]; then + pass ".env exists" + else + fail ".env is missing at ${ENV_FILE}" + fi + + if git -C "$ROOT_DIR" check-ignore -q .env; then + pass ".env is ignored by git" + else + warn ".env is not ignored by git" + fi + + if git -C "$ROOT_DIR" ls-files --error-unmatch .env >/dev/null 2>&1; then + fail ".env is tracked by git" + else + pass ".env is not tracked by git" + fi +} + +check_env_keys() { + local key + + for key in "${required_keys[@]}"; do + if has_non_empty_env_value "$key"; then + pass "required key is set: ${key}" + else + fail "required key is missing or empty: ${key}" + fi + done + + for key in "${optional_keys[@]}"; do + if has_non_empty_env_value "$key"; then + pass "optional key is set: ${key}" + else + warn "optional fast-smoke key is not set: ${key}" + fi + done +} + +check_command_resources() { + local command_count + local command_file + + if [ ! -d "$COMMAND_DIR" ]; then + fail "command resource directory is missing: ${COMMAND_DIR}" + return + fi + + command_count=0 + for command_file in "$COMMAND_DIR"/*.json; do + if [ -f "$command_file" ]; then + command_count=$((command_count + 1)) + fi + done + + if [ "$command_count" -ge 9 ]; then + pass "slash command JSON resources found: ${command_count}" + else + fail "expected at least 9 slash command JSON resources, found: ${command_count}" + fi +} + +printf 'EventPilot live smoke preflight\n' +printf 'Checking local readiness without printing secret values.\n\n' + +check_docker +check_env_file +check_env_keys +check_command_resources + +printf '\n' +if [ "$FAILURES" -eq 0 ]; then + pass "preflight passed; continue with docs/live-smoke-test.md" +else + fail "preflight failed with ${FAILURES} blocking issue(s)" + exit 1 +fi