Feat v0.3.2 workflow engine + handlers
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Failing after 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m57s
CI/CD / build-and-deploy (pull_request) Has been skipped

Workflow execution engine with Start/Advance/Cancel lifecycle, automated
stage processor with Starlark hook firing and cycle guard (max 10),
instance and assignment HTTP handlers, store round-trip tests for all
v0.3.1 methods, and Starlark module expansion (get_instance, list_instances).

New routes:
- POST/GET /workflows/:id/instances (start, list)
- GET /workflows/:id/instances/:iid (get)
- POST /workflows/:id/instances/:iid/advance (advance)
- POST /workflows/:id/instances/:iid/cancel (cancel)
- POST /assignments/:id/claim|unclaim|complete|cancel
- GET /assignments/mine
- Team-scoped mirrors under /teams/:teamId/...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 21:36:40 +00:00
parent 01ee9e668b
commit 438143318f
13 changed files with 1652 additions and 24 deletions

View File

@@ -0,0 +1,172 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── Assignment Handlers ─────────────────────
// WorkflowAssignmentHandler manages workflow assignment HTTP endpoints.
type WorkflowAssignmentHandler struct {
engine *workflow.Engine
stores store.Stores
}
// NewWorkflowAssignmentHandler creates an assignment handler.
func NewWorkflowAssignmentHandler(engine *workflow.Engine, stores store.Stores) *WorkflowAssignmentHandler {
return &WorkflowAssignmentHandler{engine: engine, stores: stores}
}
// Claim claims an assignment for the current user.
// POST /api/v1/assignments/:id/claim
func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
userID := c.GetString("user_id")
assignmentID := c.Param("id")
if err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"claimed": true})
}
// Unclaim releases a claimed assignment back to the queue.
// POST /api/v1/assignments/:id/unclaim
func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) {
userID := c.GetString("user_id")
assignmentID := c.Param("id")
// Verify current user is the claimer
userAssignments, err := h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, models.AssignmentStatusClaimed)
if err == nil {
found := false
for _, a := range userAssignments {
if a.ID == assignmentID {
found = true
break
}
}
if !found {
c.JSON(http.StatusForbidden, gin.H{"error": "assignment not claimed by you"})
return
}
}
if err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"unclaimed": true})
}
// Complete marks a claimed assignment as completed.
// POST /api/v1/assignments/:id/complete
func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
userID := c.GetString("user_id")
assignmentID := c.Param("id")
var body struct {
ReviewData json.RawMessage `json:"review_data"`
}
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Verify current user is the claimer
userAssignments, err := h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, models.AssignmentStatusClaimed)
if err == nil {
found := false
for _, a := range userAssignments {
if a.ID == assignmentID {
found = true
break
}
}
if !found {
c.JSON(http.StatusForbidden, gin.H{"error": "assignment not claimed by you"})
return
}
}
if err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID, body.ReviewData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If the stage has auto_transition, advance the instance.
// Look up the assignment's instance to get context.
userAssignments, _ = h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, models.AssignmentStatusCompleted)
for _, a := range userAssignments {
if a.ID == assignmentID {
// Try to advance — engine will handle validation
h.engine.Advance(c.Request.Context(), a.InstanceID, body.ReviewData, userID)
break
}
}
c.JSON(http.StatusOK, gin.H{"completed": true})
}
// Cancel cancels an open assignment.
// POST /api/v1/assignments/:id/cancel
func (h *WorkflowAssignmentHandler) Cancel(c *gin.Context) {
assignmentID := c.Param("id")
if err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
// ListByTeam returns assignments for a team.
// GET /api/v1/teams/:teamId/assignments?status=
func (h *WorkflowAssignmentHandler) ListByTeam(c *gin.Context) {
teamID := c.Param("teamId")
status := c.Query("status")
result, err := h.stores.Workflows.ListAssignmentsByTeam(c.Request.Context(), teamID, status)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
return
}
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ListMine returns assignments claimed by or assigned to the current user.
// GET /api/v1/assignments/mine?status=
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
userID := c.GetString("user_id")
status := c.Query("status")
result, err := h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
return
}
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}

View File

@@ -0,0 +1,134 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── Instance Handlers ───────────────────────
// WorkflowInstanceHandler manages workflow instance HTTP endpoints.
type WorkflowInstanceHandler struct {
engine *workflow.Engine
stores store.Stores
}
// NewWorkflowInstanceHandler creates an instance handler.
func NewWorkflowInstanceHandler(engine *workflow.Engine, stores store.Stores) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{engine: engine, stores: stores}
}
// Start creates a new workflow instance.
// POST /api/v1/workflows/:id/instances
func (h *WorkflowInstanceHandler) Start(c *gin.Context) {
workflowID := c.Param("id")
userID := c.GetString("user_id")
var body struct {
Data json.RawMessage `json:"data"`
Metadata json.RawMessage `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if len(body.Data) == 0 {
body.Data = json.RawMessage(`{}`)
}
inst, err := h.engine.Start(c.Request.Context(), workflowID, body.Data, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, inst)
}
// ListInstances returns instances for a workflow.
// GET /api/v1/workflows/:id/instances?status=&limit=&offset=
func (h *WorkflowInstanceHandler) ListInstances(c *gin.Context) {
workflowID := c.Param("id")
status := c.Query("status")
opts := store.ListOptions{}
if l := c.Query("limit"); l != "" {
opts.Limit, _ = strconv.Atoi(l)
}
if o := c.Query("offset"); o != "" {
opts.Offset, _ = strconv.Atoi(o)
}
result, err := h.stores.Workflows.ListInstances(c.Request.Context(), workflowID, status, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list instances"})
return
}
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// GetInstance returns a single instance.
// GET /api/v1/workflows/:id/instances/:iid
func (h *WorkflowInstanceHandler) GetInstance(c *gin.Context) {
inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), c.Param("iid"))
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Verify it belongs to the requested workflow
if inst.WorkflowID != c.Param("id") {
c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"})
return
}
c.JSON(http.StatusOK, inst)
}
// Advance moves an instance to the next stage.
// POST /api/v1/workflows/:id/instances/:iid/advance
func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
userID := c.GetString("user_id")
var body struct {
Data json.RawMessage `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
inst, err := h.engine.Advance(c.Request.Context(), c.Param("iid"), body.Data, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, inst)
}
// Cancel terminates an active instance.
// POST /api/v1/workflows/:id/instances/:iid/cancel
func (h *WorkflowInstanceHandler) Cancel(c *gin.Context) {
userID := c.GetString("user_id")
if err := h.engine.Cancel(c.Request.Context(), c.Param("iid"), userID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}

View File

@@ -0,0 +1,633 @@
package handlers
import (
"context"
"encoding/json"
"testing"
"time"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/store/postgres"
"switchboard-core/store/sqlite"
)
// testStores returns an appropriate Stores for the current dialect.
func testStores(t *testing.T) store.Stores {
t.Helper()
database.RequireTestDB(t)
if database.IsSQLite() {
return sqlite.NewStores(database.TestDB)
}
return postgres.NewStores(database.TestDB)
}
// seedWorkflowFixture creates a workflow + 2 stages + published version.
// Returns (workflowID, versionNumber, stage1Name, stage2Name).
func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (string, int, string, string) {
t.Helper()
ctx := context.Background()
wf := &models.Workflow{
TeamID: &teamID,
Name: "Test Workflow",
Slug: "test-workflow",
EntryMode: "team_only",
IsActive: true,
CreatedBy: userID,
}
if err := s.Workflows.Create(ctx, wf); err != nil {
t.Fatalf("create workflow: %v", err)
}
s1 := &models.WorkflowStage{
WorkflowID: wf.ID,
Ordinal: 0,
Name: "intake",
StageMode: models.StageModeForm,
Audience: models.AudienceTeam,
StageType: models.StageTypeSimple,
}
s2 := &models.WorkflowStage{
WorkflowID: wf.ID,
Ordinal: 1,
Name: "review",
StageMode: models.StageModeReview,
Audience: models.AudienceTeam,
StageType: models.StageTypeSimple,
AssignmentTeamID: &teamID,
}
if err := s.Workflows.CreateStage(ctx, s1); err != nil {
t.Fatalf("create stage 1: %v", err)
}
if err := s.Workflows.CreateStage(ctx, s2); err != nil {
t.Fatalf("create stage 2: %v", err)
}
// Publish version 1
snapshot, _ := json.Marshal([]models.WorkflowStage{*s1, *s2})
ver := &models.WorkflowVersion{
WorkflowID: wf.ID,
VersionNumber: 1,
Snapshot: snapshot,
}
if err := s.Workflows.Publish(ctx, ver); err != nil {
t.Fatalf("publish version: %v", err)
}
return wf.ID, 1, s1.Name, s2.Name
}
// ── Instance Store Tests ────────────────────
func TestWorkflowInstance_CreateAndGet(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Alice", "alice@test.com")
teamID := database.SeedTestTeam(t, "Team A", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
token := "tok-abc-123"
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StageData: json.RawMessage(`{"key":"val"}`),
Status: models.InstanceStatusActive,
StartedBy: userID,
EntryToken: &token,
Metadata: json.RawMessage(`{"source":"test"}`),
}
if err := s.Workflows.CreateInstance(ctx, inst); err != nil {
t.Fatalf("create instance: %v", err)
}
if inst.ID == "" {
t.Fatal("expected ID to be set")
}
got, err := s.Workflows.GetInstance(ctx, inst.ID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
if got.WorkflowID != wfID {
t.Errorf("workflow_id = %q, want %q", got.WorkflowID, wfID)
}
if got.CurrentStage != stage1 {
t.Errorf("current_stage = %q, want %q", got.CurrentStage, stage1)
}
if got.Status != models.InstanceStatusActive {
t.Errorf("status = %q, want active", got.Status)
}
if got.StartedBy != userID {
t.Errorf("started_by = %q, want %q", got.StartedBy, userID)
}
if got.EntryToken == nil || *got.EntryToken != token {
t.Errorf("entry_token = %v, want %q", got.EntryToken, token)
}
if string(got.StageData) != `{"key":"val"}` {
t.Errorf("stage_data = %s, want {\"key\":\"val\"}", got.StageData)
}
if got.CreatedAt.IsZero() {
t.Error("created_at is zero")
}
}
func TestWorkflowInstance_GetByToken(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Bob", "bob@test.com")
teamID := database.SeedTestTeam(t, "Team B", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
token := "unique-entry-token"
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StartedBy: userID,
EntryToken: &token,
}
if err := s.Workflows.CreateInstance(ctx, inst); err != nil {
t.Fatalf("create instance: %v", err)
}
got, err := s.Workflows.GetInstanceByToken(ctx, token)
if err != nil {
t.Fatalf("get by token: %v", err)
}
if got.ID != inst.ID {
t.Errorf("id = %q, want %q", got.ID, inst.ID)
}
}
func TestWorkflowInstance_Update(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Carol", "carol@test.com")
teamID := database.SeedTestTeam(t, "Team C", userID)
wfID, ver, stage1, stage2 := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StageData: json.RawMessage(`{}`),
StartedBy: userID,
Metadata: json.RawMessage(`{}`),
}
if err := s.Workflows.CreateInstance(ctx, inst); err != nil {
t.Fatalf("create: %v", err)
}
inst.CurrentStage = stage2
inst.StageData = json.RawMessage(`{"updated":true}`)
inst.StageEnteredAt = time.Now().UTC().Add(time.Minute)
if err := s.Workflows.UpdateInstance(ctx, inst); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
if got.CurrentStage != stage2 {
t.Errorf("current_stage = %q, want %q", got.CurrentStage, stage2)
}
if string(got.StageData) != `{"updated":true}` {
t.Errorf("stage_data = %s", got.StageData)
}
}
func TestWorkflowInstance_List(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Dave", "dave@test.com")
teamID := database.SeedTestTeam(t, "Team D", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
// Create 3 instances: 2 active, 1 completed
for i := 0; i < 3; i++ {
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StartedBy: userID,
}
if err := s.Workflows.CreateInstance(ctx, inst); err != nil {
t.Fatalf("create %d: %v", i, err)
}
if i == 2 {
s.Workflows.CompleteInstance(ctx, inst.ID)
}
}
// List all
all, err := s.Workflows.ListInstances(ctx, wfID, "", store.ListOptions{})
if err != nil {
t.Fatalf("list all: %v", err)
}
if len(all) != 3 {
t.Fatalf("len = %d, want 3", len(all))
}
// Filter by status
active, err := s.Workflows.ListInstances(ctx, wfID, "active", store.ListOptions{})
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 2 {
t.Errorf("active count = %d, want 2", len(active))
}
// Pagination
page, err := s.Workflows.ListInstances(ctx, wfID, "", store.ListOptions{Limit: 2, Offset: 1})
if err != nil {
t.Fatalf("list paged: %v", err)
}
if len(page) != 2 {
t.Errorf("paged count = %d, want 2", len(page))
}
}
func TestWorkflowInstance_AdvanceStage(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Eve", "eve@test.com")
teamID := database.SeedTestTeam(t, "Team E", userID)
wfID, ver, stage1, stage2 := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StageData: json.RawMessage(`{"step":"one"}`),
StartedBy: userID,
}
if err := s.Workflows.CreateInstance(ctx, inst); err != nil {
t.Fatalf("create: %v", err)
}
// Re-read to get the stored (possibly truncated) timestamp
before, _ := s.Workflows.GetInstance(ctx, inst.ID)
originalEnteredAt := before.StageEnteredAt
// Sleep >1s to ensure SQLite second-precision timestamp differs
time.Sleep(1100 * time.Millisecond)
newData := json.RawMessage(`{"step":"two"}`)
if err := s.Workflows.AdvanceStage(ctx, inst.ID, stage2, newData); err != nil {
t.Fatalf("advance: %v", err)
}
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
if got.CurrentStage != stage2 {
t.Errorf("current_stage = %q, want %q", got.CurrentStage, stage2)
}
if string(got.StageData) != `{"step":"two"}` {
t.Errorf("stage_data = %s", got.StageData)
}
if !got.StageEnteredAt.After(originalEnteredAt) {
t.Errorf("stage_entered_at should have advanced; original=%v, got=%v",
originalEnteredAt, got.StageEnteredAt)
}
}
func TestWorkflowInstance_Complete(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Frank", "frank@test.com")
teamID := database.SeedTestTeam(t, "Team F", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
if err := s.Workflows.CompleteInstance(ctx, inst.ID); err != nil {
t.Fatalf("complete: %v", err)
}
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
if got.Status != models.InstanceStatusCompleted {
t.Errorf("status = %q, want completed", got.Status)
}
// Complete again — should be no-op (already completed, WHERE status='active' won't match)
if err := s.Workflows.CompleteInstance(ctx, inst.ID); err != nil {
t.Fatalf("complete again: %v", err)
}
}
func TestWorkflowInstance_Cancel(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Grace", "grace@test.com")
teamID := database.SeedTestTeam(t, "Team G", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
if err := s.Workflows.CancelInstance(ctx, inst.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
if got.Status != models.InstanceStatusCancelled {
t.Errorf("status = %q, want cancelled", got.Status)
}
}
// ── Assignment Store Tests ──────────────────
func TestWorkflowAssignment_CreateAndList(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Hank", "hank@test.com")
teamID := database.SeedTestTeam(t, "Team H", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID,
WorkflowVersion: ver,
CurrentStage: stage1,
StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
a := &models.WorkflowAssignment{
InstanceID: inst.ID,
Stage: stage1,
TeamID: teamID,
}
if err := s.Workflows.CreateAssignment(ctx, a); err != nil {
t.Fatalf("create assignment: %v", err)
}
if a.ID == "" {
t.Fatal("expected assignment ID")
}
if a.Status != models.AssignmentStatusUnassigned {
t.Errorf("default status = %q, want unassigned", a.Status)
}
list, err := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
if err != nil {
t.Fatalf("list by instance: %v", err)
}
if len(list) != 1 {
t.Fatalf("len = %d, want 1", len(list))
}
if list[0].Stage != stage1 {
t.Errorf("stage = %q, want %q", list[0].Stage, stage1)
}
}
func TestWorkflowAssignment_Claim(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Ivy", "ivy@test.com")
teamID := database.SeedTestTeam(t, "Team I", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
a := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamID}
s.Workflows.CreateAssignment(ctx, a)
if err := s.Workflows.ClaimAssignment(ctx, a.ID, userID); err != nil {
t.Fatalf("claim: %v", err)
}
list, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
got := list[0]
if got.Status != models.AssignmentStatusClaimed {
t.Errorf("status = %q, want claimed", got.Status)
}
if got.AssignedTo == nil || *got.AssignedTo != userID {
t.Errorf("assigned_to = %v, want %q", got.AssignedTo, userID)
}
if got.ClaimedAt == nil {
t.Error("claimed_at should be set")
}
}
func TestWorkflowAssignment_ClaimAlreadyClaimed(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Jack", "jack@test.com")
teamID := database.SeedTestTeam(t, "Team J", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
a := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamID}
s.Workflows.CreateAssignment(ctx, a)
s.Workflows.ClaimAssignment(ctx, a.ID, userID)
// Second claim should fail
err := s.Workflows.ClaimAssignment(ctx, a.ID, userID)
if err == nil {
t.Fatal("expected error on double claim")
}
}
func TestWorkflowAssignment_Unclaim(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Kate", "kate@test.com")
teamID := database.SeedTestTeam(t, "Team K", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
a := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamID}
s.Workflows.CreateAssignment(ctx, a)
s.Workflows.ClaimAssignment(ctx, a.ID, userID)
if err := s.Workflows.UnclaimAssignment(ctx, a.ID); err != nil {
t.Fatalf("unclaim: %v", err)
}
list, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
got := list[0]
if got.Status != models.AssignmentStatusUnassigned {
t.Errorf("status = %q, want unassigned", got.Status)
}
if got.AssignedTo != nil {
t.Errorf("assigned_to = %v, want nil", got.AssignedTo)
}
if got.ClaimedAt != nil {
t.Errorf("claimed_at = %v, want nil", got.ClaimedAt)
}
}
func TestWorkflowAssignment_Complete(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Leo", "leo@test.com")
teamID := database.SeedTestTeam(t, "Team L", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
a := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamID}
s.Workflows.CreateAssignment(ctx, a)
s.Workflows.ClaimAssignment(ctx, a.ID, userID)
reviewData := json.RawMessage(`{"approved":true,"notes":"LGTM"}`)
if err := s.Workflows.CompleteAssignment(ctx, a.ID, reviewData); err != nil {
t.Fatalf("complete: %v", err)
}
list, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
got := list[0]
if got.Status != models.AssignmentStatusCompleted {
t.Errorf("status = %q, want completed", got.Status)
}
if string(got.ReviewData) != `{"approved":true,"notes":"LGTM"}` {
t.Errorf("review_data = %s", got.ReviewData)
}
if got.CompletedAt == nil {
t.Error("completed_at should be set")
}
}
func TestWorkflowAssignment_Cancel(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Mia", "mia@test.com")
teamID := database.SeedTestTeam(t, "Team M", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
// Cancel from unassigned
a1 := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamID}
s.Workflows.CreateAssignment(ctx, a1)
if err := s.Workflows.CancelAssignment(ctx, a1.ID); err != nil {
t.Fatalf("cancel unassigned: %v", err)
}
list, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
if list[0].Status != models.AssignmentStatusCancelled {
t.Errorf("status = %q, want cancelled", list[0].Status)
}
// Cancel from claimed
a2 := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamID}
s.Workflows.CreateAssignment(ctx, a2)
s.Workflows.ClaimAssignment(ctx, a2.ID, userID)
if err := s.Workflows.CancelAssignment(ctx, a2.ID); err != nil {
t.Fatalf("cancel claimed: %v", err)
}
}
func TestWorkflowAssignment_ListByTeam(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Nora", "nora@test.com")
teamA := database.SeedTestTeam(t, "Team Alpha", userID)
teamB := database.SeedTestTeam(t, "Team Beta", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamA)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
// 2 assignments for teamA, 1 for teamB
for i := 0; i < 2; i++ {
a := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamA}
s.Workflows.CreateAssignment(ctx, a)
}
a3 := &models.WorkflowAssignment{InstanceID: inst.ID, Stage: stage1, TeamID: teamB}
s.Workflows.CreateAssignment(ctx, a3)
// Claim one of teamA's so we can test status filter
listA, _ := s.Workflows.ListAssignmentsByTeam(ctx, teamA, "")
if len(listA) != 2 {
t.Fatalf("teamA count = %d, want 2", len(listA))
}
s.Workflows.ClaimAssignment(ctx, listA[0].ID, userID)
// Filter teamA unassigned only
unassigned, _ := s.Workflows.ListAssignmentsByTeam(ctx, teamA, "unassigned")
if len(unassigned) != 1 {
t.Errorf("teamA unassigned = %d, want 1", len(unassigned))
}
// teamB should have 1
listB, _ := s.Workflows.ListAssignmentsByTeam(ctx, teamB, "")
if len(listB) != 1 {
t.Errorf("teamB count = %d, want 1", len(listB))
}
}