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)) beatAt := at.Add(90 * time.Second) // deliberately not the database's NOW() require.NoError(t, s.Heartbeat(ctx, userA, id, beatAt)) got, err := s.GetUpload(ctx, userA, id) require.NoError(t, err) assert.True(t, got.AliveAt.Equal(beatAt), "the heartbeat is stamped with the application's clock, the same one progress_at uses, not the database's NOW() (#51)") 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, time.Now()) 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") }