package store_test import ( "context" "testing" "time" "vetting/internal/model" "vetting/internal/store" ) // TestSubStepsUpsertAndList covers the two-phase life cycle the agent // is meant to exercise: an initial "started" upsert with state=running // and started_at set, then a terminal upsert that flips state to passed // and fills completed_at without clobbering started_at. Ordering falls // out of the (stage_name, ordinal) index — ListForRun must return rows // in that deterministic order even when they were inserted out of order. func TestSubStepsUpsertAndList(t *testing.T) { runs := newDB(t) _, runID := seedRun(t, runs) ss := &store.SubSteps{DB: runs.DB} ctx := context.Background() start := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC) end := start.Add(3 * time.Minute) // Insert CPUStress ordinals out of order to prove ListForRun orders // by (stage, ordinal) rather than insertion time. if err := ss.Upsert(ctx, model.SubStep{ RunID: runID, StageName: "CPUStress", Ordinal: 1, Name: "Memory pass", State: model.StageRunning, StartedAt: &start, }); err != nil { t.Fatalf("upsert CPUStress/1 running: %v", err) } if err := ss.Upsert(ctx, model.SubStep{ RunID: runID, StageName: "CPUStress", Ordinal: 0, Name: "CPU pass", State: model.StagePassed, StartedAt: &start, CompletedAt: &end, SummaryJSON: `{"elapsed_secs":180}`, }); err != nil { t.Fatalf("upsert CPUStress/0 passed: %v", err) } // Terminal update for ordinal 1 — only completed_at + state; started_at // intentionally omitted so COALESCE preserves the original value. if err := ss.Upsert(ctx, model.SubStep{ RunID: runID, StageName: "CPUStress", Ordinal: 1, Name: "Memory pass", State: model.StagePassed, CompletedAt: &end, }); err != nil { t.Fatalf("upsert CPUStress/1 passed: %v", err) } list, err := ss.ListForRun(ctx, runID) if err != nil { t.Fatalf("ListForRun: %v", err) } if len(list) != 2 { t.Fatalf("got %d rows, want 2", len(list)) } if list[0].Name != "CPU pass" || list[1].Name != "Memory pass" { t.Fatalf("order: %+v", list) } if list[1].State != model.StagePassed { t.Fatalf("memory pass state = %q, want passed", list[1].State) } if list[1].StartedAt == nil { t.Fatalf("memory pass started_at was wiped by terminal upsert") } if !list[1].StartedAt.Equal(start) { t.Fatalf("started_at = %v, want %v", list[1].StartedAt, start) } if list[0].SummaryJSON != `{"elapsed_secs":180}` { t.Fatalf("CPU pass summary = %q, want {\"elapsed_secs\":180}", list[0].SummaryJSON) } // ListForStage scopes to a single stage. only, err := ss.ListForStage(ctx, runID, "CPUStress") if err != nil { t.Fatalf("ListForStage: %v", err) } if len(only) != 2 { t.Fatalf("ListForStage CPUStress: got %d, want 2", len(only)) } other, err := ss.ListForStage(ctx, runID, "Storage") if err != nil { t.Fatalf("ListForStage Storage: %v", err) } if len(other) != 0 { t.Fatalf("ListForStage Storage should be empty, got %d", len(other)) } } // TestSubStepsSummaryPreserveOnDefault verifies the intentional special- // case: when the second upsert supplies the default empty-object summary // ('{}'), the prior non-default summary is kept. This lets a "complete" // call that only carries timing update the row without wiping the rich // summary the "start" call persisted. func TestSubStepsSummaryPreserveOnDefault(t *testing.T) { runs := newDB(t) _, runID := seedRun(t, runs) ss := &store.SubSteps{DB: runs.DB} ctx := context.Background() if err := ss.Upsert(ctx, model.SubStep{ RunID: runID, StageName: "SMART", Ordinal: 0, Name: "smartctl /dev/sda", State: model.StageRunning, SummaryJSON: `{"device":"/dev/sda"}`, }); err != nil { t.Fatalf("first upsert: %v", err) } // Terminal with empty summary — should not blow away the device field. if err := ss.Upsert(ctx, model.SubStep{ RunID: runID, StageName: "SMART", Ordinal: 0, Name: "smartctl /dev/sda", State: model.StagePassed, }); err != nil { t.Fatalf("terminal upsert: %v", err) } got, err := ss.Get(ctx, runID, "SMART", 0) if err != nil { t.Fatalf("Get: %v", err) } if got.SummaryJSON != `{"device":"/dev/sda"}` { t.Fatalf("summary wiped: %q", got.SummaryJSON) } if got.State != model.StagePassed { t.Fatalf("state = %q, want passed", got.State) } }