From b98e9df46214762511872d66a0aedd05b6ad395f Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:40:03 +0200 Subject: [PATCH] test(uploads): Accept -- size cap, probe verdicts, cleanup on every failure path (red) Red: uploads.Job, Intake and ErrUnreadable are undefined. Co-Authored-By: Claude Sonnet 5.5 --- internal/uploads/intake_test.go | 212 ++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 internal/uploads/intake_test.go diff --git a/internal/uploads/intake_test.go b/internal/uploads/intake_test.go new file mode 100644 index 0000000..0fcafc1 --- /dev/null +++ b/internal/uploads/intake_test.go @@ -0,0 +1,212 @@ +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 + failed map[string]domain.UploadErrorKind + nextID string +} + +func (f *fakeStore) CreateUpload(_ context.Context, _, title string) (string, error) { + f.created = append(f.created, title) + 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", uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, uploads.ReasonVideo}, + {"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)) +}