- 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>
216 lines
5.7 KiB
Go
216 lines
5.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
func TestBuildService_StartBuild(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("enqueues build successfully", func(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
audit := newMockBuildAudit()
|
|
svc := NewBuildService(queue, audit, nil)
|
|
|
|
taskID, err := svc.StartBuild(ctx, "project-1", domain.BuildSpec{
|
|
Prompt: "Build a landing page",
|
|
Template: "nextjs",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StartBuild() error = %v", err)
|
|
}
|
|
if taskID == "" {
|
|
t.Error("expected non-empty task ID")
|
|
}
|
|
|
|
// Verify task was enqueued
|
|
if len(queue.tasks) != 1 {
|
|
t.Errorf("expected 1 task in queue, got %d", len(queue.tasks))
|
|
}
|
|
task := queue.tasks[taskID]
|
|
if task.ProjectID != "project-1" {
|
|
t.Errorf("got project_id %q, want %q", task.ProjectID, "project-1")
|
|
}
|
|
if task.Type != domain.WorkTaskTypeBuild {
|
|
t.Errorf("got type %q, want %q", task.Type, domain.WorkTaskTypeBuild)
|
|
}
|
|
|
|
// Verify audit was recorded
|
|
if len(audit.entries) != 1 {
|
|
t.Errorf("expected 1 audit entry, got %d", len(audit.entries))
|
|
}
|
|
})
|
|
|
|
t.Run("validates prompt required", func(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
audit := newMockBuildAudit()
|
|
svc := NewBuildService(queue, audit, nil)
|
|
|
|
_, err := svc.StartBuild(ctx, "project-1", domain.BuildSpec{})
|
|
if err == nil {
|
|
t.Error("expected error for empty prompt")
|
|
}
|
|
})
|
|
|
|
t.Run("validates project ID required", func(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
audit := newMockBuildAudit()
|
|
svc := NewBuildService(queue, audit, nil)
|
|
|
|
_, err := svc.StartBuild(ctx, "", domain.BuildSpec{Prompt: "Build"})
|
|
if err == nil {
|
|
t.Error("expected error for empty project ID")
|
|
}
|
|
})
|
|
|
|
t.Run("includes variables in spec", func(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
audit := newMockBuildAudit()
|
|
svc := NewBuildService(queue, audit, nil)
|
|
|
|
taskID, err := svc.StartBuild(ctx, "project-1", domain.BuildSpec{
|
|
Prompt: "Build",
|
|
Variables: map[string]string{
|
|
"name": "My App",
|
|
"color": "blue",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StartBuild() error = %v", err)
|
|
}
|
|
|
|
task := queue.tasks[taskID]
|
|
vars, ok := task.Spec["variables"].(map[string]string)
|
|
if !ok {
|
|
t.Fatal("expected variables in task spec")
|
|
}
|
|
if vars["name"] != "My App" {
|
|
t.Errorf("got variable name %q, want %q", vars["name"], "My App")
|
|
}
|
|
})
|
|
|
|
t.Run("continues if audit fails", func(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
audit := newMockBuildAudit()
|
|
audit.err = fmt.Errorf("db connection failed")
|
|
svc := NewBuildService(queue, audit, nil)
|
|
|
|
taskID, err := svc.StartBuild(ctx, "project-1", domain.BuildSpec{
|
|
Prompt: "Build",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StartBuild() should succeed even if audit fails, got error = %v", err)
|
|
}
|
|
if taskID == "" {
|
|
t.Error("expected non-empty task ID")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildService_GetBuildStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("returns existing entry", func(t *testing.T) {
|
|
audit := newMockBuildAudit()
|
|
audit.entries["task-1"] = &domain.BuildAuditEntry{
|
|
TaskID: "task-1",
|
|
ProjectID: "project-1",
|
|
Status: domain.BuildStatusRunning,
|
|
}
|
|
svc := NewBuildService(newMockWorkQueue(), audit, nil)
|
|
|
|
entry, err := svc.GetBuildStatus(ctx, "task-1")
|
|
if err != nil {
|
|
t.Fatalf("GetBuildStatus() error = %v", err)
|
|
}
|
|
if entry.Status != domain.BuildStatusRunning {
|
|
t.Errorf("got status %q, want %q", entry.Status, domain.BuildStatusRunning)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for nonexistent entry", func(t *testing.T) {
|
|
audit := newMockBuildAudit()
|
|
svc := NewBuildService(newMockWorkQueue(), audit, nil)
|
|
|
|
_, err := svc.GetBuildStatus(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent entry")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildService_ListBuilds(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
audit := newMockBuildAudit()
|
|
audit.entries["task-1"] = &domain.BuildAuditEntry{
|
|
TaskID: "task-1", ProjectID: "project-a", Status: domain.BuildStatusCompleted,
|
|
}
|
|
audit.entries["task-2"] = &domain.BuildAuditEntry{
|
|
TaskID: "task-2", ProjectID: "project-a", Status: domain.BuildStatusFailed,
|
|
}
|
|
audit.entries["task-3"] = &domain.BuildAuditEntry{
|
|
TaskID: "task-3", ProjectID: "project-b", Status: domain.BuildStatusPending,
|
|
}
|
|
|
|
svc := NewBuildService(newMockWorkQueue(), audit, nil)
|
|
|
|
t.Run("lists builds for project", func(t *testing.T) {
|
|
entries, err := svc.ListBuilds(ctx, "project-a", 50)
|
|
if err != nil {
|
|
t.Fatalf("ListBuilds() error = %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("got %d entries, want 2", len(entries))
|
|
}
|
|
})
|
|
|
|
t.Run("uses default limit", func(t *testing.T) {
|
|
entries, err := svc.ListBuilds(ctx, "project-a", 0)
|
|
if err != nil {
|
|
t.Fatalf("ListBuilds() error = %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("got %d entries, want 2", len(entries))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildService_CompleteBuild(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("updates audit on completion", func(t *testing.T) {
|
|
audit := newMockBuildAudit()
|
|
audit.entries["task-1"] = &domain.BuildAuditEntry{
|
|
TaskID: "task-1",
|
|
ProjectID: "project-1",
|
|
Status: domain.BuildStatusRunning,
|
|
}
|
|
svc := NewBuildService(newMockWorkQueue(), audit, nil)
|
|
|
|
err := svc.CompleteBuild(ctx, "task-1", &domain.BuildResult{
|
|
Success: true,
|
|
CommitSHA: "abc123",
|
|
DurationMs: 5000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteBuild() error = %v", err)
|
|
}
|
|
|
|
entry := audit.entries["task-1"]
|
|
if entry.Status != domain.BuildStatusCompleted {
|
|
t.Errorf("got status %q, want %q", entry.Status, domain.BuildStatusCompleted)
|
|
}
|
|
if entry.Result == nil {
|
|
t.Fatal("expected result to be set")
|
|
}
|
|
if !entry.Result.Success {
|
|
t.Error("expected result.Success = true")
|
|
}
|
|
})
|
|
}
|