import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import AudioManager from '../src/audio-manager'; import { AudioManagerConfigError, AudioManagerStateError } from '../src'; import { startFfmpeg } from '../src/ffmpeg'; import type { AudioSource, VoiceConnectionOptions } from '../src'; const mockAudioPlayer = { play: jest.fn(), pause: jest.fn(), unpause: jest.fn(), stop: jest.fn(), }; const mockConnection = { subscribe: jest.fn(), disconnect: jest.fn(), destroy: jest.fn(), }; const mockAudioResource = { playStream: { destroy: jest.fn(), }, volume: { setVolume: jest.fn(), }, }; const mockFfmpegHandle = { process: { stdout: new PassThrough(), }, stop: jest.fn(), }; jest.mock('@discordjs/voice', () => ({ NoSubscriberBehavior: { Play: 'play', }, StreamType: { Raw: 'raw', }, VoiceConnectionStatus: { Ready: 'ready', }, createAudioPlayer: jest.fn(() => mockAudioPlayer), createAudioResource: jest.fn(() => mockAudioResource), entersState: jest.fn(() => Promise.resolve(mockConnection)), joinVoiceChannel: jest.fn(() => mockConnection), })); jest.mock('../src/ffmpeg', () => ({ startFfmpeg: jest.fn(() => mockFfmpegHandle), })); const connectionOptions: VoiceConnectionOptions = { guildId: 'guild-id', channelId: 'channel-id', adapterCreator: jest.fn() as unknown as VoiceConnectionOptions['adapterCreator'], }; const liveStreamSource: AudioSource = { type: 'url', url: 'https://synradiode.stream.laut.fm/synradiode', }; const fileSourcePath = 'tests/audio.mp3'; const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath); describe('AudioManager', () => { beforeEach(() => { jest.clearAllMocks(); }); afterEach(() => { jest.useRealTimers(); }); it('requires connection options before connecting', async () => { const manager = new AudioManager({ renewIntervalMs: false }); await expect(manager.connect()).rejects.toThrow(AudioManagerConfigError); }); it('connects and starts playback with resolved URL source', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); await manager.start(); expect(manager.state).toBe('playing'); expect(manager.isConnected).toBe(true); expect(manager.isPlaying).toBe(true); expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer); expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined); expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); it('starts playback with the committed mp3 test file', async () => { const manager = new AudioManager({ connection: connectionOptions, source: { type: 'file', path: fileSourcePath, }, renewIntervalMs: false, }); await manager.start(); expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined); expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); it('accepts the live stream URL as a valid source', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); await manager.connect(); await manager.play(); expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined); }); it('plays a source passed directly to play()', async () => { const manager = new AudioManager({ connection: connectionOptions, renewIntervalMs: false, }); await manager.connect(); await manager.play({ type: 'file', path: fileSourcePath, }); expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined); expect(manager.state).toBe('playing'); }); it('replaces active playback when a new source is played', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); await manager.start(); await manager.play({ type: 'file', path: fileSourcePath, }); expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); expect(startFfmpeg).toHaveBeenLastCalledWith(resolvedFileSourcePath, undefined); }); it('requires an active connection before play()', async () => { const manager = new AudioManager({ source: liveStreamSource, renewIntervalMs: false, }); await expect(manager.play()).rejects.toThrow(AudioManagerStateError); }); it('requires a source before play()', async () => { const manager = new AudioManager({ connection: connectionOptions, renewIntervalMs: false, }); await manager.connect(); await expect(manager.play()).rejects.toThrow(AudioManagerConfigError); }); it('rejects invalid URL sources', async () => { const manager = new AudioManager({ connection: connectionOptions, source: { type: 'url', url: 'not a url', }, renewIntervalMs: false, }); await manager.connect(); await expect(manager.play()).rejects.toThrow(AudioManagerConfigError); }); it('applies initial volume only when volume support is enabled', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, volume: { enabled: true, initialPercent: 35, }, }); await manager.start(); expect(mockAudioResource.volume.setVolume).toHaveBeenCalledWith(0.35); }); it('rejects volume changes when inline volume is disabled', () => { const manager = new AudioManager({ renewIntervalMs: false }); expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError); }); it('rejects invalid volume percentages', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, volume: { enabled: true, }, }); await manager.start(); expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError); expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError); }); it('rejects volume changes before any resource exists', () => { const manager = new AudioManager({ renewIntervalMs: false, volume: { enabled: true, }, }); expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError); }); it('pauses and resumes only from valid playback states', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); expect(() => manager.pause()).toThrow(AudioManagerStateError); await manager.start(); manager.pause(); manager.resume(); expect(mockAudioPlayer.pause).toHaveBeenCalledTimes(1); expect(mockAudioPlayer.unpause).toHaveBeenCalledTimes(1); expect(manager.state).toBe('playing'); }); it('stops playback and voice resources idempotently', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); await manager.start(); await manager.stop(); await manager.stop(); expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); expect(mockConnection.destroy).toHaveBeenCalled(); expect(manager.state).toBe('stopped'); }); it('restarts playback when the renewal timer fires', async () => { jest.useFakeTimers(); const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: 10_000, }); const startSpy = jest.spyOn(manager, 'start').mockResolvedValue(undefined); await manager.connect(); jest.advanceTimersByTime(10_000); await Promise.resolve(); expect(startSpy).toHaveBeenCalledTimes(1); }); it('clears existing renewal timer before reconnecting', async () => { jest.useFakeTimers(); const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: 10_000, }); await manager.connect(); await manager.connect(); expect(jest.getTimerCount()).toBe(1); manager.dispose(); expect(jest.getTimerCount()).toBe(0); }); it('blocks source and connection changes after disposal', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); manager.dispose(); expect(() => manager.setSource(liveStreamSource)).toThrow(AudioManagerStateError); expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError); }); it('prevents use after disposal', async () => { const manager = new AudioManager({ connection: connectionOptions, source: liveStreamSource, renewIntervalMs: false, }); await manager.start(); manager.dispose(); expect(() => manager.pause()).toThrow(AudioManagerStateError); expect(manager.state).toBe('disposed'); }); });