package store import ( "context" "errors" "testing" "time" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) func createTestUser(t *testing.T, s *Store, id string) { t.Helper() ctx := context.Background() _, err := s.CreateUser(ctx, id, model.DefaultQuota()) if err != nil { t.Fatalf("create test user %q: %v", id, err) } } func newTestRunner(user, id string) *model.Runner { return &model.Runner{ ID: id, User: user, RepoURL: "ilia/test-repo", Branch: "main", Tools: "go", Task: "implement feature", Status: model.RunnerStatusReceived, CPUReq: "2", MemReq: "4Gi", CreatedAt: time.Now().UTC(), } } func cleanRunners(t *testing.T) { t.Helper() ctx := context.Background() if _, err := testPool.Exec(ctx, "DELETE FROM runners"); err != nil { t.Fatalf("clean runners: %v", err) } } func TestCreateRunner(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-abc123") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create runner: %v", err) } got, err := s.GetRunner(ctx, "runner-abc123") if err != nil { t.Fatalf("get runner: %v", err) } if got.ID != "runner-abc123" { t.Errorf("got id %q, want %q", got.ID, "runner-abc123") } if got.User != "alice" { t.Errorf("got user %q, want %q", got.User, "alice") } if got.Status != model.RunnerStatusReceived { t.Errorf("got status %q, want %q", got.Status, model.RunnerStatusReceived) } if got.RepoURL != "ilia/test-repo" { t.Errorf("got repo %q, want %q", got.RepoURL, "ilia/test-repo") } } func TestCreateRunner_Duplicate(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-dup1") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("first create: %v", err) } err := s.CreateRunner(ctx, r) if err == nil { t.Fatal("expected error for duplicate runner") } if !errors.Is(err, ErrDuplicate) { t.Errorf("got %v, want ErrDuplicate", err) } } func TestGetRunner_NotFound(t *testing.T) { s := newTestStore(t) cleanRunners(t) ctx := context.Background() _, err := s.GetRunner(ctx, "nonexistent") if err == nil { t.Fatal("expected error for nonexistent runner") } if !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } } func TestListRunners(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") createTestUser(t, s, "bob") ctx := context.Background() r1 := newTestRunner("alice", "runner-list1") r2 := newTestRunner("alice", "runner-list2") r3 := newTestRunner("bob", "runner-list3") for _, r := range []*model.Runner{r1, r2, r3} { if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create runner %s: %v", r.ID, err) } } all, err := s.ListRunners(ctx, "", "") if err != nil { t.Fatalf("list all: %v", err) } if len(all) != 3 { t.Fatalf("expected 3 runners, got %d", len(all)) } aliceRunners, err := s.ListRunners(ctx, "alice", "") if err != nil { t.Fatalf("list alice: %v", err) } if len(aliceRunners) != 2 { t.Fatalf("expected 2 runners for alice, got %d", len(aliceRunners)) } byStatus, err := s.ListRunners(ctx, "", "received") if err != nil { t.Fatalf("list by status: %v", err) } if len(byStatus) != 3 { t.Fatalf("expected 3 received runners, got %d", len(byStatus)) } } func TestUpdateRunnerStatus_ValidTransition(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-trans1") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create: %v", err) } if err := s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusPodCreating, ""); err != nil { t.Fatalf("transition to pod_creating: %v", err) } got, _ := s.GetRunner(ctx, r.ID) if got.Status != model.RunnerStatusPodCreating { t.Errorf("expected pod_creating, got %s", got.Status) } if err := s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusRunnerRegistered, "forgejo-42"); err != nil { t.Fatalf("transition to runner_registered: %v", err) } got, _ = s.GetRunner(ctx, r.ID) if got.Status != model.RunnerStatusRunnerRegistered { t.Errorf("expected runner_registered, got %s", got.Status) } if got.ForgejoRunnerID != "forgejo-42" { t.Errorf("expected forgejo_runner_id 'forgejo-42', got %q", got.ForgejoRunnerID) } } func TestUpdateRunnerStatus_InvalidTransition(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-invalid1") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create: %v", err) } err := s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusCompleted, "") if err == nil { t.Fatal("expected error for invalid transition received -> completed") } } func TestUpdateRunnerStatus_SetsClaimedAt(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-claimed1") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create: %v", err) } _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusPodCreating, "") _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusRunnerRegistered, "") _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusJobClaimed, "") got, _ := s.GetRunner(ctx, r.ID) if got.ClaimedAt == nil { t.Error("expected claimed_at to be set") } } func TestUpdateRunnerStatus_SetsCompletedAt(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-complete1") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create: %v", err) } _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusPodCreating, "") _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusRunnerRegistered, "") _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusJobClaimed, "") _ = s.UpdateRunnerStatus(ctx, r.ID, model.RunnerStatusCompleted, "") got, _ := s.GetRunner(ctx, r.ID) if got.CompletedAt == nil { t.Error("expected completed_at to be set") } } func TestDeleteRunner(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r := newTestRunner("alice", "runner-del1") if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create: %v", err) } if err := s.DeleteRunner(ctx, r.ID); err != nil { t.Fatalf("delete: %v", err) } _, err := s.GetRunner(ctx, r.ID) if !errors.Is(err, ErrNotFound) { t.Errorf("expected ErrNotFound after delete, got %v", err) } } func TestDeleteRunner_NotFound(t *testing.T) { s := newTestStore(t) cleanRunners(t) ctx := context.Background() err := s.DeleteRunner(ctx, "nonexistent") if !errors.Is(err, ErrNotFound) { t.Errorf("expected ErrNotFound, got %v", err) } } func TestIsDeliveryProcessed(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() isDupe, err := s.IsDeliveryProcessed(ctx, "delivery-1") if err != nil { t.Fatalf("check delivery: %v", err) } if isDupe { t.Error("expected delivery-1 to not be processed yet") } r := newTestRunner("alice", "runner-dedupe1") r.WebhookDeliveryID = "delivery-1" if err := s.CreateRunner(ctx, r); err != nil { t.Fatalf("create: %v", err) } isDupe, err = s.IsDeliveryProcessed(ctx, "delivery-1") if err != nil { t.Fatalf("check delivery after create: %v", err) } if !isDupe { t.Error("expected delivery-1 to be processed") } } func TestIsDeliveryProcessed_EmptyID(t *testing.T) { s := newTestStore(t) cleanRunners(t) ctx := context.Background() isDupe, err := s.IsDeliveryProcessed(ctx, "") if err != nil { t.Fatalf("check empty delivery: %v", err) } if isDupe { t.Error("expected empty delivery ID to return false") } } func TestWebhookDeliveryDedupe_UniqueConstraint(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() r1 := newTestRunner("alice", "runner-uniq1") r1.WebhookDeliveryID = "webhook-abc" if err := s.CreateRunner(ctx, r1); err != nil { t.Fatalf("first insert: %v", err) } r2 := newTestRunner("alice", "runner-uniq2") r2.WebhookDeliveryID = "webhook-abc" err := s.CreateRunner(ctx, r2) if err == nil { t.Fatal("expected duplicate error for same webhook_delivery_id") } if !errors.Is(err, ErrDuplicate) { t.Errorf("expected ErrDuplicate, got %v", err) } } func TestGetStaleRunners(t *testing.T) { s := newTestStore(t) cleanRunners(t) createTestUser(t, s, "alice") ctx := context.Background() old := newTestRunner("alice", "runner-stale1") old.CreatedAt = time.Now().UTC().Add(-3 * time.Hour) if err := s.CreateRunner(ctx, old); err != nil { t.Fatalf("create old runner: %v", err) } fresh := newTestRunner("alice", "runner-fresh1") fresh.CreatedAt = time.Now().UTC() if err := s.CreateRunner(ctx, fresh); err != nil { t.Fatalf("create fresh runner: %v", err) } stale, err := s.GetStaleRunners(ctx, 2*time.Hour) if err != nil { t.Fatalf("get stale: %v", err) } if len(stale) != 1 { t.Fatalf("expected 1 stale runner, got %d", len(stale)) } if stale[0].ID != "runner-stale1" { t.Errorf("expected stale runner runner-stale1, got %s", stale[0].ID) } } func TestGenerateRunnerID(t *testing.T) { id1, err := GenerateRunnerID() if err != nil { t.Fatalf("generate: %v", err) } if len(id1) == 0 { t.Error("expected non-empty ID") } id2, _ := GenerateRunnerID() if id1 == id2 { t.Error("expected unique IDs") } if id1[:7] != "runner-" { t.Errorf("expected runner- prefix, got %s", id1[:7]) } }