feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user