Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,23 @@ permissions:
contents: read

jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '25'
cache: gradle
- name: Run tests
run: sh ./gradlew --no-daemon check

image:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ Cipher support solves signature deciphering, **not** YouTube IP blocks, age rest

The Docker image includes a healthcheck that polls JDA's Discord connection state, checking that the bot is connected rather than just running.

## Tests

Run `./gradlew test` with JDK 25. See [testing guidance](TESTING.md) for scope and conventions.
CI runs tests in a separate job before building or publishing the container.

## Build locally

Requires JDK 25. JDAVE provides Discord voice encryption (DAVE).
Expand Down
42 changes: 42 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Testing

Run `./gradlew test` (or `sh ./gradlew test`) with JDK 25. `./gradlew check`
includes the same suite. Reports are in `build/reports/tests/test/index.html`.
CI runs `check` in a separate Test job; container builds/publication depend on it.

## Scope

JUnit Jupiter and Mockito test our decisions, not the external services:

- `GuildAudioTest`: immediate playback versus FIFO queueing, skip/stop/pause,
exhausted or failed next-track handling, track-end policies, and advancement
despite failed error notifications.
- `AudioManagerTest`: one search fallback, direct playlists versus search
results, request metadata, queue outcomes, failure cleanup without interrupting
existing playback, and voice connection policy.
- `YoutubeConfigurationTest`: pure environment parsing, blank/trimmed values,
independent optional settings, paired poToken/visitor data, and credential
redaction in parsed settings' string representation and validation errors.

Tests inject the loader and activity callback, manually deliver load/playback
callbacks, and parse configuration from a map. They never construct a connected
bot or authenticate with YouTube. Mockito is supplied as an explicit test JVM
agent for modern JDKs; no application runtime dependency is added.

## Keeping this lightweight

- Add tests for plausible bugs in application behavior, not a coverage target.
- Prefer parameterized boundary cases and observable outcomes. Interaction
assertions are appropriate for decisions such as disconnecting or advancing
exactly once, not incidental builder call order.
- No sleeps, network, credentials, real audio, or whole-bot startup in tests.
- Do not test Discord delivery/voice transport, YouTube availability, cipher or
OAuth refresh, decoding, library internals, reflection, generated accessors,
trivial command delegation, or exact prose/embed styling.
- Keep fixtures local and small; do not build a Discord/Lavaplayer simulator.
- Add a focused regression test when fixing a substantive bug.

After deployment or dependency updates, manually check Discord connectivity,
playback, queue advancement, pause, and stop on the intended host. YouTube/IP,
cipher, and voice transport issues belong to this operational smoke check, not
the deterministic Gradle suite.
15 changes: 15 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,22 @@ repositories {
}
}

configurations {
mockitoAgent
}

tasks.named('test', Test) {
useJUnitPlatform()
jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}"
}

dependencies {
testImplementation platform('org.junit:junit-bom:6.0.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'org.mockito:mockito-core:5.23.0'
mockitoAgent('org.mockito:mockito-core:5.23.0') { transitive = false }

implementation 'net.dv8tion:JDA:6.7.0'
implementation 'club.minnced:jdave-api:0.1.8'
runtimeOnly 'club.minnced:jdave-native-linux-x86-64:0.1.8'
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/net/theinfinitymc/infinitybot/AudioManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ public class AudioManager {
private final Map<Guild, GuildAudio> guildAudioMap;

AudioManager(){
this.guildAudioMap = new HashMap<>();
this.audioPlayerManager = new DefaultAudioPlayerManager();
this(new DefaultAudioPlayerManager());

// Register default sources, but replace the deprecated YT source with new version
YoutubeAudioSourceManager youtubeSource = YoutubeConfiguration.createSource();
Expand All @@ -31,6 +30,11 @@ public class AudioManager {
AudioSourceManagers.registerRemoteSources(audioPlayerManager, deprecatedYoutubeSource);
}

AudioManager(AudioPlayerManager audioPlayerManager) {
this.guildAudioMap = new HashMap<>();
this.audioPlayerManager = audioPlayerManager;
}

public void tryAddToQueue(String song, Guild guild, MessageChannelUnion channel, User user, QueueCallback callback) {
GuildAudio guildAudio = getGuildAudio(guild);
if (connectToGuild(guildAudio, user, callback)) {
Expand Down
10 changes: 8 additions & 2 deletions src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ public class GuildAudio extends AudioEventAdapter {
Guild guild;
AudioPlayer player;
BlockingQueue<AudioTrack> queue;
Runnable activityChanged;

GuildAudio(Guild guild, AudioPlayer player) {
this(guild, player, () -> InfinityBot.instance.updateActivity());
}

GuildAudio(Guild guild, AudioPlayer player, Runnable activityChanged) {
this.activityChanged = activityChanged;
this.guild = guild;
this.player = player;
this.queue = new LinkedBlockingQueue<>();
Expand Down Expand Up @@ -101,7 +107,7 @@ public void connect(AudioChannelUnion channel) {

public void disconnect() {
guild.getAudioManager().closeAudioConnection();
InfinityBot.instance.updateActivity();
activityChanged.run();
}

public boolean isConnected() {
Expand All @@ -122,7 +128,7 @@ public void onTrackStart(AudioPlayer player, AudioTrack track) {
Message message = trackData.getChannel().sendMessageEmbeds(embed.build()).complete();
track.setUserData(message);

InfinityBot.instance.updateActivity();
activityChanged.run();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,26 @@
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.function.Function;

final class YoutubeConfiguration {
private static final Logger log = LoggerFactory.getLogger(YoutubeConfiguration.class);

private YoutubeConfiguration() {}

static YoutubeAudioSourceManager createSource() {
String cipherUrl = environment("YOUTUBE_REMOTE_CIPHER_URL");
String cipherPassword = environment("YOUTUBE_REMOTE_CIPHER_PASSWORD");
String refreshToken = environment("YOUTUBE_OAUTH_REFRESH_TOKEN");
String poToken = environment("YOUTUBE_PO_TOKEN");
String visitorData = environment("YOUTUBE_VISITOR_DATA");
Settings settings = parse(System::getenv);
String cipherUrl = settings.cipherUrl();
String cipherPassword = settings.cipherPassword();
String refreshToken = settings.refreshToken();
String poToken = settings.poToken();
String visitorData = settings.visitorData();

YoutubeSourceOptions options = new YoutubeSourceOptions();
if (cipherUrl != null) {
options.setRemoteCipher(cipherUrl, cipherPassword, "InfinityBot");
}

if ((poToken == null) != (visitorData == null)) {
throw new IllegalArgumentException("YOUTUBE_PO_TOKEN and YOUTUBE_VISITOR_DATA must both be set or both be empty.");
}
Web.setPoTokenAndVisitorData(poToken, visitorData);
WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData);

Expand Down Expand Up @@ -58,8 +57,28 @@ static YoutubeAudioSourceManager createSource() {
return source;
}

private static String environment(String name) {
String value = System.getenv(name);
// Parsing is deliberately separate from source construction (which can authenticate).
record Settings(String cipherUrl, String cipherPassword, String refreshToken,
String poToken, String visitorData) {
@Override
public String toString() {
return "YouTube settings [credentials redacted]";
}
}

static Settings parse(Function<String, String> environment) {
String poToken = environment(environment, "YOUTUBE_PO_TOKEN");
String visitorData = environment(environment, "YOUTUBE_VISITOR_DATA");
if ((poToken == null) != (visitorData == null)) {
throw new IllegalArgumentException("YOUTUBE_PO_TOKEN and YOUTUBE_VISITOR_DATA must both be set or both be empty.");
}
return new Settings(environment(environment, "YOUTUBE_REMOTE_CIPHER_URL"),
environment(environment, "YOUTUBE_REMOTE_CIPHER_PASSWORD"),
environment(environment, "YOUTUBE_OAUTH_REFRESH_TOKEN"), poToken, visitorData);
}

private static String environment(Function<String, String> environment, String name) {
String value = environment.apply(name);
return value == null || value.isBlank() ? null : value.strip();
}
}
143 changes: 143 additions & 0 deletions src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package net.theinfinitymc.infinitybot;

import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;

import java.util.List;

import static net.theinfinitymc.infinitybot.QueueCallback.QueueStatus.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class AudioManagerTest {
private final AudioPlayerManager loader = mock(AudioPlayerManager.class);
private final AudioManager manager = spy(new AudioManager(loader));
private final Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS);
private final GuildAudio audio = mock(GuildAudio.class);
private final User user = mock(User.class);
private final MessageChannelUnion channel = mock(MessageChannelUnion.class);
private final QueueCallback callback = mock(QueueCallback.class);

private AudioLoadResultHandler load() {
doReturn(audio).when(manager).getGuildAudio(guild);
when(audio.isConnected()).thenReturn(true);
manager.tryAddToQueue("song", guild, channel, user, callback);
var handler = ArgumentCaptor.forClass(AudioLoadResultHandler.class);
verify(loader).loadItem(eq("song"), handler.capture());
return handler.getValue();
}

@ParameterizedTest
@CsvSource({"false,false", "true,false", "false,true", "true,true"})
void terminalFailureDisconnectsOnlyWhenIdle(boolean playing, boolean loadFailure) {
AudioLoadResultHandler handler = load();
when(audio.isPlaying()).thenReturn(playing);
if (loadFailure) {
handler.loadFailed(new FriendlyException("failed", FriendlyException.Severity.COMMON, null));
} else {
handler.noMatches();
verify(loader).loadItem("ytsearch:song", handler);
verifyNoInteractions(callback);
handler.noMatches();
verify(loader, times(2)).loadItem(anyString(), any());
}
verify(audio, times(playing ? 0 : 1)).disconnect();
verify(callback).call(loadFailure ? FAILURE_LOAD : NO_MATCHES, "song");
verifyNoMoreInteractions(callback);
}

@ParameterizedTest
@CsvSource({"false", "true"})
void directPlaylistQueuesAllButSearchTakesOnlyFirstAcceptedResult(boolean search) {
AudioLoadResultHandler handler = load();
AudioTrack first = track("First");
AudioTrack second = track("Second");
AudioTrack third = track("Third");
AudioPlaylist playlist = mock(AudioPlaylist.class);
when(playlist.getTracks()).thenReturn(List.of(first, second, third));
when(playlist.getName()).thenReturn("Playlist");
when(audio.queue(any())).thenReturn(true);
if (search) {
handler.noMatches();
when(audio.queue(first)).thenReturn(false);
}
handler.playlistLoaded(playlist);
var order = inOrder(first, second, third, audio);
GuildTrackData metadata = new GuildTrackData(user, channel, guild);
order.verify(first).setUserData(metadata);
order.verify(audio).queue(first);
order.verify(second).setUserData(metadata);
order.verify(audio).queue(second);
if (!search) {
order.verify(third).setUserData(metadata);
order.verify(audio).queue(third);
} else {
verify(audio, never()).queue(third);
}
verify(callback).call(SUCCESS, search ? "Second" : "Playlist");
verifyNoMoreInteractions(callback);
}

@ParameterizedTest
@CsvSource({"true", "false"})
void singleTrackReportsQueueOutcome(boolean accepted) {
AudioLoadResultHandler handler = load();
AudioTrack track = track("Title");
when(audio.queue(track)).thenReturn(accepted);
handler.trackLoaded(track);
var order = inOrder(track, audio);
order.verify(track).setUserData(new GuildTrackData(user, channel, guild));
order.verify(audio).queue(track);
verify(callback).call(accepted ? SUCCESS : FAILURE_QUEUE, accepted ? "Title" : "song");
}

@Test
void existingConnectionIsReusedWithoutLookingUpRequestersChannel() {
when(audio.isConnected()).thenReturn(true);
assertTrue(manager.connectToGuild(audio, user, callback));
verify(audio, never()).getGuild();
verifyNoInteractions(callback);
}

@Test
void firstRequestConnectsToRequestersVoiceChannelBeforeLoading() {
doReturn(audio).when(manager).getGuildAudio(guild);
when(audio.isConnected()).thenReturn(false);
when(audio.getGuild()).thenReturn(guild);
var voiceState = guild.getMember(user).getVoiceState();
when(voiceState.inAudioChannel()).thenReturn(true);
var voiceChannel = voiceState.getChannel();
manager.tryAddToQueue("song", guild, channel, user, callback);
var order = inOrder(audio, loader);
order.verify(audio).connect(voiceChannel);
order.verify(loader).loadItem(eq("song"), any(AudioLoadResultHandler.class));
verifyNoInteractions(callback);
}

@Test
void missingVoiceChannelRejectsRequestBeforeLoading() {
doReturn(audio).when(manager).getGuildAudio(guild);
when(audio.getGuild()).thenReturn(guild);
when(guild.getMember(user).getVoiceState()).thenReturn(null);
manager.tryAddToQueue("song", guild, channel, user, callback);
verify(callback).call(FAILURE_CHANNEL);
verifyNoInteractions(loader);
}

private static AudioTrack track(String title) {
AudioTrack track = mock(AudioTrack.class);
when(track.getInfo()).thenReturn(new AudioTrackInfo(title, "Artist", 1000, title, false, "https://example.com/track"));
return track;
}
}
Loading
Loading