diff --git a/internal/uploads/worker_duration_test.go b/internal/uploads/worker_duration_test.go new file mode 100644 index 0000000..a1c94d4 --- /dev/null +++ b/internal/uploads/worker_duration_test.go @@ -0,0 +1,116 @@ +package uploads_test + +import ( + "context" + "os" + "testing" + "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" +) + +// The duration limit is a claim in the file's header. The worker measures what was +// really converted and refuses both a recording that is longer than the limit (the +// header lied low) and one that ends far earlier than the file claimed (it was truncated +// and would otherwise yield a partial transcript with no warning) -- adversarial review F2. + +// sizedConverter writes a (sparse) 16 kHz mono PCM WAV of the given length, without +// actually allocating it: 32,000 bytes per second plus a 44-byte header. +type sizedConverter struct{ seconds float64 } + +func (c sizedConverter) ToWAV(_ context.Context, _, dst string) error { + f, err := os.Create(dst) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + return f.Truncate(44 + int64(c.seconds*32000)) +} + +func durationWorker(t *testing.T, r *wrig, wavSeconds float64, max time.Duration) *uploads.Worker { + t.Helper() + opts := []uploads.WorkerOption{} + if max > 0 { + opts = append(opts, uploads.WithMaxDuration(max)) + } + return uploads.NewWorker(r.st, sizedConverter{seconds: wavSeconds}, r.tr, r.tmp, time.Minute, opts...) +} + +func jobWith(r *wrig, t *testing.T, claimed time.Duration) uploads.Job { + j := r.spool(t, "a") + j.Duration = claimed + return j +} + +func TestWorkerDuration_AConvertedLengthOverTheLimitFailsTheJobBeforeAnyEngineSeesIt(t *testing.T) { + r := newWRig(t, time.Minute) + w := durationWorker(t, r, 100*60, 90*time.Minute) + + w.Process(context.Background(), jobWith(r, t, 10*time.Second)) + + _, failed := r.st.snapshot() + assert.Equal(t, domain.UploadErrTooLong, failed["a"], "a header that claimed 10 s must not let 100 minutes through") + assert.Empty(t, r.tr.paths, "no engine may be sent audio that is over the limit") + assert.Empty(t, r.leftovers(t), "the audio is still deleted") +} + +func TestWorkerDuration_JustOverTheLimitWithinTheSlackStillTranscribes(t *testing.T) { + r := newWRig(t, time.Minute) + w := durationWorker(t, r, 90*60+20, 90*time.Minute) + + w.Process(context.Background(), jobWith(r, t, 90*time.Minute)) + + completed, failed := r.st.snapshot() + assert.Empty(t, failed) + assert.Contains(t, completed, "a", "ffmpeg rounds: 20 s over a 90-minute limit is not a lie") +} + +func TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsRefusedNotHalfTranscribed(t *testing.T) { + r := newWRig(t, time.Minute) + w := durationWorker(t, r, 20*60, 90*time.Minute) + + w.Process(context.Background(), jobWith(r, t, 60*time.Minute)) + + _, failed := r.st.snapshot() + assert.Equal(t, domain.UploadErrIncomplete, failed["a"], "a recording truncated at a third must not return a third of a transcript with no warning") + assert.Empty(t, r.tr.paths) +} + +func TestWorkerDuration_AnEstimateThatIsMerelyALittleOffIsNotTruncation(t *testing.T) { + tests := []struct { + name string + claimed time.Duration + wav float64 + }{ + {"five percent short", 60 * time.Minute, 57 * 60}, + {"under two minutes short of a long file", 60 * time.Minute, 59 * 60}, + {"unknown claimed length", 0, 20 * 60}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newWRig(t, time.Minute) + w := durationWorker(t, r, tt.wav, 90*time.Minute) + + w.Process(context.Background(), jobWith(r, t, tt.claimed)) + + completed, failed := r.st.snapshot() + assert.Empty(t, failed) + assert.Contains(t, completed, "a") + }) + } +} + +func TestWorkerDuration_WithNoMaximumConfiguredNothingIsMeasured(t *testing.T) { + r := newWRig(t, time.Minute) + w := durationWorker(t, r, 100*60, 0) + + w.Process(context.Background(), jobWith(r, t, 0)) + + completed, _ := r.st.snapshot() + assert.Contains(t, completed, "a") + require.NotNil(t, w) +}