diff --git a/internal/adapters/audio/ffprobe_test.go b/internal/adapters/audio/ffprobe_test.go new file mode 100644 index 0000000..609a84c --- /dev/null +++ b/internal/adapters/audio/ffprobe_test.go @@ -0,0 +1,129 @@ +package audio_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/adapters/audio" + "git.d-ma.be/mathias/tapir/internal/uploads" +) + +func TestParseProbe(t *testing.T) { + tests := []struct { + name string + json string + want uploads.Probe + }{ + { + name: "audio-only m4a", + json: `{"streams":[{"codec_type":"audio","disposition":{"attached_pic":0}}],"format":{"duration":"2701.5"}}`, + want: uploads.Probe{Duration: 2701*time.Second + 500*time.Millisecond, HasAudio: true}, + }, + { + name: "mp4 with a real video stream", + json: `{"streams":[{"codec_type":"video","disposition":{"attached_pic":0}},{"codec_type":"audio"}],"format":{"duration":"240.0"}}`, + want: uploads.Probe{Duration: 240 * time.Second, HasAudio: true, HasVideo: true}, + }, + { + name: "mp3 whose only video stream is attached cover art is NOT video", + json: `{"streams":[{"codec_type":"audio"},{"codec_type":"video","disposition":{"attached_pic":1}}],"format":{"duration":"60"}}`, + want: uploads.Probe{Duration: 60 * time.Second, HasAudio: true}, + }, + { + name: "no streams at all", + json: `{"streams":[],"format":{"duration":"10"}}`, + want: uploads.Probe{Duration: 10 * time.Second}, + }, + { + name: "duration absent means zero, which Validate rejects as unreadable", + json: `{"streams":[{"codec_type":"audio"}],"format":{}}`, + want: uploads.Probe{HasAudio: true}, + }, + { + name: "duration reported as N/A means zero", + json: `{"streams":[{"codec_type":"audio"}],"format":{"duration":"N/A"}}`, + want: uploads.Probe{HasAudio: true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := audio.ParseProbe([]byte(tt.json)) + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseProbe_GarbageIsAnError(t *testing.T) { + _, err := audio.ParseProbe([]byte("not json")) + assert.Error(t, err) +} + +// stub writes an executable shell script standing in for ffprobe. This is a fake +// at the process boundary: the real binary is not on every host (it is not on +// koala), and what is under test is how Probe drives and interprets a process. +func stub(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "ffprobe") + require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755)) + return p +} + +func TestFFprobeProbe_ParsesWhatTheProcessPrints(t *testing.T) { + bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`) + + got, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/ignored") + + require.NoError(t, err) + assert.Equal(t, uploads.Probe{Duration: 90 * time.Second, HasAudio: true}, got) +} + +func TestFFprobeProbe_PassesTheFilePathAsTheLastArgument(t *testing.T) { + out := filepath.Join(t.TempDir(), "args") + bin := stub(t, `for a; do last="$a"; done; printf '%s' "$last" > `+out+` +echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`) + + _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-123") + + require.NoError(t, err) + got, err := os.ReadFile(out) + require.NoError(t, err) + assert.Equal(t, "/spool/upload-123", string(got)) +} + +func TestFFprobeProbe_NonzeroExitMeansTheFileIsUnreadable(t *testing.T) { + bin := stub(t, `echo "Invalid data found when processing input" >&2; exit 1`) + + _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/x") + + assert.ErrorIs(t, err, uploads.ErrUnreadable) +} + +func TestFFprobeProbe_MissingBinaryIsOurFaultNotAnUnreadableFile(t *testing.T) { + _, err := (&audio.FFprobe{Bin: filepath.Join(t.TempDir(), "no-such-ffprobe")}).Probe(context.Background(), "/x") + + require.Error(t, err) + assert.False(t, errors.Is(err, uploads.ErrUnreadable), + "a missing ffprobe must not be reported to the user as their file being bad") +} + +func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) { + bin := stub(t, `sleep 30`) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := (&audio.FFprobe{Bin: bin}).Probe(ctx, "/x") + + require.Error(t, err) + assert.Less(t, time.Since(start), 5*time.Second) + assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file") +}