287 lines
11 KiB
Go
287 lines
11 KiB
Go
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", false)
|
|
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", false)
|
|
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", false)
|
|
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", false)
|
|
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", false)
|
|
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", false)
|
|
require.NoError(t, err)
|
|
second, err := s.CreateUpload(ctx, userA, "second", false)
|
|
require.NoError(t, err)
|
|
_, err = s.CreateUpload(ctx, userB, "B's", false)
|
|
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", false)
|
|
require.NoError(t, err)
|
|
running, err := s.CreateUpload(ctx, userB, "running", false)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.StartTranscribing(ctx, userB, running))
|
|
done, err := s.CreateUpload(ctx, userA, "done", false)
|
|
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)
|
|
}
|
|
|
|
func TestCreateUpload_RecordsWhetherTheFileHadAVideoTrack(t *testing.T) {
|
|
s := newStore(t)
|
|
seedUsers(t, userA)
|
|
ctx := context.Background()
|
|
withVideo, err := s.CreateUpload(ctx, userA, "camera off", true)
|
|
require.NoError(t, err)
|
|
audioOnly, err := s.CreateUpload(ctx, userA, "voice only", false)
|
|
require.NoError(t, err)
|
|
|
|
gotVideo, err := s.GetUpload(ctx, userA, withVideo)
|
|
require.NoError(t, err)
|
|
gotAudio, err := s.GetUpload(ctx, userA, audioOnly)
|
|
require.NoError(t, err)
|
|
listed, err := s.ListUploads(ctx, userA)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, gotVideo.HadVideo, "the page must be able to say the video was ignored")
|
|
assert.False(t, gotAudio.HadVideo)
|
|
byID := map[string]bool{}
|
|
for _, u := range listed {
|
|
byID[u.ID] = u.HadVideo
|
|
}
|
|
assert.Equal(t, map[string]bool{withVideo: true, audioOnly: false}, byID, "the list carries it too")
|
|
}
|
|
|
|
func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
|
|
s := newStore(t)
|
|
seedUsers(t, userA)
|
|
ctx := context.Background()
|
|
done, err := s.CreateUpload(ctx, userA, "d", true)
|
|
require.NoError(t, err)
|
|
failed, err := s.CreateUpload(ctx, userA, "f", true)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.StartTranscribing(ctx, userA, done))
|
|
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "x", Endpoint: "e"}))
|
|
require.NoError(t, s.FailUpload(ctx, userA, failed, domain.UploadErrTranscriptionFailed))
|
|
|
|
for _, id := range []string{done, failed} {
|
|
got, err := s.GetUpload(ctx, userA, id)
|
|
require.NoError(t, err)
|
|
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
|
|
}
|
|
}
|
|
|
|
func TestUploadLifecycle_ReconciliationRoundTripsWithItsContestedSpots(t *testing.T) {
|
|
s := newStore(t)
|
|
seedUsers(t, userA)
|
|
ctx := context.Background()
|
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
|
want := &domain.Reconciliation{
|
|
Status: domain.ReconcileDone, Base: "iguana/pianissimo", Alt: "iguana/kb-whisper", Reconciler: "iguana/gemma4-26b",
|
|
Disputes: 380, AutoResolved: 90, Adjudicated: 285, Changed: 120, Unsure: 5, Errors: 0,
|
|
Contested: []domain.ContestedSpot{
|
|
{Seconds: 2210.5, Base: "Axel", Alt: "Alex", Used: "alt"},
|
|
{Seconds: 3400, Base: "A+2", Alt: "en A", Used: "base", Unsure: true},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: want}))
|
|
|
|
got, err := s.GetUpload(ctx, userA, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got.Reconciliation)
|
|
}
|
|
|
|
func TestUploadLifecycle_ASingleEngineTranscriptHasNoReconciliation(t *testing.T) {
|
|
s := newStore(t)
|
|
seedUsers(t, userA)
|
|
ctx := context.Background()
|
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
|
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
|
|
|
|
got, err := s.GetUpload(ctx, userA, id)
|
|
|
|
require.NoError(t, err)
|
|
assert.Nil(t, got.Reconciliation, "no reconciliation is not an empty one: the page must not claim a second opinion")
|
|
}
|
|
|
|
func TestUploadLifecycle_ASkippedReconciliationKeepsItsReason(t *testing.T) {
|
|
s := newStore(t)
|
|
seedUsers(t, userA)
|
|
ctx := context.Background()
|
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
|
rec := &domain.Reconciliation{Status: domain.ReconcileSkipped, Reason: "the model failed 3 times in a row: gateway down", Errors: 3}
|
|
|
|
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: rec}))
|
|
|
|
got, err := s.GetUpload(ctx, userA, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, rec, got.Reconciliation)
|
|
}
|