Skip to content
Open
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
15 changes: 13 additions & 2 deletions docs/live-smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
150 changes: 150 additions & 0 deletions scripts/live-smoke-preflight.sh
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,26 +68,19 @@ void tearDown() throws Exception {
autoCloseable.close();
}

@Test
void handle_ReturnsEmptyMono_WhenButtonIdIsNotSignupId() {
// Arrange
when(buttonEvent.getCustomId()).thenReturn("confirm");

// Act
Mono<Message> 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<Message> actual = underTest.handle(buttonEvent)
Expand All @@ -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));
Expand All @@ -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());

Expand All @@ -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.<InteractionApplicationCommandCallbackSpec>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.<TopLevelMessageComponent>of());

// Act
Mono<Message> 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() {
Expand Down
Loading