Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
241 lines
7.2 KiB
Go
241 lines
7.2 KiB
Go
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, Duration: 10 * time.Minute}, job,
|
|
"the job carries what the file CLAIMED, so the worker can compare it with what was converted")
|
|
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")
|
|
})
|
|
}
|
|
}
|