test(store): progress and heartbeat on an upload row -- round trip, alive is not progress, another user's write changes nothing (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress and heartbeat of an upload job (ADR-035, migration 020). Like the other
|
||||||
|
// upload store tests these run as the superuser, so the cross-user cases prove the
|
||||||
|
// statement's own user_id predicate; the database-enforced isolation is covered by the
|
||||||
|
// RLS tests, and migration 020 adds columns to the already-FORCE'd table, not a table.
|
||||||
|
|
||||||
|
func startedUpload(t *testing.T, s *store.Store) string {
|
||||||
|
t.Helper()
|
||||||
|
seedUsers(t, userA, userB)
|
||||||
|
id, err := s.CreateUpload(context.Background(), userA, "m", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_AFreshUploadHasNoProgressAndNoHeartbeat(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
|
||||||
|
got, err := s.GetUpload(context.Background(), userA, id)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got.Progress)
|
||||||
|
assert.True(t, got.ProgressAt.IsZero())
|
||||||
|
assert.True(t, got.AliveAt.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_StepsAndTheirTimesRoundTripInOrder(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
|
||||||
|
steps := []domain.ProgressStep{
|
||||||
|
{Key: "audio", Label: "Preparing audio", State: domain.StepDone, StartedAt: at, FinishedAt: at.Add(40 * time.Second)},
|
||||||
|
{Key: "decide", Label: "Deciding", State: domain.StepRunning, StartedAt: at.Add(time.Minute), Done: 12, Total: 282},
|
||||||
|
{Key: "save", Label: "Saving", State: domain.StepPending},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, s.SaveProgress(ctx, userA, id, steps, at.Add(2*time.Minute)))
|
||||||
|
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got.Progress, 3)
|
||||||
|
assert.Equal(t, []string{"audio", "decide", "save"}, []string{got.Progress[0].Key, got.Progress[1].Key, got.Progress[2].Key})
|
||||||
|
assert.Equal(t, domain.StepRunning, got.Progress[1].State)
|
||||||
|
assert.Equal(t, 12, got.Progress[1].Done)
|
||||||
|
assert.Equal(t, 282, got.Progress[1].Total)
|
||||||
|
assert.True(t, got.Progress[0].FinishedAt.Equal(at.Add(40*time.Second)), "a step's finish time must survive the round trip")
|
||||||
|
assert.True(t, got.Progress[2].StartedAt.IsZero(), "a pending step has no start time, not a zero date")
|
||||||
|
assert.True(t, got.ProgressAt.Equal(at.Add(2*time.Minute)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
|
||||||
|
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
|
||||||
|
before := time.Now().Add(-time.Second)
|
||||||
|
|
||||||
|
require.NoError(t, s.Heartbeat(ctx, userA, id))
|
||||||
|
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now")
|
||||||
|
assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_ASecondSaveReplacesTheFirst(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
|
||||||
|
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, at))
|
||||||
|
|
||||||
|
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}, {Key: "b", Label: "B", State: domain.StepRunning}}, at.Add(time.Minute)))
|
||||||
|
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got.Progress, 2)
|
||||||
|
assert.Equal(t, domain.StepDone, got.Progress[0].State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
|
||||||
|
errBeat := s.Heartbeat(ctx, userB, id)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
|
||||||
|
assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got.Progress, "B's write must not have reached A's upload")
|
||||||
|
assert.True(t, got.AliveAt.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_ListDoesNotDragItAlong(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, time.Now()))
|
||||||
|
|
||||||
|
rows, err := s.ListUploads(ctx, userA)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
assert.Empty(t, rows[0].Progress, "the list view needs a status, not every row's phases")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_SurvivesCompletion(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
id := startedUpload(t, s)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
||||||
|
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}}, time.Now()))
|
||||||
|
|
||||||
|
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)
|
||||||
|
require.Len(t, got.Progress, 1, "a finished upload keeps its record of what was done, and how long each step took")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user