From 4b557209c8cef73e2393452a7913c52f4e8fee1e Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:37:30 +0200 Subject: [PATCH] test(store): Store API for uploaded transcripts (red) Lifecycle queued -> transcribing -> done|failed, per-row serving endpoint, segments JSONB round trip, owner-only get/write (not-found, not forbidden), list without transcript text, and the start-up sweep that fails unfinished rows as `interrupted` for every user (audio is not retained, so no resume). Red: CreateUpload and friends are undefined. Co-Authored-By: Claude Sonnet 5.5 --- .../store/uploaded_transcripts_test.go | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 internal/adapters/store/uploaded_transcripts_test.go diff --git a/internal/adapters/store/uploaded_transcripts_test.go b/internal/adapters/store/uploaded_transcripts_test.go new file mode 100644 index 0000000..38d3726 --- /dev/null +++ b/internal/adapters/store/uploaded_transcripts_test.go @@ -0,0 +1,188 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/adapters/store" + "git.d-ma.be/mathias/tapir/internal/domain" +) + +// These exercise the Store API over uploaded_transcripts. The test Store connects +// as the superuser, which BYPASSES row-level security — so the cross-user cases +// here prove the query's own user_id predicate (defence in depth), while the +// database-enforced guarantee is proven separately, as the non-superuser app +// role, in uploaded_transcripts_isolation_test.go. Text is synthetic; this repo is +// public and no real transcript may appear in it (#33). + +func seedUsers(t *testing.T, ids ...string) { + t.Helper() + super := rawPool(t) + resetDB(t, super) + for _, id := range ids { + _, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, id) + require.NoError(t, err) + } +} + +func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) { + s := newStore(t) + seedUsers(t, userA) + ctx := context.Background() + + id, err := s.CreateUpload(ctx, userA, "Teams export") + require.NoError(t, err) + + got, err := s.GetUpload(ctx, userA, id) + require.NoError(t, err) + assert.Equal(t, id, got.ID) + assert.Equal(t, userA, got.UserID) + assert.Equal(t, "Teams export", got.Title) + assert.Equal(t, domain.UploadQueued, got.Status) + assert.Empty(t, got.Content) + assert.True(t, got.FinishedAt.IsZero(), "an unfinished upload has no finish time") +} + +func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testing.T) { + s := newStore(t) + seedUsers(t, userA) + ctx := context.Background() + id, err := s.CreateUpload(ctx, userA, "m") + require.NoError(t, err) + + require.NoError(t, s.StartTranscribing(ctx, userA, id)) + mid, err := s.GetUpload(ctx, userA, id) + require.NoError(t, err) + assert.Equal(t, domain.UploadTranscribing, mid.Status) + + want := domain.Transcription{ + Text: "God morgon allihopa. Vi går igenom läget.", + Segments: []domain.Segment{ + {Start: 0.0, End: 1.5, Text: "God morgon allihopa."}, + {Start: 1.5, End: 4.25, Text: "Vi går igenom läget."}, + }, + Endpoint: "iguana/kb-whisper", + AudioSeconds: 4.25, + } + require.NoError(t, s.CompleteUpload(ctx, userA, id, want)) + + got, err := s.GetUpload(ctx, userA, id) + require.NoError(t, err) + assert.Equal(t, domain.UploadDone, got.Status) + assert.Equal(t, want.Text, got.Content) + assert.Equal(t, want.Segments, got.Segments, "segment times must survive the JSONB round trip") + assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is a quality fact and is recorded per row") + assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9) + assert.False(t, got.FinishedAt.IsZero()) + assert.Empty(t, got.ErrorKind) +} + +func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) { + s := newStore(t) + seedUsers(t, userA) + ctx := context.Background() + id, err := s.CreateUpload(ctx, userA, "m") + require.NoError(t, err) + require.NoError(t, s.StartTranscribing(ctx, userA, id)) + + require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrTranscriptionFailed)) + + got, err := s.GetUpload(ctx, userA, id) + require.NoError(t, err) + assert.Equal(t, domain.UploadFailed, got.Status) + assert.Equal(t, domain.UploadErrTranscriptionFailed, got.ErrorKind) + assert.Empty(t, got.Content) + assert.False(t, got.FinishedAt.IsZero()) +} + +func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) { + s := newStore(t) + seedUsers(t, userA, userB) + ctx := context.Background() + id, err := s.CreateUpload(ctx, userA, "A's meeting") + require.NoError(t, err) + + _, err = s.GetUpload(ctx, userB, id) + + assert.ErrorIs(t, err, store.ErrUploadNotFound, + "a non-owner must get not-found, not forbidden: existence is not disclosed") +} + +func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) { + s := newStore(t) + seedUsers(t, userA, userB) + ctx := context.Background() + id, err := s.CreateUpload(ctx, userA, "A's meeting") + require.NoError(t, err) + + assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound) + assert.ErrorIs(t, s.CompleteUpload(ctx, userB, id, domain.Transcription{Text: "planted"}), store.ErrUploadNotFound) + assert.ErrorIs(t, s.FailUpload(ctx, userB, id, domain.UploadErrTranscriptionFailed), store.ErrUploadNotFound) + + got, err := s.GetUpload(ctx, userA, id) + require.NoError(t, err) + assert.Equal(t, domain.UploadQueued, got.Status, "B's writes must leave A's upload exactly as it was") + assert.Empty(t, got.Content) +} + +func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *testing.T) { + s := newStore(t) + seedUsers(t, userA, userB) + ctx := context.Background() + first, err := s.CreateUpload(ctx, userA, "first") + require.NoError(t, err) + second, err := s.CreateUpload(ctx, userA, "second") + require.NoError(t, err) + _, err = s.CreateUpload(ctx, userB, "B's") + require.NoError(t, err) + require.NoError(t, s.StartTranscribing(ctx, userA, first)) + require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"})) + + got, err := s.ListUploads(ctx, userA) + + require.NoError(t, err) + require.Len(t, got, 2, "A must see only A's uploads") + assert.Equal(t, second, got[0].ID, "newest first") + assert.Equal(t, first, got[1].ID) + assert.Empty(t, got[1].Content, "a list row must not drag the whole transcript along") + assert.Equal(t, domain.UploadDone, got[1].Status) +} + +func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T) { + s := newStore(t) + seedUsers(t, userA, userB) + super := rawPool(t) + ctx := context.Background() + for subject, uid := range map[string]string{"sub-a": userA, "sub-b": userB} { + _, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid) + require.NoError(t, err) + } + queued, err := s.CreateUpload(ctx, userA, "queued") + require.NoError(t, err) + running, err := s.CreateUpload(ctx, userB, "running") + require.NoError(t, err) + require.NoError(t, s.StartTranscribing(ctx, userB, running)) + done, err := s.CreateUpload(ctx, userA, "done") + require.NoError(t, err) + require.NoError(t, s.StartTranscribing(ctx, userA, done)) + require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"})) + + n, err := s.FailInterruptedUploads(ctx) + + require.NoError(t, err) + assert.Equal(t, 2, n, "the queued and the transcribing row, across both users") + for _, c := range []struct{ user, id string }{{userA, queued}, {userB, running}} { + got, err := s.GetUpload(ctx, c.user, c.id) + require.NoError(t, err) + assert.Equal(t, domain.UploadFailed, got.Status) + assert.Equal(t, domain.UploadErrInterrupted, got.ErrorKind, + "audio is not retained, so a restart cannot resume: the honest state is failed/interrupted") + } + kept, err := s.GetUpload(ctx, userA, done) + require.NoError(t, err) + assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep") + assert.Equal(t, "kept", kept.Content) +}