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 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user