Files
beads/internal/storage/sqlite/child_counters_test.go
Steve Yegge 1e7c7f5e54 fix(sqlite): update child_counters when explicit child IDs are created (GH#728)
When creating issues with explicit hierarchical IDs (e.g., bd-test.1, bd-test.2
via --id flag or import), the child_counters table was not being updated.
This caused GetNextChildID to return colliding IDs when later called with
--parent.

Changes:
- Add ensureChildCounterUpdatedWithConn() to update counter on explicit child creation
- Add ParseHierarchicalID() to extract parent and child number from IDs
- Update CreateIssue to call counter update after hierarchical ID validation
- Update EnsureIDs to call counter update when parent exists
- Add post-insert phase in batch operations to update counters after FK
  constraint can be satisfied
- Update tests to reflect new behavior where counter is properly initialized

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:22:55 -08:00

236 lines
6.2 KiB
Go

package sqlite
import (
"context"
"sync"
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestChildCountersTableExists(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Verify table exists by querying it
var count int
err := store.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='child_counters'`).Scan(&count)
if err != nil {
t.Fatalf("failed to check for child_counters table: %v", err)
}
if count != 1 {
t.Errorf("child_counters table not found, got count %d", count)
}
}
func TestGetNextChildNumber(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
parentID := "bd-af78e9a2"
// Create parent issue first (required by foreign key)
parent := &types.Issue{
ID: parentID,
Title: "Parent epic",
Description: "Test parent",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, parent, "test-user"); err != nil {
t.Fatalf("failed to create parent issue: %v", err)
}
// First child should be 1
child1, err := store.getNextChildNumber(ctx, parentID)
if err != nil {
t.Fatalf("getNextChildNumber failed: %v", err)
}
if child1 != 1 {
t.Errorf("expected first child to be 1, got %d", child1)
}
// Second child should be 2
child2, err := store.getNextChildNumber(ctx, parentID)
if err != nil {
t.Fatalf("getNextChildNumber failed: %v", err)
}
if child2 != 2 {
t.Errorf("expected second child to be 2, got %d", child2)
}
// Third child should be 3
child3, err := store.getNextChildNumber(ctx, parentID)
if err != nil {
t.Fatalf("getNextChildNumber failed: %v", err)
}
if child3 != 3 {
t.Errorf("expected third child to be 3, got %d", child3)
}
}
func TestGetNextChildNumber_DifferentParents(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
parent1 := "bd-af78e9a2"
parent2 := "bd-bf89e0b3" // Use non-hierarchical ID to avoid counter interaction
// Create parent issues first
for _, id := range []string{parent1, parent2} {
parent := &types.Issue{
ID: id,
Title: "Parent " + id,
Description: "Test parent",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, parent, "test-user"); err != nil {
t.Fatalf("failed to create parent issue %s: %v", id, err)
}
}
// Each parent should have independent counters
child1_1, err := store.getNextChildNumber(ctx, parent1)
if err != nil {
t.Fatalf("getNextChildNumber failed: %v", err)
}
if child1_1 != 1 {
t.Errorf("expected parent1 child to be 1, got %d", child1_1)
}
child2_1, err := store.getNextChildNumber(ctx, parent2)
if err != nil {
t.Fatalf("getNextChildNumber failed: %v", err)
}
if child2_1 != 1 {
t.Errorf("expected parent2 child to be 1, got %d", child2_1)
}
child1_2, err := store.getNextChildNumber(ctx, parent1)
if err != nil {
t.Fatalf("getNextChildNumber failed: %v", err)
}
if child1_2 != 2 {
t.Errorf("expected parent1 second child to be 2, got %d", child1_2)
}
}
func TestGetNextChildNumber_Concurrent(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
parentID := "bd-af78e9a2"
numWorkers := 10
// Create parent issue first
parent := &types.Issue{
ID: parentID,
Title: "Parent epic",
Description: "Test parent",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, parent, "test-user"); err != nil {
t.Fatalf("failed to create parent issue: %v", err)
}
// Track all generated child numbers
childNumbers := make([]int, numWorkers)
var wg sync.WaitGroup
// Spawn concurrent workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
child, err := store.getNextChildNumber(ctx, parentID)
if err != nil {
t.Errorf("concurrent getNextChildNumber failed: %v", err)
return
}
childNumbers[idx] = child
}(i)
}
wg.Wait()
// Verify all numbers are unique and in range [1, numWorkers]
seen := make(map[int]bool)
for _, num := range childNumbers {
if num < 1 || num > numWorkers {
t.Errorf("child number %d out of expected range [1, %d]", num, numWorkers)
}
if seen[num] {
t.Errorf("duplicate child number: %d", num)
}
seen[num] = true
}
// Verify we got all numbers from 1 to numWorkers
if len(seen) != numWorkers {
t.Errorf("expected %d unique child numbers, got %d", numWorkers, len(seen))
}
}
func TestGetNextChildNumber_NestedHierarchy(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create parent issues for nested hierarchy
// Note: When creating bd-af78e9a2.1, the counter for bd-af78e9a2 is set to 1 (GH#728 fix)
// When creating bd-af78e9a2.1.2, the counter for bd-af78e9a2.1 is set to 2 (GH#728 fix)
parents := []string{"bd-af78e9a2", "bd-af78e9a2.1", "bd-af78e9a2.1.2"}
for _, id := range parents {
parent := &types.Issue{
ID: id,
Title: "Parent " + id,
Description: "Test parent",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, parent, "test-user"); err != nil {
t.Fatalf("failed to create parent issue %s: %v", id, err)
}
}
// With GH#728 fix, counters are updated when explicit hierarchical IDs are created:
// - Creating bd-af78e9a2.1 sets counter for bd-af78e9a2 to 1
// - Creating bd-af78e9a2.1.2 sets counter for bd-af78e9a2.1 to 2
// So getNextChildNumber returns the NEXT number after the existing children
tests := []struct {
parent string
expected []int
}{
{"bd-af78e9a2", []int{2, 3}}, // counter was 1 after creating .1
{"bd-af78e9a2.1", []int{3, 4}}, // counter was 2 after creating .1.2
{"bd-af78e9a2.1.2", []int{1, 2}}, // no children created, starts at 1
}
for _, tt := range tests {
for _, want := range tt.expected {
got, err := store.getNextChildNumber(ctx, tt.parent)
if err != nil {
t.Fatalf("getNextChildNumber(%s) failed: %v", tt.parent, err)
}
if got != want {
t.Errorf("parent %s: expected child %d, got %d", tt.parent, want, got)
}
}
}
}