From 256e5b1a66aa99925ed68b2bedcb91fab1da1451 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 08:51:04 +0200 Subject: [PATCH] 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 --- .../uploaded_transcripts_progress_test.go | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 internal/adapters/store/uploaded_transcripts_progress_test.go diff --git a/internal/adapters/store/uploaded_transcripts_progress_test.go b/internal/adapters/store/uploaded_transcripts_progress_test.go new file mode 100644 index 0000000..3456df2 --- /dev/null +++ b/internal/adapters/store/uploaded_transcripts_progress_test.go @@ -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") +}