From 46f72ed18ce0ea386f3e15fb4833e015da58c75d Mon Sep 17 00:00:00 2001 From: beads/crew/emma Date: Fri, 2 Jan 2026 00:04:45 -0800 Subject: [PATCH] test(sync): add tests for pre-sync validation hook (bd-nv4g) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for validateOpenIssuesForSync(): - ModeNone: validation skipped when config is "none" - ModeEmpty: validation skipped for empty config (backwards compat) - ModeWarn: sync proceeds with warnings for invalid issues - ModeError: sync blocked with error for invalid issues - NoWarnings: valid issues pass validation - SkipsClosedIssues: closed issues not validated - ChoreHasNoRequirements: chore type has no required sections The pre-sync validation hook was already implemented as part of bd-t7jq. This commit adds the missing test coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/sync_validation_test.go | 277 +++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 cmd/bd/sync_validation_test.go diff --git a/cmd/bd/sync_validation_test.go b/cmd/bd/sync_validation_test.go new file mode 100644 index 00000000..f2f3dcee --- /dev/null +++ b/cmd/bd/sync_validation_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/types" +) + +// setupSyncValidationTest creates a test store and properly initializes globals. +// Returns the store and a cleanup function that should be deferred. +func setupSyncValidationTest(t *testing.T) (*testing.T, func()) { + t.Helper() + + tmpDir := t.TempDir() + testDBPath := filepath.Join(tmpDir, ".beads", "issues.db") + + testStore := newTestStore(t, testDBPath) + + // Save original state + origStore := store + origStoreActive := storeActive + origDBPath := dbPath + + // Set up test state + store = testStore + storeMutex.Lock() + storeActive = true + storeMutex.Unlock() + dbPath = testDBPath + + cleanup := func() { + storeMutex.Lock() + store = origStore + storeActive = origStoreActive + storeMutex.Unlock() + dbPath = origDBPath + } + + return t, cleanup +} + +// TestValidateOpenIssuesForSync_ModeNone verifies validation is skipped when +// validation.on-sync is set to "none" (default). +func TestValidateOpenIssuesForSync_ModeNone(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + // Create a bug without required sections (should fail validation) + ctx := context.Background() + issue := &types.Issue{ + ID: "test-001", + Title: "Bug without sections", + IssueType: types.TypeBug, + Description: "No steps to reproduce or acceptance criteria", + Status: types.StatusOpen, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to "none" + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "none") + + // Should return nil (skip validation) + err := validateOpenIssuesForSync(ctx) + if err != nil { + t.Errorf("validateOpenIssuesForSync with mode=none returned error: %v", err) + } +} + +// TestValidateOpenIssuesForSync_ModeEmpty verifies validation is skipped when +// validation.on-sync is empty (backwards compatibility). +func TestValidateOpenIssuesForSync_ModeEmpty(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + // Create a bug without required sections + ctx := context.Background() + issue := &types.Issue{ + ID: "test-001", + Title: "Bug without sections", + IssueType: types.TypeBug, + Description: "No sections", + Status: types.StatusOpen, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to empty string + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "") + + // Should return nil (skip validation) + err := validateOpenIssuesForSync(ctx) + if err != nil { + t.Errorf("validateOpenIssuesForSync with mode=empty returned error: %v", err) + } +} + +// TestValidateOpenIssuesForSync_ModeWarn verifies sync proceeds when +// validation.on-sync is "warn" even with invalid issues. +func TestValidateOpenIssuesForSync_ModeWarn(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + // Create a bug without required sections + ctx := context.Background() + issue := &types.Issue{ + ID: "test-001", + Title: "Bug without sections", + IssueType: types.TypeBug, + Description: "No steps to reproduce or acceptance criteria", + Status: types.StatusOpen, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to "warn" + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "warn") + + // Should return nil (warnings printed but sync proceeds) + // The function prints to stderr but returns nil to allow sync to continue + err := validateOpenIssuesForSync(ctx) + if err != nil { + t.Errorf("validateOpenIssuesForSync with mode=warn should return nil, got: %v", err) + } +} + +// TestValidateOpenIssuesForSync_ModeError verifies sync is blocked when +// validation.on-sync is "error" and issues fail validation. +func TestValidateOpenIssuesForSync_ModeError(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + // Create a bug without required sections + ctx := context.Background() + issue := &types.Issue{ + ID: "test-001", + Title: "Bug without sections", + IssueType: types.TypeBug, + Description: "No sections at all", + Status: types.StatusOpen, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to "error" + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "error") + + // Should return error (function also prints to stderr which we allow) + err := validateOpenIssuesForSync(ctx) + + if err == nil { + t.Error("validateOpenIssuesForSync with mode=error should return error for invalid issues") + } + if !strings.Contains(err.Error(), "template validation failed") { + t.Errorf("expected 'template validation failed' in error, got: %v", err) + } +} + +// TestValidateOpenIssuesForSync_NoWarnings verifies no errors when all issues pass validation. +func TestValidateOpenIssuesForSync_NoWarnings(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + // Create a bug WITH all required sections + ctx := context.Background() + issue := &types.Issue{ + ID: "test-001", + Title: "Bug with sections", + IssueType: types.TypeBug, + Description: `## Steps to Reproduce +1. Do this +2. Do that + +## Acceptance Criteria +- It works`, + Status: types.StatusOpen, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to "error" (strictest mode) + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "error") + + // Should return nil (no validation errors) + err := validateOpenIssuesForSync(ctx) + if err != nil { + t.Errorf("validateOpenIssuesForSync should not return error for valid issues: %v", err) + } +} + +// TestValidateOpenIssuesForSync_SkipsClosedIssues verifies closed issues are not validated. +func TestValidateOpenIssuesForSync_SkipsClosedIssues(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + ctx := context.Background() + + // Create a closed bug without required sections (should be skipped) + closedIssue := &types.Issue{ + ID: "test-001", + Title: "Closed bug without sections", + IssueType: types.TypeBug, + Description: "No sections", + Status: types.StatusClosed, + } + if err := store.CreateIssue(ctx, closedIssue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to "error" + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "error") + + // Should return nil (closed issues are not validated) + err := validateOpenIssuesForSync(ctx) + if err != nil { + t.Errorf("validateOpenIssuesForSync should skip closed issues: %v", err) + } +} + +// TestValidateOpenIssuesForSync_ChoreHasNoRequirements verifies chore type +// has no required sections and passes validation. +func TestValidateOpenIssuesForSync_ChoreHasNoRequirements(t *testing.T) { + _, cleanup := setupSyncValidationTest(t) + defer cleanup() + + // Create a chore without any sections (should pass - no requirements) + ctx := context.Background() + issue := &types.Issue{ + ID: "test-001", + Title: "Chore issue", + IssueType: types.TypeChore, + Description: "Just a description, no sections needed", + Status: types.StatusOpen, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Set validation mode to "error" + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("validation.on-sync", "error") + + // Should return nil (chore has no requirements) + err := validateOpenIssuesForSync(ctx) + if err != nil { + t.Errorf("validateOpenIssuesForSync should not error for chore issues: %v", err) + } +}