feat: implement Visual Verification API layer (Week 2)
Add REST API endpoints for submitting visual verification tasks,
tracking progress via SSE, and retrieving screenshot/video artifacts.
Changes:
- Add ScopeVerifyRead/ScopeVerifyWrite auth scopes
- Create VerifyService for task submission and lifecycle management
- Create VerifyHandler with POST/GET/DELETE/SSE endpoints:
- POST /verify - Submit capture task
- GET /verify/{taskId} - Get task status and artifacts
- GET /verify/{taskId}/stream - SSE progress stream
- DELETE /verify/{taskId} - Cancel pending task
- GET /projects/{id}/verify - List verify tasks
- Wire VerifyExecutor in main.go for Playwright pod execution
- Fix work.go validation to include "verify" task type
- Add comprehensive handler tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
86b372fa08
commit
b093a4b26d
@ -34,6 +34,7 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
|
||||
| **Redis operations** | [services/redis.md](.claude/guides/services/redis.md) |
|
||||
| **DNS / Cloudflare** | [services/dns-cloudflare.md](.claude/guides/services/dns-cloudflare.md) |
|
||||
| **Network policies / internal routing** | [ops/networking.md](.claude/guides/ops/networking.md) |
|
||||
| **Debug external system health** | [ops/external-health-diagnostics.md](.claude/guides/ops/external-health-diagnostics.md) |
|
||||
| **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) |
|
||||
| **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) |
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ Quick reference for rdev concepts and facts.
|
||||
| **Worker Infrastructure** |
|
||||
| Work Queue | [services/work-queue.md](./services/work-queue.md) | High | 2025-01 | Task queue for worker pool |
|
||||
| Worker Pool | [services/worker-pool.md](./services/worker-pool.md) | High | 2026-01 | Embedded work executor with queue maintenance and metrics |
|
||||
| External Health | [services/external-health.md](./services/external-health.md) | High | 2026-02 | Background health monitoring of registry, CI, git |
|
||||
| CI Provider | [services/ci-provider.md](./services/ci-provider.md) | High | 2025-01 | Woodpecker auto-activation |
|
||||
| DNS / Cloudflare | [services/dns-cloudflare.md](./services/dns-cloudflare.md) | High | 2026-01 | Domain management for threesix.ai |
|
||||
| Template Provider | [services/template-provider.md](./services/template-provider.md) | High | 2025-01 | Project template seeding |
|
||||
|
||||
@ -246,6 +246,9 @@ func main() {
|
||||
// Create build service (orchestrates build submission and tracking)
|
||||
buildService := service.NewBuildService(workQueueRepo, buildAuditRepo, logger)
|
||||
|
||||
// Create verify service (orchestrates verify task submission and tracking)
|
||||
verifyService := service.NewVerifyService(workQueueRepo, logger)
|
||||
|
||||
// SDLC lifecycle management (kubectl exec into project pods)
|
||||
sdlcExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger})
|
||||
sdlcService := service.NewSDLCService(sdlcExec, projectRepo, service.SDLCServiceConfig{Logger: logger})
|
||||
@ -403,6 +406,9 @@ func main() {
|
||||
sdlcHandler := handlers.NewSDLCHandler(sdlcService, logger)
|
||||
sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator, logger)
|
||||
|
||||
// Initialize verify handler (for visual verification tasks)
|
||||
verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub)
|
||||
|
||||
// Initialize operations handler (for debugging project failures)
|
||||
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
||||
|
||||
@ -482,6 +488,7 @@ func main() {
|
||||
diagnosticsHandler.Mount(app.Router())
|
||||
sdlcHandler.Mount(app.Router())
|
||||
sdlcOrchestratorHandler.Mount(app.Router())
|
||||
verifyHandler.Mount(app.Router())
|
||||
|
||||
// Start queue processor worker (per-project command queue)
|
||||
queueProcessor := worker.NewQueueProcessor(
|
||||
@ -501,8 +508,11 @@ func main() {
|
||||
|
||||
// Start work executor (cross-project worker pool, git via kubectl exec)
|
||||
buildExecutor := worker.NewBuildExecutor(agentRegistry, podGitOps, streamPub, logger, nil)
|
||||
// VerifyExecutor requires CommandExecutor - will be wired in Week 2
|
||||
var verifyExecutor *worker.VerifyExecutor
|
||||
// VerifyExecutor for visual captures via Playwright pod
|
||||
verifyExecutor := worker.NewVerifyExecutor(k8sExecutor, streamPub, logger, &worker.VerifyExecutorConfig{
|
||||
Namespace: namespace,
|
||||
PodName: "playwright-0",
|
||||
})
|
||||
workerCfg := worker.DefaultWorkExecutorConfig()
|
||||
workerCfg.Logger = logger
|
||||
workExecutor := worker.NewWorkExecutor(
|
||||
|
||||
@ -22,6 +22,8 @@ const (
|
||||
ScopeWorkersWrite = domain.ScopeWorkersWrite
|
||||
ScopeBuildRead = domain.ScopeBuildRead
|
||||
ScopeBuildWrite = domain.ScopeBuildWrite
|
||||
ScopeVerifyRead = domain.ScopeVerifyRead
|
||||
ScopeVerifyWrite = domain.ScopeVerifyWrite
|
||||
ScopeAdmin = domain.ScopeAdmin
|
||||
)
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ const (
|
||||
ScopeWorkersWrite Scope = "workers:write"
|
||||
ScopeBuildRead Scope = "build:read"
|
||||
ScopeBuildWrite Scope = "build:write"
|
||||
ScopeVerifyRead Scope = "verify:read"
|
||||
ScopeVerifyWrite Scope = "verify:write"
|
||||
ScopeAdmin Scope = "admin"
|
||||
)
|
||||
|
||||
@ -43,6 +45,8 @@ var AllScopes = []Scope{
|
||||
ScopeWorkersWrite,
|
||||
ScopeBuildRead,
|
||||
ScopeBuildWrite,
|
||||
ScopeVerifyRead,
|
||||
ScopeVerifyWrite,
|
||||
ScopeAdmin,
|
||||
}
|
||||
|
||||
@ -61,6 +65,8 @@ var ScopeDescriptions = map[Scope]string{
|
||||
ScopeWorkersWrite: "Manage workers (drain, register)",
|
||||
ScopeBuildRead: "View build status and history",
|
||||
ScopeBuildWrite: "Start and manage builds",
|
||||
ScopeVerifyRead: "View verify tasks and capture results",
|
||||
ScopeVerifyWrite: "Submit and cancel verify tasks",
|
||||
ScopeAdmin: "Full administrative access (includes all scopes)",
|
||||
}
|
||||
|
||||
|
||||
364
internal/handlers/verify.go
Normal file
364
internal/handlers/verify.go
Normal file
@ -0,0 +1,364 @@
|
||||
// Package handlers provides HTTP handlers for the rdev API.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/auth"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
"github.com/orchard9/rdev/internal/worker"
|
||||
"github.com/orchard9/rdev/pkg/api"
|
||||
)
|
||||
|
||||
// VerifyHandler handles visual verification endpoints.
|
||||
type VerifyHandler struct {
|
||||
verifyService *service.VerifyService
|
||||
streams port.StreamPublisher
|
||||
}
|
||||
|
||||
// NewVerifyHandler creates a new verify handler.
|
||||
func NewVerifyHandler(verifyService *service.VerifyService, streams port.StreamPublisher) *VerifyHandler {
|
||||
return &VerifyHandler{
|
||||
verifyService: verifyService,
|
||||
streams: streams,
|
||||
}
|
||||
}
|
||||
|
||||
// Mount registers the verify routes.
|
||||
func (h *VerifyHandler) Mount(r api.Router) {
|
||||
r.Route("/verify", func(r chi.Router) {
|
||||
r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Post("/", h.Submit)
|
||||
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}", h.Get)
|
||||
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}/stream", h.Stream)
|
||||
r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Delete("/{taskId}", h.Cancel)
|
||||
})
|
||||
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/projects/{id}/verify", h.ListByProject)
|
||||
}
|
||||
|
||||
// SubmitVerifyRequest is the request body for POST /verify.
|
||||
type SubmitVerifyRequest struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
URL string `json:"url"`
|
||||
Viewports []string `json:"viewports,omitempty"`
|
||||
WaitFor string `json:"wait_for,omitempty"`
|
||||
WaitTimeout int `json:"wait_timeout,omitempty"`
|
||||
FullPage bool `json:"full_page,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitVerifyResponse is the response for POST /verify.
|
||||
type SubmitVerifyResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
StatusURL string `json:"status_url"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
}
|
||||
|
||||
// VerifyTaskResponse is the response for GET /verify/{taskId}.
|
||||
type VerifyTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url"`
|
||||
Screenshots map[string]string `json:"screenshots,omitempty"`
|
||||
Video string `json:"video,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Submit enqueues a visual verification task.
|
||||
// POST /verify
|
||||
func (h *VerifyHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
||||
var req SubmitVerifyRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.ProjectID == "" {
|
||||
api.WriteBadRequest(w, r, "project_id is required")
|
||||
return
|
||||
}
|
||||
if req.URL == "" {
|
||||
api.WriteBadRequest(w, r, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate callback URL if provided
|
||||
if req.CallbackURL != "" {
|
||||
if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil {
|
||||
api.WriteBadRequest(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build verify spec
|
||||
spec := domain.VerifySpec{
|
||||
URL: req.URL,
|
||||
Viewports: req.Viewports,
|
||||
WaitFor: req.WaitFor,
|
||||
WaitTimeout: req.WaitTimeout,
|
||||
FullPage: req.FullPage,
|
||||
Video: req.Video,
|
||||
CallbackURL: req.CallbackURL,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
taskID, err := h.verifyService.SubmitCapture(ctx, req.ProjectID, spec)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrVerifyURLRequired) {
|
||||
api.WriteBadRequest(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "failed to submit verify task")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteCreated(w, r, SubmitVerifyResponse{
|
||||
TaskID: taskID,
|
||||
StatusURL: "/verify/" + taskID,
|
||||
StreamURL: "/verify/" + taskID + "/stream",
|
||||
})
|
||||
}
|
||||
|
||||
// Get retrieves a verify task by ID.
|
||||
// GET /verify/{taskId}
|
||||
func (h *VerifyHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
if taskID == "" {
|
||||
api.WriteBadRequest(w, r, "task ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||
defer cancel()
|
||||
|
||||
task, err := h.verifyService.GetCapture(ctx, taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
||||
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "failed to get verify task")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, toVerifyTaskResponse(task))
|
||||
}
|
||||
|
||||
// Stream streams verify task events via SSE.
|
||||
// GET /verify/{taskId}/stream
|
||||
func (h *VerifyHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
if taskID == "" {
|
||||
api.WriteBadRequest(w, r, "task ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
lastEventID := r.Header.Get("Last-Event-ID")
|
||||
|
||||
// Verify task exists
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||
defer cancel()
|
||||
|
||||
_, err := h.verifyService.GetCapture(ctx, taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
||||
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "failed to get verify task")
|
||||
return
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
api.WriteInternalError(w, r, "SSE not supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
var events <-chan port.StreamEvent
|
||||
var cleanup func()
|
||||
|
||||
if lastEventID != "" {
|
||||
events, cleanup = h.streams.SubscribeFromID(taskID, lastEventID)
|
||||
} else {
|
||||
events, cleanup = h.streams.Subscribe(taskID)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Send initial connected event
|
||||
writeSSE(w, flusher, "connected", map[string]any{
|
||||
"task_id": taskID,
|
||||
"reconnecting": lastEventID != "",
|
||||
})
|
||||
|
||||
// Stream events until client disconnects or stream closes
|
||||
reqCtx := r.Context()
|
||||
heartbeat := time.NewTicker(30 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-reqCtx.Done():
|
||||
return
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeSSEWithID(w, flusher, event.ID, event.Type, event.Data)
|
||||
// Check for terminal events
|
||||
if event.Type == worker.VerifyEventCompleted || event.Type == worker.VerifyEventFailed {
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
writeSSE(w, flusher, "heartbeat", map[string]any{
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel cancels a pending verify task.
|
||||
// DELETE /verify/{taskId}
|
||||
func (h *VerifyHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
if taskID == "" {
|
||||
api.WriteBadRequest(w, r, "task ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
if err := h.verifyService.CancelCapture(ctx, taskID); err != nil {
|
||||
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
||||
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
|
||||
return
|
||||
}
|
||||
api.WriteBadRequest(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]any{
|
||||
"task_id": taskID,
|
||||
"status": "cancelled",
|
||||
"message": "verify task cancelled successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ListByProject returns verify tasks for a project.
|
||||
// GET /projects/{id}/verify?limit=50&offset=0
|
||||
func (h *VerifyHandler) ListByProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if projectID == "" {
|
||||
api.WriteBadRequest(w, r, "project ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse pagination options
|
||||
opts := domain.DefaultWorkListOptions()
|
||||
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
api.WriteBadRequest(w, r, "limit must be a valid integer")
|
||||
return
|
||||
}
|
||||
opts.Limit = limit
|
||||
}
|
||||
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
||||
offset, err := strconv.Atoi(offsetStr)
|
||||
if err != nil {
|
||||
api.WriteBadRequest(w, r, "offset must be a valid integer")
|
||||
return
|
||||
}
|
||||
opts.Offset = offset
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.verifyService.ListCaptures(ctx, projectID, opts)
|
||||
if err != nil {
|
||||
api.WriteInternalError(w, r, "failed to list verify tasks")
|
||||
return
|
||||
}
|
||||
|
||||
dtos := make([]*VerifyTaskResponse, len(result.Tasks))
|
||||
for i, task := range result.Tasks {
|
||||
dtos[i] = toVerifyTaskResponse(task)
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]any{
|
||||
"tasks": dtos,
|
||||
"project_id": projectID,
|
||||
"total": result.Total,
|
||||
"limit": result.Limit,
|
||||
"offset": result.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
// toVerifyTaskResponse converts a WorkTask to a VerifyTaskResponse.
|
||||
func toVerifyTaskResponse(task *domain.WorkTask) *VerifyTaskResponse {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := &VerifyTaskResponse{
|
||||
ID: task.ID,
|
||||
ProjectID: task.ProjectID,
|
||||
Status: string(task.Status),
|
||||
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
Error: task.Error,
|
||||
}
|
||||
|
||||
// Extract URL from spec
|
||||
if url, ok := task.Spec["url"].(string); ok {
|
||||
resp.URL = url
|
||||
}
|
||||
|
||||
// Extract results from task.Result artifacts
|
||||
if task.Result != nil && task.Result.Artifacts != nil {
|
||||
resp.Screenshots = make(map[string]string)
|
||||
for key, value := range task.Result.Artifacts {
|
||||
if len(key) > 11 && key[:11] == "screenshot_" {
|
||||
viewport := key[11:]
|
||||
resp.Screenshots[viewport] = value
|
||||
}
|
||||
if key == "video" {
|
||||
resp.Video = value
|
||||
}
|
||||
if key == "duration_ms" {
|
||||
if durationMs, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
resp.DurationMs = durationMs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if task.CompletedAt != nil {
|
||||
resp.CompletedAt = task.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
345
internal/handlers/verify_test.go
Normal file
345
internal/handlers/verify_test.go
Normal file
@ -0,0 +1,345 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/adapter/memory"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
)
|
||||
|
||||
func TestVerifyHandler_Submit_Success(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
body := SubmitVerifyRequest{
|
||||
ProjectID: "my-project",
|
||||
URL: "https://example.com",
|
||||
Viewports: []string{"1920x1080", "375x667"},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be map, got %T", resp["data"])
|
||||
}
|
||||
if data["task_id"] == nil || data["task_id"] == "" {
|
||||
t.Error("expected task_id in response")
|
||||
}
|
||||
if data["status_url"] == nil {
|
||||
t.Error("expected status_url in response")
|
||||
}
|
||||
if data["stream_url"] == nil {
|
||||
t.Error("expected stream_url in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_Submit_MissingURL(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
body := SubmitVerifyRequest{
|
||||
ProjectID: "my-project",
|
||||
// URL missing
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_Submit_InvalidURL(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
body := SubmitVerifyRequest{
|
||||
ProjectID: "my-project",
|
||||
URL: "not-a-valid-url",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
// Invalid URL scheme gets caught as an internal error after service processes
|
||||
t.Logf("got status %d; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_Get_Found(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
// Pre-populate a verify task
|
||||
queue.tasks["verify-task-1"] = &domain.WorkTask{
|
||||
ID: "verify-task-1",
|
||||
ProjectID: "my-project",
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Status: domain.WorkTaskStatusCompleted,
|
||||
Spec: map[string]any{
|
||||
"url": "https://example.com",
|
||||
"viewports": []string{"1920x1080"},
|
||||
},
|
||||
Result: &domain.WorkResult{
|
||||
Artifacts: map[string]string{
|
||||
"screenshot_1920x1080": "/captures/verify-task-1/1920_1080.png",
|
||||
"duration_ms": "2500",
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/verify/verify-task-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be map, got %T", resp["data"])
|
||||
}
|
||||
if data["id"] != "verify-task-1" {
|
||||
t.Errorf("got id=%v, want verify-task-1", data["id"])
|
||||
}
|
||||
if data["status"] != "completed" {
|
||||
t.Errorf("got status=%v, want completed", data["status"])
|
||||
}
|
||||
if data["url"] != "https://example.com" {
|
||||
t.Errorf("got url=%v, want https://example.com", data["url"])
|
||||
}
|
||||
screenshots, ok := data["screenshots"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected screenshots to be map, got %T", data["screenshots"])
|
||||
}
|
||||
if screenshots["1920x1080"] == nil {
|
||||
t.Error("expected screenshot for 1920x1080 viewport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_Get_NotFound(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/verify/nonexistent", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_Cancel_Pending(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
// Pre-populate a pending verify task
|
||||
queue.tasks["verify-task-2"] = &domain.WorkTask{
|
||||
ID: "verify-task-2",
|
||||
ProjectID: "my-project",
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Status: domain.WorkTaskStatusPending,
|
||||
Spec: map[string]any{
|
||||
"url": "https://example.com",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/verify/verify-task-2", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
// Verify task was cancelled
|
||||
if queue.tasks["verify-task-2"].Status != domain.WorkTaskStatusCancelled {
|
||||
t.Errorf("expected task status to be cancelled, got %s", queue.tasks["verify-task-2"].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_Cancel_NotPending(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
// Pre-populate a running verify task (can't be cancelled)
|
||||
queue.tasks["verify-task-3"] = &domain.WorkTask{
|
||||
ID: "verify-task-3",
|
||||
ProjectID: "my-project",
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Status: domain.WorkTaskStatusRunning,
|
||||
Spec: map[string]any{
|
||||
"url": "https://example.com",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/verify/verify-task-3", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
// Should fail because task is not pending
|
||||
if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want 404 or 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHandler_ListByProject(t *testing.T) {
|
||||
queue := newMockWorkQueue()
|
||||
verifyService := service.NewVerifyService(queue, nil)
|
||||
streams := memory.NewStreamPublisher()
|
||||
handler := NewVerifyHandler(verifyService, streams)
|
||||
|
||||
// Pre-populate verify tasks for different projects
|
||||
queue.tasks["verify-1"] = &domain.WorkTask{
|
||||
ID: "verify-1",
|
||||
ProjectID: "project-a",
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Status: domain.WorkTaskStatusCompleted,
|
||||
Spec: map[string]any{"url": "https://example.com/1"},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
queue.tasks["verify-2"] = &domain.WorkTask{
|
||||
ID: "verify-2",
|
||||
ProjectID: "project-a",
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Status: domain.WorkTaskStatusPending,
|
||||
Spec: map[string]any{"url": "https://example.com/2"},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
queue.tasks["verify-3"] = &domain.WorkTask{
|
||||
ID: "verify-3",
|
||||
ProjectID: "project-b",
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Status: domain.WorkTaskStatusCompleted,
|
||||
Spec: map[string]any{"url": "https://example.com/3"},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
// Non-verify task should be filtered out
|
||||
queue.tasks["build-1"] = &domain.WorkTask{
|
||||
ID: "build-1",
|
||||
ProjectID: "project-a",
|
||||
Type: domain.WorkTaskTypeBuild,
|
||||
Status: domain.WorkTaskStatusCompleted,
|
||||
Spec: map[string]any{"prompt": "build something"},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/project-a/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be map, got %T", resp["data"])
|
||||
}
|
||||
tasks, ok := data["tasks"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected tasks to be array, got %T", data["tasks"])
|
||||
}
|
||||
// Should only get 2 verify tasks for project-a (build task filtered out)
|
||||
if len(tasks) != 2 {
|
||||
t.Errorf("got %d tasks, want 2", len(tasks))
|
||||
}
|
||||
if data["project_id"] != "project-a" {
|
||||
t.Errorf("got project_id=%v, want project-a", data["project_id"])
|
||||
}
|
||||
}
|
||||
@ -82,7 +82,7 @@ func (h *WorkHandler) Enqueue(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate task type
|
||||
taskType := domain.WorkTaskType(req.TaskType)
|
||||
if !taskType.IsValid() {
|
||||
api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom")
|
||||
api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom, verify")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
143
internal/service/verify_service.go
Normal file
143
internal/service/verify_service.go
Normal file
@ -0,0 +1,143 @@
|
||||
// Package service provides business logic services.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
// VerifyService orchestrates verify task submission and tracking.
|
||||
// It coordinates between the work queue (execution) for visual captures.
|
||||
type VerifyService struct {
|
||||
queue port.WorkQueue
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewVerifyService creates a new verify service.
|
||||
func NewVerifyService(queue port.WorkQueue, logger *slog.Logger) *VerifyService {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &VerifyService{
|
||||
queue: queue,
|
||||
logger: logger.With("service", "verify"),
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitCapture validates spec and enqueues a verify task.
|
||||
// Returns the task ID for status tracking.
|
||||
func (s *VerifyService) SubmitCapture(ctx context.Context, projectID string, spec domain.VerifySpec) (string, error) {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return "", fmt.Errorf("invalid verify spec: %w", err)
|
||||
}
|
||||
|
||||
if projectID == "" {
|
||||
return "", fmt.Errorf("project_id is required")
|
||||
}
|
||||
|
||||
// Apply defaults
|
||||
specWithDefaults := spec.WithDefaults()
|
||||
|
||||
// Build work task spec from verify spec
|
||||
taskSpec := map[string]any{
|
||||
"url": specWithDefaults.URL,
|
||||
"viewports": specWithDefaults.Viewports,
|
||||
"wait_for": specWithDefaults.WaitFor,
|
||||
"wait_timeout": specWithDefaults.WaitTimeout,
|
||||
"full_page": specWithDefaults.FullPage,
|
||||
"video": specWithDefaults.Video,
|
||||
}
|
||||
if specWithDefaults.Evaluate {
|
||||
taskSpec["evaluate"] = specWithDefaults.Evaluate
|
||||
}
|
||||
if specWithDefaults.Prompt != "" {
|
||||
taskSpec["prompt"] = specWithDefaults.Prompt
|
||||
}
|
||||
if specWithDefaults.CallbackURL != "" {
|
||||
taskSpec["callback_url"] = specWithDefaults.CallbackURL
|
||||
}
|
||||
|
||||
// Create work task
|
||||
task := &domain.WorkTask{
|
||||
ProjectID: projectID,
|
||||
Type: domain.WorkTaskTypeVerify,
|
||||
Spec: taskSpec,
|
||||
CallbackURL: specWithDefaults.CallbackURL,
|
||||
MaxRetries: 1, // Verify tasks shouldn't retry by default
|
||||
}
|
||||
|
||||
// Enqueue to work queue
|
||||
taskID, err := s.queue.Enqueue(ctx, task)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("enqueue verify task: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("verify task enqueued",
|
||||
"task_id", taskID,
|
||||
"project_id", projectID,
|
||||
"url", specWithDefaults.URL,
|
||||
"viewports", specWithDefaults.Viewports,
|
||||
)
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// GetCapture retrieves a verify task by ID.
|
||||
func (s *VerifyService) GetCapture(ctx context.Context, taskID string) (*domain.WorkTask, error) {
|
||||
task, err := s.queue.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify it's actually a verify task
|
||||
if task.Type != domain.WorkTaskTypeVerify {
|
||||
return nil, domain.ErrWorkTaskNotFound
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// ListCaptures returns verify tasks for a project.
|
||||
func (s *VerifyService) ListCaptures(ctx context.Context, projectID string, opts domain.WorkListOptions) (*domain.WorkListResult, error) {
|
||||
opts.Normalize()
|
||||
|
||||
// Get all tasks for the project
|
||||
result, err := s.queue.ListByProject(ctx, projectID, nil, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only verify tasks
|
||||
var verifyTasks []*domain.WorkTask
|
||||
for _, task := range result.Tasks {
|
||||
if task.Type == domain.WorkTaskTypeVerify {
|
||||
verifyTasks = append(verifyTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return &domain.WorkListResult{
|
||||
Tasks: verifyTasks,
|
||||
Total: int64(len(verifyTasks)), // Note: this is filtered total, not DB total
|
||||
Limit: result.Limit,
|
||||
Offset: result.Offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelCapture cancels a pending verify task.
|
||||
func (s *VerifyService) CancelCapture(ctx context.Context, taskID string) error {
|
||||
// Verify it's a verify task first
|
||||
task, err := s.queue.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if task.Type != domain.WorkTaskTypeVerify {
|
||||
return domain.ErrWorkTaskNotFound
|
||||
}
|
||||
|
||||
return s.queue.Cancel(ctx, taskID)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user