Files
beads/cmd/bd/rename_prefix_test.go
Steve Yegge 57253f93a3 Context propagation with graceful cancellation (bd-rtp, bd-yb8, bd-2o2)
Complete implementation of signal-aware context propagation for graceful
cancellation across all commands and storage operations.

Key changes:

1. Signal-aware contexts (bd-rtp):
   - Added rootCtx/rootCancel in main.go using signal.NotifyContext()
   - Set up in PersistentPreRun, cancelled in PersistentPostRun
   - Daemon uses same pattern in runDaemonLoop()
   - Handles SIGINT/SIGTERM for graceful shutdown

2. Context propagation (bd-yb8):
   - All commands now use rootCtx instead of context.Background()
   - sqlite.New() receives context for cancellable operations
   - Database operations respect context cancellation
   - Storage layer propagates context through all queries

3. Cancellation tests (bd-2o2):
   - Added import_cancellation_test.go with comprehensive tests
   - Added export cancellation test in export_test.go
   - Tests verify database integrity after cancellation
   - All cancellation tests passing

Fixes applied during review:
   - Fixed rootCtx lifecycle (removed premature defer from PersistentPreRun)
   - Fixed test context contamination (reset rootCtx in test cleanup)
   - Fixed export tests missing context setup

Impact:
   - Pressing Ctrl+C during import/export now cancels gracefully
   - No database corruption or hanging transactions
   - Clean shutdown of all operations

Tested:
   - go build ./cmd/bd ✓
   - go test ./cmd/bd -run TestImportCancellation ✓
   - go test ./cmd/bd -run TestExportCommand ✓
   - Manual Ctrl+C testing verified

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:57:23 -05:00

222 lines
5.8 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
func TestValidatePrefix(t *testing.T) {
tests := []struct {
name string
prefix string
wantErr bool
}{
{"valid lowercase", "kw-", false},
{"valid with numbers", "work1-", false},
{"valid with hyphen", "my-work-", false},
{"empty", "", true},
{"too long", "verylongprefix-", true},
{"starts with number", "1work-", true},
{"uppercase", "KW-", true},
{"no hyphen", "kw", false},
{"just hyphen", "-", true},
{"starts with hyphen", "-work", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePrefix(tt.prefix)
if (err != nil) != tt.wantErr {
t.Errorf("validatePrefix(%q) error = %v, wantErr %v", tt.prefix, err, tt.wantErr)
}
})
}
}
func TestRenamePrefixCommand(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
testStore, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer testStore.Close()
ctx := context.Background()
store = testStore
actor = "test"
defer func() {
store = nil
actor = ""
}()
if err := testStore.SetConfig(ctx, "issue_prefix", "old"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
issue1 := &types.Issue{
ID: "old-1",
Title: "Fix bug in old-2",
Description: "See old-3 for details",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeBug,
}
issue2 := &types.Issue{
ID: "old-2",
Title: "Related to old-1",
Description: "This depends on old-1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "old-3",
Title: "Another issue",
Description: "Referenced by old-1",
Design: "Mentions old-2 in design",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeFeature,
}
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create issue1: %v", err)
}
if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("Failed to create issue2: %v", err)
}
if err := testStore.CreateIssue(ctx, issue3, "test"); err != nil {
t.Fatalf("Failed to create issue3: %v", err)
}
dep := &types.Dependency{
IssueID: "old-1",
DependsOnID: "old-2",
Type: types.DepBlocks,
}
if err := testStore.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
issues := []*types.Issue{issue1, issue2, issue3}
if err := renamePrefixInDB(ctx, "old", "new", issues); err != nil {
t.Fatalf("renamePrefixInDB failed: %v", err)
}
newPrefix, err := testStore.GetConfig(ctx, "issue_prefix")
if err != nil {
t.Fatalf("Failed to get new prefix: %v", err)
}
if newPrefix != "new" {
t.Errorf("Expected prefix 'new', got %q", newPrefix)
}
updatedIssue1, err := testStore.GetIssue(ctx, "new-1")
if err != nil {
t.Fatalf("Failed to get updated issue1: %v", err)
}
if updatedIssue1.Title != "Fix bug in new-2" {
t.Errorf("Expected title 'Fix bug in new-2', got %q", updatedIssue1.Title)
}
if updatedIssue1.Description != "See new-3 for details" {
t.Errorf("Expected description 'See new-3 for details', got %q", updatedIssue1.Description)
}
updatedIssue2, err := testStore.GetIssue(ctx, "new-2")
if err != nil {
t.Fatalf("Failed to get updated issue2: %v", err)
}
if updatedIssue2.Title != "Related to new-1" {
t.Errorf("Expected title 'Related to new-1', got %q", updatedIssue2.Title)
}
if updatedIssue2.Description != "This depends on new-1" {
t.Errorf("Expected description 'This depends on new-1', got %q", updatedIssue2.Description)
}
updatedIssue3, err := testStore.GetIssue(ctx, "new-3")
if err != nil {
t.Fatalf("Failed to get updated issue3: %v", err)
}
if updatedIssue3.Design != "Mentions new-2 in design" {
t.Errorf("Expected design 'Mentions new-2 in design', got %q", updatedIssue3.Design)
}
deps, err := testStore.GetDependencies(ctx, "new-1")
if err != nil {
t.Fatalf("Failed to get dependencies: %v", err)
}
if len(deps) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(deps))
}
if deps[0].ID != "new-2" {
t.Errorf("Expected dependency ID 'new-2', got %q", deps[0].ID)
}
oldIssue, err := testStore.GetIssue(ctx, "old-1")
if err == nil && oldIssue != nil {
t.Errorf("Expected old-1 to not exist, but got: %+v", oldIssue)
}
}
func TestRenamePrefixInDB(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
testStore, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
t.Cleanup(func() {
testStore.Close()
os.Remove(dbPath)
})
ctx := context.Background()
store = testStore
actor = "test-actor"
if err := testStore.SetConfig(ctx, "issue_prefix", "old"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
issue1 := &types.Issue{
ID: "old-1",
Title: "Test issue",
Description: "Description",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
issues := []*types.Issue{issue1}
err = renamePrefixInDB(ctx, "old", "new", issues)
if err != nil {
t.Fatalf("renamePrefixInDB failed: %v", err)
}
oldIssue, err := testStore.GetIssue(ctx, "old-1")
if err == nil && oldIssue != nil {
t.Errorf("Expected old-1 to not exist after rename, got: %+v", oldIssue)
}
newIssue, err := testStore.GetIssue(ctx, "new-1")
if err != nil {
t.Fatalf("Failed to get new-1: %v", err)
}
if newIssue.ID != "new-1" {
t.Errorf("Expected ID 'new-1', got %q", newIssue.ID)
}
}