Files
tapir/internal/web/uploads_logging_handler_test.go
T
mathias 4a2067063d
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s
feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
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>
2026-10-03 14:13:37 +00:00

73 lines
2.0 KiB
Go

package web_test
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Error paths of the upload routes log a kind, never the error's text, which can carry
// a file name, a path, a database value or a provider's output (adversarial review F5).
type lockedBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (l *lockedBuf) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuf) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.String()
}
type failingUploader struct{ err error }
func (f failingUploader) Accept(context.Context, string, string, io.Reader) (string, error) {
return "", f.err
}
func TestUploadPost_AnUnexpectedFailureLogsAKindAndNotItsText(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
app.Uploads = failingUploader{err: errors.New(`ffprobe: Error opening input file /spool/upload-555 for "zebrakaka-meeting.m4a"`)}
rec := do(t, app, uploadReq(t, "t", "zebrakaka-meeting.m4a", []byte("x")))
assert.Equal(t, http.StatusInternalServerError, rec.Code)
out := buf.String()
assert.NotContains(t, out, "zebrakaka", "a file name is private content")
assert.NotContains(t, out, "/spool/", "a spool path carries the upload's identity")
assert.Contains(t, out, "kind=error")
}
func TestUploadChatPost_AModelFailureLogsAKindAndNotItsText(t *testing.T) {
app, fc := newUploadChatApp(t)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
fc.err = errors.New("LLM returned 400: the prompt was zebrakaka secret words")
id := seedUpload(t, userID, "m", "done", meetingText)
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
require.Equal(t, http.StatusOK, rec.Code)
out := buf.String()
assert.NotContains(t, out, "zebrakaka")
assert.Contains(t, out, "kind=error")
}