From 619e6a040ab5ba0aea6821d508bb6497e13352f2 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sat, 28 Mar 2026 13:09:20 +0000 Subject: [PATCH] Fix 6 Postgres-specific test failures in workflow store Root causes: - PG store CreateInstance/CreateAssignment set defaults in local vars but didn't write them back to the struct, so callers saw empty Status fields (violating CHECK constraints on subsequent updates) - PG JSONB normalizes whitespace ({"key": "val"} vs {"key":"val"}) but tests compared exact strings - ListSignoffs test used "nonexistent" as instance_id but PG validates UUID format Fixes: - Write defaults directly to inst.Status / a.Status in PG store (aligns with SQLite store which already did this) - Add jsonEq() helper using json.Compact for whitespace-agnostic JSON comparison across all stage_data/review_data assertions - Use valid zero-UUID for non-existent instance in signoff test All 35 tests pass on SQLite (28 store + 7 engine). Co-Authored-By: Claude Opus 4.6 (1M context) --- server/handlers/workflow_store_test.go | 30 +++++++++++++++++++------- server/store/postgres/workflows.go | 14 ++++++------ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/server/handlers/workflow_store_test.go b/server/handlers/workflow_store_test.go index b7f180b..adaaa89 100644 --- a/server/handlers/workflow_store_test.go +++ b/server/handlers/workflow_store_test.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "context" "encoding/json" "testing" @@ -13,6 +14,19 @@ import ( "switchboard-core/store/sqlite" ) +// jsonEq compares two JSON byte slices ignoring whitespace differences +// (PG JSONB normalizes spacing, SQLite preserves it verbatim). +func jsonEq(a, b json.RawMessage) bool { + var ca, cb bytes.Buffer + if err := json.Compact(&ca, a); err != nil { + return false + } + if err := json.Compact(&cb, b); err != nil { + return false + } + return ca.String() == cb.String() +} + // testStores returns an appropriate Stores for the current dialect. func testStores(t *testing.T) store.Stores { t.Helper() @@ -128,7 +142,7 @@ func TestWorkflowInstance_CreateAndGet(t *testing.T) { if got.EntryToken == nil || *got.EntryToken != token { t.Errorf("entry_token = %v, want %q", got.EntryToken, token) } - if string(got.StageData) != `{"key":"val"}` { + if !jsonEq(got.StageData, json.RawMessage(`{"key":"val"}`)) { t.Errorf("stage_data = %s, want {\"key\":\"val\"}", got.StageData) } if got.CreatedAt.IsZero() { @@ -200,8 +214,8 @@ func TestWorkflowInstance_Update(t *testing.T) { 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) + if !jsonEq(got.StageData, json.RawMessage(`{"updated":true}`)) { + t.Errorf("stage_data = %s, want {\"updated\":true}", got.StageData) } } @@ -296,8 +310,8 @@ func TestWorkflowInstance_AdvanceStage(t *testing.T) { 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 !jsonEq(got.StageData, json.RawMessage(`{"step":"two"}`)) { + t.Errorf("stage_data = %s, want {\"step\":\"two\"}", got.StageData) } if !got.StageEnteredAt.After(originalEnteredAt) { t.Errorf("stage_entered_at should have advanced; original=%v, got=%v", @@ -543,8 +557,8 @@ func TestWorkflowAssignment_Complete(t *testing.T) { 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 !jsonEq(got.ReviewData, json.RawMessage(`{"approved":true,"notes":"LGTM"}`)) { + t.Errorf("review_data = %s, want {\"approved\":true,\"notes\":\"LGTM\"}", got.ReviewData) } if got.CompletedAt == nil { t.Error("completed_at should be set") @@ -837,7 +851,7 @@ func TestWorkflowSignoff_ListEmpty(t *testing.T) { ctx := context.Background() // ListSignoffs on non-existent instance should return empty, not error - list, err := s.Workflows.ListSignoffs(ctx, "nonexistent", "stage") + list, err := s.Workflows.ListSignoffs(ctx, "00000000-0000-0000-0000-000000000000", "stage") if err != nil { t.Fatalf("list signoffs error: %v", err) } diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 4afd8f3..01df4e4 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -406,9 +406,8 @@ func nullIfEmpty(s string) interface{} { func (s *WorkflowStore) CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error { stageData := jsonOrEmpty(inst.StageData) metadata := jsonOrEmpty(inst.Metadata) - status := inst.Status - if status == "" { - status = models.InstanceStatusActive + if inst.Status == "" { + inst.Status = models.InstanceStatusActive } return DB.QueryRowContext(ctx, ` INSERT INTO workflow_instances (workflow_id, workflow_version, current_stage, @@ -416,7 +415,7 @@ func (s *WorkflowStore) CreateInstance(ctx context.Context, inst *models.Workflo VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, stage_entered_at, created_at, updated_at`, inst.WorkflowID, inst.WorkflowVersion, inst.CurrentStage, - stageData, status, inst.StartedBy, inst.EntryToken, metadata, + stageData, inst.Status, inst.StartedBy, inst.EntryToken, metadata, ).Scan(&inst.ID, &inst.StageEnteredAt, &inst.CreatedAt, &inst.UpdatedAt) } @@ -575,15 +574,14 @@ func (s *WorkflowStore) ListActiveInstances(ctx context.Context) ([]models.Workf func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error { reviewData := jsonOrEmpty(a.ReviewData) - status := a.Status - if status == "" { - status = models.AssignmentStatusUnassigned + if a.Status == "" { + a.Status = models.AssignmentStatusUnassigned } return DB.QueryRowContext(ctx, ` INSERT INTO workflow_assignments (instance_id, stage, team_id, assigned_to, status, review_data) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`, - a.InstanceID, a.Stage, a.TeamID, a.AssignedTo, status, reviewData, + a.InstanceID, a.Stage, a.TeamID, a.AssignedTo, a.Status, reviewData, ).Scan(&a.ID, &a.CreatedAt) }