package uploads_test import ( "bytes" "context" "errors" "io" "os" "path/filepath" "strings" "testing" "testing/iotest" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/uploads" ) type fakeStore struct { created []string // titles hadVideo []bool failed map[string]domain.UploadErrorKind nextID string } func (f *fakeStore) CreateUpload(_ context.Context, _, title string, hadVideo bool) (string, error) { f.created = append(f.created, title) f.hadVideo = append(f.hadVideo, hadVideo) return f.nextID, nil } func (f *fakeStore) FailUpload(_ context.Context, _, id string, kind domain.UploadErrorKind) error { if f.failed == nil { f.failed = map[string]domain.UploadErrorKind{} } f.failed[id] = kind return nil } type fakeProber struct { probe uploads.Probe err error paths []string seen []byte // content of the file at probe time } func (f *fakeProber) Probe(_ context.Context, path string) (uploads.Probe, error) { f.paths = append(f.paths, path) f.seen, _ = os.ReadFile(path) return f.probe, f.err } type fakeSubmitter struct { jobs []uploads.Job err error } func (f *fakeSubmitter) Submit(j uploads.Job) error { f.jobs = append(f.jobs, j) return f.err } type rig struct { in *uploads.Intake st *fakeStore pr *fakeProber sub *fakeSubmitter tmp string } func newRig(t *testing.T) *rig { t.Helper() r := &rig{ st: &fakeStore{nextID: "up-1"}, pr: &fakeProber{probe: uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}}, sub: &fakeSubmitter{}, tmp: t.TempDir(), } r.in = uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: 90 * time.Minute}, r.tmp) return r } func (r *rig) tmpFiles(t *testing.T) []string { t.Helper() ents, err := os.ReadDir(r.tmp) require.NoError(t, err) var names []string for _, e := range ents { names = append(names, e.Name()) } return names } func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) { r := newRig(t) audio := bytes.Repeat([]byte("a"), 500) id, err := r.in.Accept(context.Background(), "user-1", "Teams export", bytes.NewReader(audio)) require.NoError(t, err) assert.Equal(t, "up-1", id) assert.Equal(t, []string{"Teams export"}, r.st.created) assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk") require.Len(t, r.sub.jobs, 1) job := r.sub.jobs[0] assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path}, job) assert.Equal(t, filepath.Dir(job.Path), r.tmp, "the temp file lives in the configured directory") got, err := os.ReadFile(job.Path) require.NoError(t, err) assert.Equal(t, audio, got, "the handed-over file still exists: the worker, not Accept, deletes it") } func TestAccept_OversizeBodyIsRejectedAndLeavesNothingBehind(t *testing.T) { r := newRig(t) _, err := r.in.Accept(context.Background(), "user-1", "big", strings.NewReader(strings.Repeat("a", 1025))) var rej *uploads.RejectError require.True(t, errors.As(err, &rej)) assert.Equal(t, uploads.ReasonTooLarge, rej.Reason) assert.Contains(t, rej.Message, "limit") assert.Empty(t, r.pr.paths, "a file already over the size cap must not be probed") assert.Empty(t, r.st.created, "a rejected upload creates no row") assert.Empty(t, r.sub.jobs) assert.Empty(t, r.tmpFiles(t), "the partial temp file must be removed") } func TestAccept_BodyExactlyAtTheLimitIsAccepted(t *testing.T) { r := newRig(t) _, err := r.in.Accept(context.Background(), "user-1", "edge", strings.NewReader(strings.Repeat("a", 1024))) require.NoError(t, err) } func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) { tests := []struct { name string probe uploads.Probe reason uploads.Reason }{ {"video with no audio", uploads.Probe{Duration: time.Minute, HasVideo: true}, uploads.ReasonNoAudio}, {"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong}, {"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := newRig(t) r.pr.probe = tt.probe _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data")) var rej *uploads.RejectError require.True(t, errors.As(err, &rej)) assert.Equal(t, tt.reason, rej.Reason) assert.Empty(t, r.st.created) assert.Empty(t, r.sub.jobs) assert.Empty(t, r.tmpFiles(t)) }) } } func TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError(t *testing.T) { t.Run("ffprobe says the file is not media", func(t *testing.T) { r := newRig(t) r.pr.err = uploads.ErrUnreadable _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("not media")) var rej *uploads.RejectError require.True(t, errors.As(err, &rej), "the user's file is the problem: tell them") assert.Equal(t, uploads.ReasonUnreadable, rej.Reason) assert.Empty(t, r.tmpFiles(t)) }) t.Run("the prober itself broke", func(t *testing.T) { r := newRig(t) r.pr.err = errors.New("exec: ffprobe: executable file not found") _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data")) require.Error(t, err) var rej *uploads.RejectError assert.False(t, errors.As(err, &rej), "our fault must not be reported as the user's bad file") assert.Empty(t, r.st.created) assert.Empty(t, r.tmpFiles(t)) }) } func TestAccept_ClientDisconnectMidUploadLeavesNothingBehind(t *testing.T) { r := newRig(t) body := io.MultiReader(strings.NewReader("partial"), iotest.ErrReader(errors.New("client went away"))) _, err := r.in.Accept(context.Background(), "user-1", "t", body) require.Error(t, err) assert.Empty(t, r.pr.paths) assert.Empty(t, r.st.created) assert.Empty(t, r.tmpFiles(t)) } func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T) { r := newRig(t) r.sub.err = errors.New("worker stopped") _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data")) require.Error(t, err) assert.Equal(t, domain.UploadErrInterrupted, r.st.failed["up-1"], "a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids") assert.Empty(t, r.tmpFiles(t)) } func TestAccept_AFileWithAVideoTrackIsAcceptedAndRecordsThatItHadOne(t *testing.T) { tests := []struct { name string probe uploads.Probe wantVideo bool }{ {"audio only", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}, false}, {"audio with a video track (a Teams meeting with the camera off)", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true, HasVideo: true}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := newRig(t) r.pr.probe = tt.probe id, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data")) require.NoError(t, err) assert.Equal(t, "up-1", id) assert.Equal(t, []bool{tt.wantVideo}, r.st.hadVideo, "the row must say whether a video track was present, so the page can say it was ignored") assert.Len(t, r.sub.jobs, 1, "it is queued like any other recording") }) } }