package sqlite import ( "context" "os" "testing" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/types" ) func TestGetNextChildID(t *testing.T) { tmpFile := t.TempDir() + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create a parent issue with hash ID parent := &types.Issue{ ID: "bd-a3f8e9", Title: "Parent Epic", Description: "Parent issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, } if err := store.CreateIssue(ctx, parent, "test"); err != nil { t.Fatalf("failed to create parent: %v", err) } // Test: Generate first child ID childID1, err := store.GetNextChildID(ctx, parent.ID) if err != nil { t.Fatalf("GetNextChildID failed: %v", err) } expectedID1 := "bd-a3f8e9.1" if childID1 != expectedID1 { t.Errorf("expected %s, got %s", expectedID1, childID1) } // Test: Generate second child ID (sequential) childID2, err := store.GetNextChildID(ctx, parent.ID) if err != nil { t.Fatalf("GetNextChildID failed: %v", err) } expectedID2 := "bd-a3f8e9.2" if childID2 != expectedID2 { t.Errorf("expected %s, got %s", expectedID2, childID2) } // Create the first child and test nested hierarchy child1 := &types.Issue{ ID: childID1, Title: "Child Task 1", Description: "First child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, child1, "test"); err != nil { t.Fatalf("failed to create child: %v", err) } // Test: Generate nested child (depth 2) nestedID1, err := store.GetNextChildID(ctx, childID1) if err != nil { t.Fatalf("GetNextChildID failed for nested: %v", err) } expectedNested1 := "bd-a3f8e9.1.1" if nestedID1 != expectedNested1 { t.Errorf("expected %s, got %s", expectedNested1, nestedID1) } // Create the nested child nested1 := &types.Issue{ ID: nestedID1, Title: "Nested Task", Description: "Nested child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, nested1, "test"); err != nil { t.Fatalf("failed to create nested child: %v", err) } // Test: Generate third level (depth 3, maximum) deepID1, err := store.GetNextChildID(ctx, nestedID1) if err != nil { t.Fatalf("GetNextChildID failed for depth 3: %v", err) } expectedDeep1 := "bd-a3f8e9.1.1.1" if deepID1 != expectedDeep1 { t.Errorf("expected %s, got %s", expectedDeep1, deepID1) } // Create the deep child deep1 := &types.Issue{ ID: deepID1, Title: "Deep Task", Description: "Third level", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, deep1, "test"); err != nil { t.Fatalf("failed to create deep child: %v", err) } // Test: Attempt to create fourth level (should fail) _, err = store.GetNextChildID(ctx, deepID1) if err == nil { t.Errorf("expected error for depth 4, got nil") } if err != nil && err.Error() != "maximum hierarchy depth (3) exceeded for parent bd-a3f8e9.1.1.1" { t.Errorf("unexpected error message: %v", err) } } func TestGetNextChildID_ParentNotExists(t *testing.T) { tmpFile := t.TempDir() + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Test: Attempt to get child ID for non-existent parent _, err := store.GetNextChildID(ctx, "bd-nonexistent") if err == nil { t.Errorf("expected error for non-existent parent, got nil") } // With resurrection feature (bd-dvd fix), error message includes JSONL history check expectedErr := "parent issue bd-nonexistent does not exist and could not be resurrected from JSONL history" if err != nil && err.Error() != expectedErr { t.Errorf("unexpected error message: got %q, want %q", err.Error(), expectedErr) } } func TestCreateIssue_HierarchicalID(t *testing.T) { tmpFile := t.TempDir() + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create parent parent := &types.Issue{ ID: "bd-parent1", Title: "Parent", Description: "Parent issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, } if err := store.CreateIssue(ctx, parent, "test"); err != nil { t.Fatalf("failed to create parent: %v", err) } // Test: Create child with explicit hierarchical ID child := &types.Issue{ ID: "bd-parent1.1", Title: "Child", Description: "Child issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, child, "test"); err != nil { t.Fatalf("failed to create child: %v", err) } // Verify child was created retrieved, err := store.GetIssue(ctx, child.ID) if err != nil { t.Fatalf("failed to retrieve child: %v", err) } if retrieved.ID != child.ID { t.Errorf("expected ID %s, got %s", child.ID, retrieved.ID) } } // TestExplicitChildIDUpdatesCounter verifies that creating issues with explicit // hierarchical IDs (e.g., bd-test.1, bd-test.2) updates the child counter so that // GetNextChildID returns the correct next ID (GH#728 fix) func TestExplicitChildIDUpdatesCounter(t *testing.T) { tmpFile := t.TempDir() + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create parent parent := &types.Issue{ ID: "bd-test", Title: "Parent", Description: "Test parent", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeEpic, } if err := store.CreateIssue(ctx, parent, "test"); err != nil { t.Fatalf("failed to create parent: %v", err) } // Create explicit child .1 child1 := &types.Issue{ ID: "bd-test.1", Title: "Existing child 1", Description: "Created with explicit ID", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, child1, "test"); err != nil { t.Fatalf("failed to create child1: %v", err) } // Create explicit child .2 child2 := &types.Issue{ ID: "bd-test.2", Title: "Existing child 2", Description: "Created with explicit ID", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, child2, "test"); err != nil { t.Fatalf("failed to create child2: %v", err) } // Now use GetNextChildID - should return .3 (not .1 which would collide) nextID, err := store.GetNextChildID(ctx, "bd-test") if err != nil { t.Fatalf("GetNextChildID failed: %v", err) } expected := "bd-test.3" if nextID != expected { t.Errorf("GetNextChildID returned %s, expected %s (GH#728 - counter should be updated when explicit child IDs are created)", nextID, expected) } // Verify we can create an issue with the returned ID without collision child3 := &types.Issue{ ID: nextID, Title: "New child via --parent", Description: "Created with GetNextChildID", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, child3, "test"); err != nil { t.Fatalf("failed to create child3 with ID %s: %v", nextID, err) } } func TestCreateIssue_HierarchicalID_ParentNotExists(t *testing.T) { tmpFile := t.TempDir() + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Test: Attempt to create child without parent child := &types.Issue{ ID: "bd-nonexistent.1", Title: "Child", Description: "Child issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } err := store.CreateIssue(ctx, child, "test") if err == nil { t.Errorf("expected error for child without parent, got nil") } // With resurrection feature, error message includes JSONL history check expectedErr := "parent issue bd-nonexistent does not exist and could not be resurrected from JSONL history" if err != nil && err.Error() != expectedErr { t.Errorf("unexpected error message: got %q, want %q", err.Error(), expectedErr) } } func TestGetNextChildID_ResurrectParent(t *testing.T) { tmpDir := t.TempDir() tmpFile := tmpDir + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create parent issue parent := &types.Issue{ ID: "bd-test123", ContentHash: "abc123", Title: "Parent Issue", Description: "Parent to be resurrected", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, } if err := store.CreateIssue(ctx, parent, "test"); err != nil { t.Fatalf("failed to create parent: %v", err) } // Delete the parent from database (simulating deletion) if err := store.DeleteIssue(ctx, parent.ID); err != nil { t.Fatalf("failed to delete parent: %v", err) } // Create JSONL file with the deleted parent (simulating JSONL history) // Note: This requires the JSONL to be in .beads/issues.jsonl relative to dbPath // The resurrection logic looks for issues.jsonl in the same directory as the database beadsDir := tmpDir jsonlPath := beadsDir + "/issues.jsonl" // Write parent to JSONL jsonlFile, err := os.Create(jsonlPath) if err != nil { t.Fatalf("failed to create JSONL file: %v", err) } parentJSON := `{"id":"bd-test123","content_hash":"abc123","title":"Parent Issue","description":"Parent to be resurrected","status":"open","priority":1,"type":"epic","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}` if _, err := jsonlFile.WriteString(parentJSON + "\n"); err != nil { jsonlFile.Close() t.Fatalf("failed to write to JSONL: %v", err) } jsonlFile.Close() // Now attempt to get next child ID - should resurrect parent childID, err := store.GetNextChildID(ctx, parent.ID) if err != nil { t.Fatalf("GetNextChildID should have resurrected parent, but got error: %v", err) } expectedID := "bd-test123.1" if childID != expectedID { t.Errorf("expected child ID %s, got %s", expectedID, childID) } // Verify parent was resurrected as tombstone resurrectedParent, err := store.GetIssue(ctx, parent.ID) if err != nil { t.Fatalf("failed to get resurrected parent: %v", err) } if resurrectedParent.Status != types.StatusClosed { t.Errorf("expected resurrected parent to be closed, got %s", resurrectedParent.Status) } if resurrectedParent.Title != "Parent Issue" { t.Errorf("expected resurrected parent title to be preserved, got %s", resurrectedParent.Title) } } // TestGetNextChildID_ResurrectParent_NotInJSONL tests resurrection when parent doesn't exist in JSONL (bd-ar2.7) func TestGetNextChildID_ResurrectParent_NotInJSONL(t *testing.T) { tmpDir := t.TempDir() tmpFile := tmpDir + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create empty JSONL file (parent not in history) jsonlPath := tmpDir + "/issues.jsonl" if err := os.WriteFile(jsonlPath, []byte(""), 0600); err != nil { t.Fatalf("failed to create JSONL file: %v", err) } // Attempt to get child ID for non-existent parent not in JSONL _, err := store.GetNextChildID(ctx, "bd-notfound") if err == nil { t.Errorf("expected error for parent not in JSONL, got nil") } expectedErr := "parent issue bd-notfound does not exist and could not be resurrected from JSONL history" if err != nil && err.Error() != expectedErr { t.Errorf("unexpected error: got %q, want %q", err.Error(), expectedErr) } } // TestGetNextChildID_ResurrectParent_NoJSONL tests resurrection when JSONL file doesn't exist (bd-ar2.7) func TestGetNextChildID_ResurrectParent_NoJSONL(t *testing.T) { tmpDir := t.TempDir() tmpFile := tmpDir + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // No JSONL file created // Attempt to get child ID for non-existent parent _, err := store.GetNextChildID(ctx, "bd-missing") if err == nil { t.Errorf("expected error for parent with no JSONL, got nil") } expectedErr := "parent issue bd-missing does not exist and could not be resurrected from JSONL history" if err != nil && err.Error() != expectedErr { t.Errorf("unexpected error: got %q, want %q", err.Error(), expectedErr) } } // TestGetNextChildID_ResurrectParent_MalformedJSONL tests resurrection with invalid JSON lines (bd-ar2.7) func TestGetNextChildID_ResurrectParent_MalformedJSONL(t *testing.T) { tmpDir := t.TempDir() tmpFile := tmpDir + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create JSONL with malformed lines and one valid parent jsonlPath := tmpDir + "/issues.jsonl" jsonlContent := `{invalid json {"id":"bd-test456","content_hash":"def456","title":"Valid Parent","description":"Should be found","status":"open","priority":1,"type":"epic","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"} this is not json either ` if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0600); err != nil { t.Fatalf("failed to create JSONL file: %v", err) } // Should successfully resurrect despite malformed lines childID, err := store.GetNextChildID(ctx, "bd-test456") if err != nil { t.Fatalf("GetNextChildID should skip malformed lines and resurrect valid parent, got error: %v", err) } expectedID := "bd-test456.1" if childID != expectedID { t.Errorf("expected child ID %s, got %s", expectedID, childID) } } // TestGetNextChildID_ConfigurableMaxDepth tests that hierarchy.max-depth config is respected (GH#995) func TestGetNextChildID_ConfigurableMaxDepth(t *testing.T) { // Initialize config for testing if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } // Ensure config is reset even if test fails or panics t.Cleanup(func() { config.Set("hierarchy.max-depth", 3) }) tmpFile := t.TempDir() + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create a chain of issues up to depth 3 issues := []struct { id string title string }{ {"bd-depth", "Root"}, {"bd-depth.1", "Level 1"}, {"bd-depth.1.1", "Level 2"}, {"bd-depth.1.1.1", "Level 3"}, } for _, issue := range issues { iss := &types.Issue{ ID: issue.id, Title: issue.title, Description: "Test issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, iss, "test"); err != nil { t.Fatalf("failed to create issue %s: %v", issue.id, err) } } // Test 1: With default max-depth (3), depth 4 should fail config.Set("hierarchy.max-depth", 3) _, err := store.GetNextChildID(ctx, "bd-depth.1.1.1") if err == nil { t.Errorf("expected error for depth 4 with max-depth=3, got nil") } if err != nil && err.Error() != "maximum hierarchy depth (3) exceeded for parent bd-depth.1.1.1" { t.Errorf("unexpected error message: %v", err) } // Test 2: With max-depth=5, depth 4 should succeed config.Set("hierarchy.max-depth", 5) childID, err := store.GetNextChildID(ctx, "bd-depth.1.1.1") if err != nil { t.Errorf("depth 4 should be allowed with max-depth=5, got error: %v", err) } expectedID := "bd-depth.1.1.1.1" if childID != expectedID { t.Errorf("expected %s, got %s", expectedID, childID) } // Test 3: With max-depth=2, depth 3 should fail config.Set("hierarchy.max-depth", 2) _, err = store.GetNextChildID(ctx, "bd-depth.1.1") if err == nil { t.Errorf("expected error for depth 3 with max-depth=2, got nil") } if err != nil && err.Error() != "maximum hierarchy depth (2) exceeded for parent bd-depth.1.1" { t.Errorf("unexpected error message: %v", err) } } // TestGetNextChildID_ResurrectParentChain tests resurrection of deeply nested missing parents (bd-ar2.7) func TestGetNextChildID_ResurrectParentChain(t *testing.T) { tmpDir := t.TempDir() tmpFile := tmpDir + "/test.db" defer os.Remove(tmpFile) store := newTestStore(t, tmpFile) defer store.Close() ctx := context.Background() // Create root parent only root := &types.Issue{ ID: "bd-root", ContentHash: "root123", Title: "Root Issue", Description: "Root", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, } if err := store.CreateIssue(ctx, root, "test"); err != nil { t.Fatalf("failed to create root: %v", err) } // Create JSONL with intermediate parents that are deleted jsonlPath := tmpDir + "/issues.jsonl" jsonlContent := `{"id":"bd-root","content_hash":"root123","title":"Root Issue","description":"Root","status":"open","priority":1,"type":"epic","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"} {"id":"bd-root.1","content_hash":"l1abc","title":"Level 1","description":"First level","status":"open","priority":1,"type":"task","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"} {"id":"bd-root.1.2","content_hash":"l2abc","title":"Level 2","description":"Second level","status":"open","priority":1,"type":"task","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"} ` if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0600); err != nil { t.Fatalf("failed to create JSONL file: %v", err) } // Try to create child of bd-root.1.2 (which doesn't exist in DB, but its parent bd-root.1 also doesn't exist) // With TryResurrectParentChain (bd-ar2.4), this should work childID, err := store.GetNextChildID(ctx, "bd-root.1.2") if err != nil { t.Fatalf("GetNextChildID should resurrect entire parent chain, got error: %v", err) } expectedID := "bd-root.1.2.1" if childID != expectedID { t.Errorf("expected child ID %s, got %s", expectedID, childID) } // Verify both intermediate parents were resurrected parent1, err := store.GetIssue(ctx, "bd-root.1") if err != nil { t.Fatalf("bd-root.1 should have been resurrected: %v", err) } if parent1.Status != types.StatusClosed { t.Errorf("expected resurrected parent to be closed, got %s", parent1.Status) } parent2, err := store.GetIssue(ctx, "bd-root.1.2") if err != nil { t.Fatalf("bd-root.1.2 should have been resurrected: %v", err) } if parent2.Status != types.StatusClosed { t.Errorf("expected resurrected parent to be closed, got %s", parent2.Status) } }