Root cause: WorkerService.ClaimTask() was modifying the audit entry in memory but never persisting it to the database. This caused build tasks to remain stuck at "pending" status even after being claimed. Changes: - Add UpdateStatus method to port.BuildAudit interface - Implement UpdateStatus in postgres.BuildAuditRepository - Fix ClaimTask to call audit.UpdateStatus() to persist status - Add test coverage for audit update during task claim - Update all mock implementations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
334 lines
9.1 KiB
Go
334 lines
9.1 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 cleanupTestBuildAudit(t *testing.T, db *sql.DB) {
|
|
t.Helper()
|
|
_, err := db.Exec("DELETE FROM build_audit WHERE project_id LIKE 'test-%'")
|
|
if err != nil {
|
|
t.Logf("cleanup test build_audit: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAuditRepository_Record(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestBuildAudit(t, db) })
|
|
|
|
repo := NewBuildAuditRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("records new audit entry", func(t *testing.T) {
|
|
entry := &domain.BuildAuditEntry{
|
|
TaskID: "test-task-audit-1",
|
|
ProjectID: "test-project-1",
|
|
Spec: domain.BuildSpec{
|
|
Prompt: "Build a landing page",
|
|
Template: "nextjs",
|
|
},
|
|
Status: domain.BuildStatusPending,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
err := repo.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
|
|
// Verify it was stored
|
|
got, err := repo.Get(ctx, "test-task-audit-1")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if got.ProjectID != "test-project-1" {
|
|
t.Errorf("got project_id %q, want %q", got.ProjectID, "test-project-1")
|
|
}
|
|
if got.Spec.Prompt != "Build a landing page" {
|
|
t.Errorf("got prompt %q, want %q", got.Spec.Prompt, "Build a landing page")
|
|
}
|
|
if got.Status != domain.BuildStatusPending {
|
|
t.Errorf("got status %q, want %q", got.Status, domain.BuildStatusPending)
|
|
}
|
|
})
|
|
|
|
t.Run("records entry with worker ID", func(t *testing.T) {
|
|
entry := &domain.BuildAuditEntry{
|
|
TaskID: "test-task-audit-2",
|
|
ProjectID: "test-project-1",
|
|
WorkerID: "worker-1",
|
|
Spec: domain.BuildSpec{
|
|
Prompt: "Run tests",
|
|
},
|
|
Status: domain.BuildStatusRunning,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
err := repo.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, "test-task-audit-2")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if got.WorkerID != "worker-1" {
|
|
t.Errorf("got worker_id %q, want %q", got.WorkerID, "worker-1")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildAuditRepository_Update(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestBuildAudit(t, db) })
|
|
|
|
repo := NewBuildAuditRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create initial entry
|
|
entry := &domain.BuildAuditEntry{
|
|
TaskID: "test-task-update-1",
|
|
ProjectID: "test-project-1",
|
|
Spec: domain.BuildSpec{Prompt: "Build"},
|
|
Status: domain.BuildStatusPending,
|
|
StartedAt: time.Now(),
|
|
}
|
|
if err := repo.Record(ctx, entry); err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
|
|
t.Run("updates with success result", func(t *testing.T) {
|
|
result := &domain.BuildResult{
|
|
Success: true,
|
|
Output: "Build successful",
|
|
CommitSHA: "abc123",
|
|
FilesChanged: []string{"index.html", "style.css"},
|
|
DurationMs: 5000,
|
|
}
|
|
|
|
err := repo.Update(ctx, "test-task-update-1", result)
|
|
if err != nil {
|
|
t.Fatalf("Update() error = %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, "test-task-update-1")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if got.Status != domain.BuildStatusCompleted {
|
|
t.Errorf("got status %q, want %q", got.Status, domain.BuildStatusCompleted)
|
|
}
|
|
if got.Result == nil {
|
|
t.Fatal("expected result to be set")
|
|
}
|
|
if !got.Result.Success {
|
|
t.Error("expected result.Success = true")
|
|
}
|
|
if got.Result.CommitSHA != "abc123" {
|
|
t.Errorf("got commit_sha %q, want %q", got.Result.CommitSHA, "abc123")
|
|
}
|
|
if got.CompletedAt == nil {
|
|
t.Error("expected completed_at to be set")
|
|
}
|
|
})
|
|
|
|
t.Run("updates with failure result", func(t *testing.T) {
|
|
// Create a new entry
|
|
entry := &domain.BuildAuditEntry{
|
|
TaskID: "test-task-update-2",
|
|
ProjectID: "test-project-1",
|
|
Spec: domain.BuildSpec{Prompt: "Build"},
|
|
Status: domain.BuildStatusPending,
|
|
StartedAt: time.Now(),
|
|
}
|
|
if err := repo.Record(ctx, entry); err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
|
|
result := &domain.BuildResult{
|
|
Success: false,
|
|
Error: "compilation error",
|
|
}
|
|
|
|
err := repo.Update(ctx, "test-task-update-2", result)
|
|
if err != nil {
|
|
t.Fatalf("Update() error = %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, "test-task-update-2")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if got.Status != domain.BuildStatusFailed {
|
|
t.Errorf("got status %q, want %q", got.Status, domain.BuildStatusFailed)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent task", func(t *testing.T) {
|
|
result := &domain.BuildResult{Success: true}
|
|
err := repo.Update(ctx, "test-task-nonexistent", result)
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent task")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildAuditRepository_UpdateStatus(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestBuildAudit(t, db) })
|
|
|
|
repo := NewBuildAuditRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create initial entry
|
|
entry := &domain.BuildAuditEntry{
|
|
TaskID: "test-task-status-1",
|
|
ProjectID: "test-project-1",
|
|
Spec: domain.BuildSpec{Prompt: "Build"},
|
|
Status: domain.BuildStatusPending,
|
|
StartedAt: time.Now(),
|
|
}
|
|
if err := repo.Record(ctx, entry); err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
|
|
t.Run("updates status and worker ID", func(t *testing.T) {
|
|
err := repo.UpdateStatus(ctx, "test-task-status-1", domain.BuildStatusRunning, "worker-123")
|
|
if err != nil {
|
|
t.Fatalf("UpdateStatus() error = %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, "test-task-status-1")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if got.Status != domain.BuildStatusRunning {
|
|
t.Errorf("got status %q, want %q", got.Status, domain.BuildStatusRunning)
|
|
}
|
|
if got.WorkerID != "worker-123" {
|
|
t.Errorf("got worker_id %q, want %q", got.WorkerID, "worker-123")
|
|
}
|
|
})
|
|
|
|
t.Run("updates status with empty worker ID", func(t *testing.T) {
|
|
// Create another entry
|
|
entry := &domain.BuildAuditEntry{
|
|
TaskID: "test-task-status-2",
|
|
ProjectID: "test-project-1",
|
|
WorkerID: "old-worker",
|
|
Spec: domain.BuildSpec{Prompt: "Build"},
|
|
Status: domain.BuildStatusRunning,
|
|
StartedAt: time.Now(),
|
|
}
|
|
if err := repo.Record(ctx, entry); err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
|
|
err := repo.UpdateStatus(ctx, "test-task-status-2", domain.BuildStatusCompleted, "")
|
|
if err != nil {
|
|
t.Fatalf("UpdateStatus() error = %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, "test-task-status-2")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if got.Status != domain.BuildStatusCompleted {
|
|
t.Errorf("got status %q, want %q", got.Status, domain.BuildStatusCompleted)
|
|
}
|
|
// WorkerID should be cleared when empty string is passed
|
|
if got.WorkerID != "" {
|
|
t.Errorf("got worker_id %q, want empty", got.WorkerID)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent task", func(t *testing.T) {
|
|
err := repo.UpdateStatus(ctx, "test-task-nonexistent", domain.BuildStatusRunning, "worker-1")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent task")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildAuditRepository_Get(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestBuildAudit(t, db) })
|
|
|
|
repo := NewBuildAuditRepository(db)
|
|
ctx := context.Background()
|
|
|
|
t.Run("returns error for nonexistent entry", func(t *testing.T) {
|
|
_, err := repo.Get(ctx, "test-task-nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent entry")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildAuditRepository_List(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { cleanupTestBuildAudit(t, db) })
|
|
|
|
repo := NewBuildAuditRepository(db)
|
|
ctx := context.Background()
|
|
|
|
// Create entries for different projects
|
|
entries := []*domain.BuildAuditEntry{
|
|
{TaskID: "test-task-list-1", ProjectID: "test-project-a", Spec: domain.BuildSpec{Prompt: "Build 1"}, Status: domain.BuildStatusCompleted, StartedAt: time.Now()},
|
|
{TaskID: "test-task-list-2", ProjectID: "test-project-a", Spec: domain.BuildSpec{Prompt: "Build 2"}, Status: domain.BuildStatusFailed, StartedAt: time.Now()},
|
|
{TaskID: "test-task-list-3", ProjectID: "test-project-b", Spec: domain.BuildSpec{Prompt: "Build 3"}, Status: domain.BuildStatusPending, StartedAt: time.Now()},
|
|
}
|
|
for _, e := range entries {
|
|
if err := repo.Record(ctx, e); err != nil {
|
|
t.Fatalf("Record() error = %v", err)
|
|
}
|
|
}
|
|
|
|
t.Run("filters by project", func(t *testing.T) {
|
|
got, err := repo.List(ctx, port.BuildAuditFilter{
|
|
ProjectID: "test-project-a",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Errorf("got %d entries, want 2", len(got))
|
|
}
|
|
})
|
|
|
|
t.Run("filters by status", func(t *testing.T) {
|
|
completed := domain.BuildStatusCompleted
|
|
got, err := repo.List(ctx, port.BuildAuditFilter{
|
|
Status: &completed,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
for _, e := range got {
|
|
if e.Status != domain.BuildStatusCompleted {
|
|
t.Errorf("got status %q, want only completed", e.Status)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("respects limit", func(t *testing.T) {
|
|
got, err := repo.List(ctx, port.BuildAuditFilter{
|
|
Limit: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
if len(got) > 1 {
|
|
t.Errorf("got %d entries, want at most 1", len(got))
|
|
}
|
|
})
|
|
}
|