rdev/internal/adapter/postgres/worker_registry_test.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:05:28 -07:00

322 lines
8.6 KiB
Go

package postgres
import (
"context"
"database/sql"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/testutil"
)
func cleanupTestWorkers(t *testing.T, db *sql.DB) {
t.Helper()
_, err := db.Exec("DELETE FROM workers WHERE id LIKE 'test-%'")
if err != nil {
t.Logf("cleanup test workers: %v", err)
}
}
func TestWorkerRegistryRepository_Register(t *testing.T) {
db := testutil.TestDB(t)
t.Cleanup(func() { cleanupTestWorkers(t, db) })
repo := NewWorkerRegistryRepository(db)
ctx := context.Background()
t.Run("registers new worker", func(t *testing.T) {
worker := &domain.Worker{
ID: "test-worker-reg-1",
Hostname: "host-1",
Capabilities: []string{"build", "test"},
Version: "1.0.0",
}
err := repo.Register(ctx, worker)
if err != nil {
t.Fatalf("Register() error = %v", err)
}
// Verify worker was stored
got, err := repo.Get(ctx, "test-worker-reg-1")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.Hostname != "host-1" {
t.Errorf("got hostname %q, want %q", got.Hostname, "host-1")
}
if got.Status != domain.WorkerStatusIdle {
t.Errorf("got status %q, want %q", got.Status, domain.WorkerStatusIdle)
}
if got.Version != "1.0.0" {
t.Errorf("got version %q, want %q", got.Version, "1.0.0")
}
})
t.Run("re-registers existing worker", func(t *testing.T) {
worker := &domain.Worker{
ID: "test-worker-reg-2",
Hostname: "host-2-old",
Version: "1.0.0",
}
if err := repo.Register(ctx, worker); err != nil {
t.Fatalf("Register() error = %v", err)
}
// Update hostname via re-registration
worker.Hostname = "host-2-new"
worker.Version = "2.0.0"
if err := repo.Register(ctx, worker); err != nil {
t.Fatalf("Register() re-register error = %v", err)
}
got, err := repo.Get(ctx, "test-worker-reg-2")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.Hostname != "host-2-new" {
t.Errorf("got hostname %q, want %q", got.Hostname, "host-2-new")
}
if got.Status != domain.WorkerStatusIdle {
t.Errorf("got status %q, want %q (should reset on re-register)", got.Status, domain.WorkerStatusIdle)
}
})
}
func TestWorkerRegistryRepository_Heartbeat(t *testing.T) {
db := testutil.TestDB(t)
t.Cleanup(func() { cleanupTestWorkers(t, db) })
repo := NewWorkerRegistryRepository(db)
ctx := context.Background()
t.Run("updates heartbeat", func(t *testing.T) {
worker := &domain.Worker{
ID: "test-worker-hb-1",
Hostname: "host-1",
}
if err := repo.Register(ctx, worker); err != nil {
t.Fatalf("Register() error = %v", err)
}
// Wait a moment so heartbeat time differs
time.Sleep(10 * time.Millisecond)
if err := repo.Heartbeat(ctx, "test-worker-hb-1"); err != nil {
t.Fatalf("Heartbeat() error = %v", err)
}
})
t.Run("returns error for nonexistent worker", func(t *testing.T) {
err := repo.Heartbeat(ctx, "test-worker-nonexistent")
if err == nil {
t.Error("expected error for nonexistent worker")
}
})
}
func TestWorkerRegistryRepository_UpdateStatus(t *testing.T) {
db := testutil.TestDB(t)
t.Cleanup(func() { cleanupTestWorkers(t, db) })
repo := NewWorkerRegistryRepository(db)
ctx := context.Background()
// Register a worker
worker := &domain.Worker{
ID: "test-worker-status-1",
Hostname: "host-1",
}
if err := repo.Register(ctx, worker); err != nil {
t.Fatalf("Register() error = %v", err)
}
t.Run("updates to busy with task", func(t *testing.T) {
err := repo.UpdateStatus(ctx, "test-worker-status-1", domain.WorkerStatusBusy, "task-123")
if err != nil {
t.Fatalf("UpdateStatus() error = %v", err)
}
got, err := repo.Get(ctx, "test-worker-status-1")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.Status != domain.WorkerStatusBusy {
t.Errorf("got status %q, want %q", got.Status, domain.WorkerStatusBusy)
}
if got.CurrentTask != "task-123" {
t.Errorf("got current_task %q, want %q", got.CurrentTask, "task-123")
}
})
t.Run("updates to idle clearing task", func(t *testing.T) {
err := repo.UpdateStatus(ctx, "test-worker-status-1", domain.WorkerStatusIdle, "")
if err != nil {
t.Fatalf("UpdateStatus() error = %v", err)
}
got, err := repo.Get(ctx, "test-worker-status-1")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.Status != domain.WorkerStatusIdle {
t.Errorf("got status %q, want %q", got.Status, domain.WorkerStatusIdle)
}
if got.CurrentTask != "" {
t.Errorf("got current_task %q, want empty", got.CurrentTask)
}
})
t.Run("returns error for nonexistent worker", func(t *testing.T) {
err := repo.UpdateStatus(ctx, "test-worker-nonexistent", domain.WorkerStatusBusy, "")
if err == nil {
t.Error("expected error for nonexistent worker")
}
})
}
func TestWorkerRegistryRepository_Deregister(t *testing.T) {
db := testutil.TestDB(t)
t.Cleanup(func() { cleanupTestWorkers(t, db) })
repo := NewWorkerRegistryRepository(db)
ctx := context.Background()
t.Run("deregisters existing worker", func(t *testing.T) {
worker := &domain.Worker{
ID: "test-worker-dereg-1",
Hostname: "host-1",
}
if err := repo.Register(ctx, worker); err != nil {
t.Fatalf("Register() error = %v", err)
}
err := repo.Deregister(ctx, "test-worker-dereg-1")
if err != nil {
t.Fatalf("Deregister() error = %v", err)
}
// Verify worker was removed
_, err = repo.Get(ctx, "test-worker-dereg-1")
if err == nil {
t.Error("expected error for deregistered worker")
}
})
t.Run("returns error for nonexistent worker", func(t *testing.T) {
err := repo.Deregister(ctx, "test-worker-nonexistent")
if err == nil {
t.Error("expected error for nonexistent worker")
}
})
}
func TestWorkerRegistryRepository_List(t *testing.T) {
db := testutil.TestDB(t)
t.Cleanup(func() { cleanupTestWorkers(t, db) })
repo := NewWorkerRegistryRepository(db)
ctx := context.Background()
// Register workers
workers := []*domain.Worker{
{ID: "test-worker-list-1", Hostname: "host-1"},
{ID: "test-worker-list-2", Hostname: "host-2"},
{ID: "test-worker-list-3", Hostname: "host-3"},
}
for _, w := range workers {
if err := repo.Register(ctx, w); err != nil {
t.Fatalf("Register() error = %v", err)
}
}
// Make one busy
if err := repo.UpdateStatus(ctx, "test-worker-list-2", domain.WorkerStatusBusy, "task-1"); err != nil {
t.Fatalf("UpdateStatus() error = %v", err)
}
t.Run("lists all workers", func(t *testing.T) {
got, err := repo.List(ctx, port.WorkerFilter{})
if err != nil {
t.Fatalf("List() error = %v", err)
}
// Filter to just our test workers
count := 0
for _, w := range got {
if w.ID == "test-worker-list-1" || w.ID == "test-worker-list-2" || w.ID == "test-worker-list-3" {
count++
}
}
if count < 3 {
t.Errorf("expected at least 3 test workers, got %d", count)
}
})
t.Run("filters by status", func(t *testing.T) {
idle := domain.WorkerStatusIdle
got, err := repo.List(ctx, port.WorkerFilter{Status: &idle})
if err != nil {
t.Fatalf("List() error = %v", err)
}
for _, w := range got {
if w.Status != domain.WorkerStatusIdle {
t.Errorf("got worker with status %q, want only idle", w.Status)
}
}
})
t.Run("respects limit", func(t *testing.T) {
got, err := repo.List(ctx, port.WorkerFilter{Limit: 1})
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(got) > 1 {
t.Errorf("got %d workers, want at most 1", len(got))
}
})
}
func TestWorkerRegistryRepository_MarkStaleOffline(t *testing.T) {
db := testutil.TestDB(t)
t.Cleanup(func() { cleanupTestWorkers(t, db) })
repo := NewWorkerRegistryRepository(db)
ctx := context.Background()
// Register a worker
worker := &domain.Worker{
ID: "test-worker-stale-1",
Hostname: "host-1",
}
if err := repo.Register(ctx, worker); err != nil {
t.Fatalf("Register() error = %v", err)
}
t.Run("marks stale worker offline", func(t *testing.T) {
// Set heartbeat to past
_, err := db.Exec("UPDATE workers SET last_heartbeat = $1 WHERE id = $2",
time.Now().Add(-5*time.Minute), "test-worker-stale-1")
if err != nil {
t.Fatalf("set heartbeat: %v", err)
}
count, err := repo.MarkStaleOffline(ctx, 90*time.Second)
if err != nil {
t.Fatalf("MarkStaleOffline() error = %v", err)
}
if count < 1 {
t.Errorf("expected at least 1 worker marked offline, got %d", count)
}
got, err := repo.Get(ctx, "test-worker-stale-1")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.Status != domain.WorkerStatusOffline {
t.Errorf("got status %q, want %q", got.Status, domain.WorkerStatusOffline)
}
})
}