rdev/internal/adapter/postgres/build_audit_test.go
jordan 4a18b1cd07 fix: Persist build audit status when worker claims task
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>
2026-01-29 21:25:04 -07:00

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))
}
})
}