@@ -3,12 +3,15 @@ package main
33import (
44 "fmt"
55 "os"
6+ "os/exec"
7+ "runtime"
68 "strings"
79 "testing"
810
911 "github.com/stretchr/testify/assert"
1012 "github.com/stretchr/testify/require"
1113
14+ "github.com/radio-t/ai-podcast/mocks"
1215 "github.com/radio-t/ai-podcast/podcast"
1316)
1417
@@ -39,6 +42,172 @@ func TestFFmpegAudioProcessor_Play(t *testing.T) {
3942 })
4043}
4144
45+ func TestFFmpegAudioProcessor_PlayWithMock (t * testing.T ) {
46+ // create a temporary file for all tests
47+ tmpFile , err := os .CreateTemp ("" , "test-audio-*.mp3" )
48+ require .NoError (t , err )
49+ defer os .Remove (tmpFile .Name ())
50+ require .NoError (t , tmpFile .Close ())
51+
52+ t .Run ("successful command execution" , func (t * testing.T ) {
53+ mockRunner := & mocks.CommandRunnerMock {
54+ GetAudioCommandFunc : func (filename string ) (* exec.Cmd , error ) {
55+ assert .Equal (t , tmpFile .Name (), filename )
56+ // return a command that will succeed (echo does nothing)
57+ return exec .Command ("echo" , "playing audio" ), nil
58+ },
59+ }
60+
61+ processor := & FFmpegAudioProcessor {cmdRunner : mockRunner }
62+ err := processor .Play (tmpFile .Name ())
63+ require .NoError (t , err )
64+
65+ // verify mock was called
66+ calls := mockRunner .GetAudioCommandCalls ()
67+ assert .Len (t , calls , 1 )
68+ assert .Equal (t , tmpFile .Name (), calls [0 ].Filename )
69+ })
70+
71+ t .Run ("command runner returns error" , func (t * testing.T ) {
72+ mockRunner := & mocks.CommandRunnerMock {
73+ GetAudioCommandFunc : func (filename string ) (* exec.Cmd , error ) {
74+ return nil , fmt .Errorf ("no suitable audio player found on your system" )
75+ },
76+ }
77+
78+ processor := & FFmpegAudioProcessor {cmdRunner : mockRunner }
79+ err := processor .Play (tmpFile .Name ())
80+ require .Error (t , err )
81+ assert .Contains (t , err .Error (), "no suitable audio player found" )
82+ })
83+
84+ t .Run ("command execution failure" , func (t * testing.T ) {
85+ mockRunner := & mocks.CommandRunnerMock {
86+ GetAudioCommandFunc : func (filename string ) (* exec.Cmd , error ) {
87+ // return a command that will fail
88+ return exec .Command ("false" ), nil
89+ },
90+ }
91+
92+ processor := & FFmpegAudioProcessor {cmdRunner : mockRunner }
93+ err := processor .Play (tmpFile .Name ())
94+ require .Error (t , err )
95+ assert .Contains (t , err .Error (), "error playing audio" )
96+ })
97+ }
98+
99+ func TestDefaultCommandRunner_GetAudioCommand (t * testing.T ) {
100+ runner := & defaultCommandRunner {}
101+
102+ tests := []struct {
103+ name string
104+ goos string
105+ setupMock func ()
106+ wantErr bool
107+ errContains string
108+ wantCmd []string
109+ }{
110+ {
111+ name : "darwin" ,
112+ goos : "darwin" ,
113+ wantCmd : []string {"afplay" },
114+ },
115+ {
116+ name : "windows" ,
117+ goos : "windows" ,
118+ wantCmd : []string {"cmd" , "/C" , "start" },
119+ },
120+ {
121+ name : "unsupported OS" ,
122+ goos : "plan9" ,
123+ wantErr : true ,
124+ errContains : "unsupported operating system: plan9" ,
125+ },
126+ }
127+
128+ for _ , tt := range tests {
129+ t .Run (tt .name , func (t * testing.T ) {
130+ // we can't actually change runtime.GOOS in tests, so we'll test what we can
131+ if runtime .GOOS != tt .goos && tt .goos != "plan9" {
132+ t .Skip ("Test requires" , tt .goos , "platform" )
133+ }
134+
135+ if tt .goos == "plan9" {
136+ // we can at least verify the error message format
137+ err := fmt .Errorf ("unsupported operating system: %s" , tt .goos )
138+ assert .Contains (t , err .Error (), tt .errContains )
139+ return
140+ }
141+
142+ cmd , err := runner .GetAudioCommand ("test.mp3" )
143+ if tt .wantErr {
144+ require .Error (t , err )
145+ if tt .errContains != "" {
146+ assert .Contains (t , err .Error (), tt .errContains )
147+ }
148+ } else {
149+ require .NoError (t , err )
150+ require .NotNil (t , cmd )
151+ // verify command structure
152+ args := append ([]string {cmd .Path }, cmd .Args [1 :]... )
153+ for i , want := range tt .wantCmd {
154+ if i < len (args ) {
155+ assert .Contains (t , args [i ], want )
156+ }
157+ }
158+ }
159+ })
160+ }
161+
162+ // security tests that work on any platform
163+ t .Run ("security validation" , func (t * testing.T ) {
164+ securityTests := []struct {
165+ name string
166+ filename string
167+ errContains string
168+ }{
169+ {
170+ name : "path traversal attempt" ,
171+ filename : "../../../etc/passwd" ,
172+ errContains : "invalid filename: potential security risk" ,
173+ },
174+ {
175+ name : "command injection with semicolon" ,
176+ filename : "test.mp3; rm -rf /" ,
177+ errContains : "invalid filename: potential security risk" ,
178+ },
179+ {
180+ name : "pipe injection attempt" ,
181+ filename : "test.mp3 | cat /etc/passwd" ,
182+ errContains : "invalid filename: potential security risk" ,
183+ },
184+ {
185+ name : "background execution attempt" ,
186+ filename : "test.mp3 &" ,
187+ errContains : "invalid filename: potential security risk" ,
188+ },
189+ {
190+ name : "command substitution attempt" ,
191+ filename : "test$(whoami).mp3" ,
192+ errContains : "invalid filename: potential security risk" ,
193+ },
194+ {
195+ name : "backtick injection attempt" ,
196+ filename : "test`whoami`.mp3" ,
197+ errContains : "invalid filename: potential security risk" ,
198+ },
199+ }
200+
201+ for _ , st := range securityTests {
202+ t .Run (st .name , func (t * testing.T ) {
203+ _ , err := runner .GetAudioCommand (st .filename )
204+ require .Error (t , err )
205+ assert .Contains (t , err .Error (), st .errContains )
206+ })
207+ }
208+ })
209+ }
210+
42211func TestFFmpegAudioProcessor_StreamToIcecast (t * testing.T ) {
43212 processor := NewFFmpegAudioProcessor ()
44213
@@ -171,3 +340,41 @@ func TestCreateConcatFile(t *testing.T) {
171340 })
172341 }
173342}
343+
344+ func TestFFmpegAudioProcessor_StreamFromConcat (t * testing.T ) {
345+ processor := NewFFmpegAudioProcessor ()
346+
347+ t .Run ("non-existent file" , func (t * testing.T ) {
348+ config := podcast.Config {
349+ IcecastUser : "user" ,
350+ IcecastPass : "pass" ,
351+ IcecastURL : "localhost:8000" ,
352+ IcecastMount : "/stream.mp3" ,
353+ }
354+ err := processor .StreamFromConcat ("/tmp/non-existent-concat-file.txt" , config )
355+ require .Error (t , err )
356+ assert .Contains (t , err .Error (), "ffmpeg streaming failed: exit status 254" )
357+ })
358+
359+ t .Run ("existing file" , func (t * testing.T ) {
360+ // create a temporary file
361+ tmpFile , err := os .CreateTemp ("" , "test-concat-*.txt" )
362+ require .NoError (t , err )
363+ defer os .Remove (tmpFile .Name ())
364+ _ , err = tmpFile .WriteString ("file '/path/to/fake.mp3'" )
365+ require .NoError (t , err )
366+ require .NoError (t , tmpFile .Close ())
367+
368+ config := podcast.Config {
369+ IcecastUser : "user" ,
370+ IcecastPass : "pass" ,
371+ IcecastURL : "localhost:8000" ,
372+ IcecastMount : "/stream.mp3" ,
373+ }
374+
375+ // this will fail because ffmpeg can't find the input file, but it covers the function
376+ err = processor .StreamFromConcat (tmpFile .Name (), config )
377+ require .Error (t , err )
378+ assert .Contains (t , err .Error (), "ffmpeg streaming failed: exit status 254" )
379+ })
380+ }
0 commit comments