From cbd02934515a4bba408fdfa3f5dd37a89c2e94b7 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:11:19 -0700 Subject: [PATCH 1/2] Add lightweight playback and loading behavior tests --- .github/workflows/docker-image.yml | 16 +++ README.md | 5 + TESTING.md | 42 +++++++ build.gradle | 15 +++ .../infinitybot/AudioManager.java | 8 +- .../theinfinitymc/infinitybot/GuildAudio.java | 10 +- .../infinitybot/YoutubeConfiguration.java | 39 ++++-- .../infinitybot/AudioManagerTest.java | 119 ++++++++++++++++++ .../infinitybot/GuildAudioTest.java | 104 +++++++++++++++ .../infinitybot/YoutubeConfigurationTest.java | 49 ++++++++ 10 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 TESTING.md create mode 100644 src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java create mode 100644 src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java create mode 100644 src/test/java/net/theinfinitymc/infinitybot/YoutubeConfigurationTest.java diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c83b312..a574c1a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -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 diff --git a/README.md b/README.md index 29ed602..66804ca 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..1021a5f --- /dev/null +++ b/TESTING.md @@ -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. diff --git a/build.gradle b/build.gradle index aa7789d..e0cc18e 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java b/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java index cfd786e..e9c537e 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java +++ b/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java @@ -21,8 +21,7 @@ public class AudioManager { private final Map 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(); @@ -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)) { diff --git a/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java b/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java index d7e8bef..304ef84 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java +++ b/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java @@ -27,8 +27,14 @@ public class GuildAudio extends AudioEventAdapter { Guild guild; AudioPlayer player; BlockingQueue 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<>(); @@ -101,7 +107,7 @@ public void connect(AudioChannelUnion channel) { public void disconnect() { guild.getAudioManager().closeAudioConnection(); - InfinityBot.instance.updateActivity(); + activityChanged.run(); } public boolean isConnected() { @@ -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 diff --git a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java index 840365b..a456338 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java +++ b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java @@ -10,6 +10,7 @@ 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); @@ -17,20 +18,18 @@ final class YoutubeConfiguration { 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); @@ -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 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 environment, String name) { + String value = environment.apply(name); return value == null || value.isBlank() ? null : value.strip(); } } diff --git a/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java b/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java new file mode 100644 index 0000000..e5df3e4 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java @@ -0,0 +1,119 @@ +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(audio); + order.verify(audio).queue(first); + order.verify(audio).queue(second); + verify(audio, times(search ? 0 : 1)).queue(third); + verify(second).setUserData(new GuildTrackData(user, channel, guild)); + 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); + verify(track).setUserData(new GuildTrackData(user, channel, guild)); + 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 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; + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java b/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java new file mode 100644 index 0000000..9b6a7bf --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java @@ -0,0 +1,104 @@ +package net.theinfinitymc.infinitybot; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.theinfinitymc.infinitybot.commands.Pause.PauseStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GuildAudioTest { + private final Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); + private final AudioPlayer player = mock(AudioPlayer.class); + private final GuildAudio audio = new GuildAudio(guild, player, () -> {}); + private final AudioTrack first = mock(AudioTrack.class); + private final AudioTrack second = mock(AudioTrack.class); + + @Test + void startsImmediatelyWhenIdleAndOtherwiseAdvancesInFifoOrder() { + AudioTrack current = mock(AudioTrack.class); + when(player.startTrack(current, true)).thenReturn(true); + assertTrue(audio.queue(current)); + assertFalse(audio.hasNext()); + when(player.getPlayingTrack()).thenReturn(current); + assertTrue(audio.queue(first)); + assertTrue(audio.queue(second)); + when(player.startTrack(first, false)).thenReturn(true); + when(player.startTrack(second, false)).thenReturn(true); + assertTrue(audio.skip()); + assertTrue(audio.skip()); + var order = inOrder(player); + order.verify(player).startTrack(first, false); + order.verify(player).startTrack(second, false); + assertFalse(audio.hasNext()); + verify(guild.getAudioManager(), never()).closeAudioConnection(); + assertTrue(audio.skip()); + verify(player).stopTrack(); + verify(guild.getAudioManager()).closeAudioConnection(); + } + + @Test + void stopClearsPendingTracksAndDisconnects() { + when(player.getPlayingTrack()).thenReturn(first); + audio.queue(second); + assertTrue(audio.stop()); + assertFalse(audio.hasNext()); + verify(player).stopTrack(); + verify(guild.getAudioManager()).closeAudioConnection(); + } + + @Test + void idleControlsAreNoOpsAndPauseToggles() { + assertFalse(audio.skip()); + assertFalse(audio.stop()); + assertEquals(PauseStatus.NO_MUSIC, audio.togglePause()); + verify(player, never()).stopTrack(); + verify(guild.getAudioManager(), never()).closeAudioConnection(); + when(player.getPlayingTrack()).thenReturn(first); + assertEquals(PauseStatus.PAUSED, audio.togglePause()); + verify(player).setPaused(true); + when(player.isPaused()).thenReturn(true); + assertEquals(PauseStatus.UNPAUSED, audio.togglePause()); + verify(player).setPaused(false); + } + + @ParameterizedTest + @CsvSource({"FINISHED,true,false", "LOAD_FAILED,true,false", "REPLACED,false,false", "STOPPED,false,true", "CLEANUP,false,true"}) + void endReasonControlsAdvancementAndDisconnect(AudioTrackEndReason reason, boolean advance, boolean disconnect) { + audio.queue(second); + when(player.startTrack(second, false)).thenReturn(true); + audio.onTrackEnd(player, first, reason); + verify(player, times(advance ? 1 : 0)).startTrack(second, false); + verify(guild.getAudioManager(), times(disconnect ? 1 : 0)).closeAudioConnection(); + assertEquals(!advance, audio.hasNext()); + } + + @ParameterizedTest + @CsvSource({"true", "false"}) + void notificationFailureCannotPreventAdvancement(boolean stuck) { + Message message = mock(Message.class); + when(first.getUserData()).thenReturn(message); + when(message.reply(anyString())).thenThrow(new IllegalStateException("Missing permission")); + audio.queue(second); + when(player.startTrack(second, false)).thenReturn(true); + if (stuck) audio.onTrackStuck(player, first, 1000); + else audio.onTrackEnd(player, first, AudioTrackEndReason.LOAD_FAILED); + verify(player).startTrack(second, false); + assertFalse(audio.hasNext()); + } + + @Test + void failedNextTrackStopsAndDisconnects() { + audio.queue(second); + audio.onTrackEnd(player, first, AudioTrackEndReason.FINISHED); + verify(player).startTrack(second, false); + verify(player).stopTrack(); + verify(guild.getAudioManager()).closeAudioConnection(); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/YoutubeConfigurationTest.java b/src/test/java/net/theinfinitymc/infinitybot/YoutubeConfigurationTest.java new file mode 100644 index 0000000..dd35af5 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/YoutubeConfigurationTest.java @@ -0,0 +1,49 @@ +package net.theinfinitymc.infinitybot; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class YoutubeConfigurationTest { + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t\n"}) + void absentAndBlankValuesAreUnset(String value) { + assertEquals(new YoutubeConfiguration.Settings(null, null, null, null, null), + YoutubeConfiguration.parse(ignored -> value)); + } + + @ParameterizedTest + @CsvSource({"true,false,false", "false,true,false", "false,false,true", "true,true,true"}) + void optionalFeaturesAreIndependentAndValuesAreTrimmed(boolean cipher, boolean oauth, boolean poToken) { + Map env = new HashMap<>(); + if (cipher) { + env.put("YOUTUBE_REMOTE_CIPHER_URL", " https://cipher.example "); + env.put("YOUTUBE_REMOTE_CIPHER_PASSWORD", " password-secret "); + } + if (oauth) env.put("YOUTUBE_OAUTH_REFRESH_TOKEN", " oauth-secret "); + if (poToken) { + env.put("YOUTUBE_PO_TOKEN", " po-secret "); + env.put("YOUTUBE_VISITOR_DATA", " visitor-secret "); + } + var settings = YoutubeConfiguration.parse(env::get); + assertEquals(new YoutubeConfiguration.Settings(cipher ? "https://cipher.example" : null, + cipher ? "password-secret" : null, oauth ? "oauth-secret" : null, + poToken ? "po-secret" : null, poToken ? "visitor-secret" : null), settings); + assertFalse(settings.toString().contains("secret")); + } + + @ParameterizedTest + @ValueSource(strings = {"YOUTUBE_PO_TOKEN", "YOUTUBE_VISITOR_DATA"}) + void rejectsHalfConfiguredTokenPairWithoutLeakingItsValue(String key) { + var env = Map.of(key, "sensitive-value"); + var exception = assertThrows(IllegalArgumentException.class, () -> YoutubeConfiguration.parse(env::get)); + assertFalse(exception.getMessage().contains("sensitive-value")); + } +} From 40d278edb9ba1448e044e8d920a0e015962722a9 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:50:29 -0700 Subject: [PATCH 2/2] Cover initial voice connection and playback callback prerequisites --- .../infinitybot/AudioManagerTest.java | 32 ++++++++++++++++--- .../infinitybot/GuildAudioTest.java | 1 + 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java b/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java index e5df3e4..c708674 100644 --- a/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java +++ b/src/test/java/net/theinfinitymc/infinitybot/AudioManagerTest.java @@ -73,11 +73,18 @@ void directPlaylistQueuesAllButSearchTakesOnlyFirstAcceptedResult(boolean search when(audio.queue(first)).thenReturn(false); } handler.playlistLoaded(playlist); - var order = inOrder(audio); + 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); - verify(audio, times(search ? 0 : 1)).queue(third); - verify(second).setUserData(new GuildTrackData(user, channel, guild)); + 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); } @@ -89,7 +96,9 @@ void singleTrackReportsQueueOutcome(boolean accepted) { AudioTrack track = track("Title"); when(audio.queue(track)).thenReturn(accepted); handler.trackLoaded(track); - verify(track).setUserData(new GuildTrackData(user, channel, guild)); + 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"); } @@ -101,6 +110,21 @@ void existingConnectionIsReusedWithoutLookingUpRequestersChannel() { 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); diff --git a/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java b/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java index 9b6a7bf..8aa1fb6 100644 --- a/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java +++ b/src/test/java/net/theinfinitymc/infinitybot/GuildAudioTest.java @@ -71,6 +71,7 @@ void idleControlsAreNoOpsAndPauseToggles() { @ParameterizedTest @CsvSource({"FINISHED,true,false", "LOAD_FAILED,true,false", "REPLACED,false,false", "STOPPED,false,true", "CLEANUP,false,true"}) void endReasonControlsAdvancementAndDisconnect(AudioTrackEndReason reason, boolean advance, boolean disconnect) { + verify(player).addListener(audio); audio.queue(second); when(player.startTrack(second, false)).thenReturn(true); audio.onTrackEnd(player, first, reason);