Files
beads/cmd/bd/import_collision_regression_test.go
Steve Yegge e3ff12448f Fix: RemapCollisions deletes existing issue dependencies (GH #120, bd-56)
Bug: updateDependencyReferences() was incorrectly updating ALL dependencies
in the database during collision resolution with --resolve-collisions,
including dependencies belonging to existing issues.

Root cause: The function checked if dep.IssueID was in idMapping keys
(old imported IDs like 'bd-1'), but those are also the IDs of existing
database issues. This caused existing dependencies to be incorrectly
modified or deleted.

Fix: Changed logic to only update dependencies where IssueID is in
idMapping VALUES (new remapped IDs like 'bd-295'). This ensures only
dependencies from remapped issues are updated, not existing ones.

During normal import flow, this is effectively a no-op since imported
dependencies haven't been added to the database yet when RemapCollisions
runs (they're added later in Phase 5 of import_shared.go).

Changes:
- Updated updateDependencyReferences() in collision.go to build a set
  of new remapped IDs and only update dependencies with those IDs
- Added comprehensive documentation explaining the correct semantics
- Added regression tests: TestRemapCollisionsRemapsImportedNotExisting
  and TestRemapCollisionsDoesNotUpdateNonexistentDependencies
- Skipped 3 tests that expected the old buggy behavior with clear
  notes about why they need to be rewritten

Real-world impact: In one case, 125 dependencies were incorrectly
deleted from 157 existing issues during collision resolution.

Fixes https://github.com/steveyegge/beads/issues/120
Fixes bd-56
2025-10-23 10:25:13 -07:00

299 lines
9.4 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestRemapCollisionsRemapsImportedNotExisting verifies the bug fix where collision
// resolution incorrectly modified existing issue dependencies.
//
// Bug (fixed): updateDependencyReferences() was updating ALL dependencies in the database
// based on the idMapping, without distinguishing between dependencies belonging to
// IMPORTED issues (should be updated) vs EXISTING issues (should NOT be touched).
//
// This test ensures existing issue dependencies are preserved during collision resolution.
func TestRemapCollisionsRemapsImportedNotExisting(t *testing.T) {
// Setup: Create temporary database
tmpDir, err := os.MkdirTemp("", "collision-bug-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Step 1: Create existing issues with dependencies
existingIssues := []*types.Issue{
{
ID: "bd-1",
Title: "Existing BD-1",
Description: "Original database issue 1, depends on bd-2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
},
{
ID: "bd-2",
Title: "Existing BD-2",
Description: "Original database issue 2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
},
{
ID: "bd-3",
Title: "Existing BD-3",
Description: "Original database issue 3, depends on bd-1",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
},
}
for _, issue := range existingIssues {
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create existing issue %s: %v", issue.ID, err)
}
}
// Add dependencies between existing issues
dep1 := &types.Dependency{
IssueID: "bd-1",
DependsOnID: "bd-2",
Type: types.DepBlocks,
}
dep2 := &types.Dependency{
IssueID: "bd-3",
DependsOnID: "bd-1",
Type: types.DepBlocks,
}
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
t.Fatalf("failed to add dependency bd-1 → bd-2: %v", err)
}
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
t.Fatalf("failed to add dependency bd-3 → bd-1: %v", err)
}
// Step 2: Simulate importing issues with same IDs but different content
importedIssues := []*types.Issue{
{
ID: "bd-1",
Title: "Imported BD-1",
Description: "From import",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
{
ID: "bd-2",
Title: "Imported BD-2",
Description: "From import",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
{
ID: "bd-3",
Title: "Imported BD-3",
Description: "From import",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
}
// Step 3: Detect collisions
collisionResult, err := sqlite.DetectCollisions(ctx, store, importedIssues)
if err != nil {
t.Fatalf("collision detection failed: %v", err)
}
if len(collisionResult.Collisions) != 3 {
t.Fatalf("expected 3 collisions, got %d", len(collisionResult.Collisions))
}
// Step 4: Resolve collisions
allExisting, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("failed to get existing issues: %v", err)
}
if err := sqlite.ScoreCollisions(ctx, store, collisionResult.Collisions, allExisting); err != nil {
t.Fatalf("failed to score collisions: %v", err)
}
idMapping, err := sqlite.RemapCollisions(ctx, store, collisionResult.Collisions, allExisting)
if err != nil {
t.Fatalf("RemapCollisions failed: %v", err)
}
// Step 5: Verify existing issue dependencies are preserved
t.Logf("\n=== Verifying Existing Issue Dependencies ===")
// Check bd-1 → bd-2 dependency (created before import)
existingDeps1, _ := store.GetDependencyRecords(ctx, "bd-1")
t.Logf("bd-1 dependencies: %d (expected: 1)", len(existingDeps1))
if len(existingDeps1) == 0 {
t.Errorf("BUG CONFIRMED: Existing bd-1 has ZERO dependencies after import!")
t.Errorf(" Expected: bd-1 → bd-2 (created by test before import)")
t.Errorf(" Actual: All dependencies deleted by updateDependencyReferences()")
} else if len(existingDeps1) != 1 {
t.Errorf("Expected 1 dependency for bd-1, got %d", len(existingDeps1))
} else {
// Verify the dependency is correct
if existingDeps1[0].DependsOnID != "bd-2" {
t.Errorf("Expected bd-1 → bd-2, got bd-1 → %s", existingDeps1[0].DependsOnID)
}
}
// Check bd-3 → bd-1 dependency (created before import)
existingDeps3, _ := store.GetDependencyRecords(ctx, "bd-3")
t.Logf("bd-3 dependencies: %d (expected: 1)", len(existingDeps3))
if len(existingDeps3) == 0 {
t.Errorf("BUG CONFIRMED: Existing bd-3 has ZERO dependencies after import!")
t.Errorf(" Expected: bd-3 → bd-1 (created by test before import)")
t.Errorf(" Actual: All dependencies deleted by updateDependencyReferences()")
} else if len(existingDeps3) != 1 {
t.Errorf("Expected 1 dependency for bd-3, got %d", len(existingDeps3))
} else {
// Verify the dependency is correct
if existingDeps3[0].DependsOnID != "bd-1" {
t.Errorf("Expected bd-3 → bd-1, got bd-3 → %s", existingDeps3[0].DependsOnID)
}
}
t.Logf("\nID Mappings: %v", idMapping)
t.Logf("Fix verified: Existing issue dependencies preserved during collision resolution")
}
// TestRemapCollisionsDoesNotUpdateNonexistentDependencies verifies that
// updateDependencyReferences is effectively a no-op during normal import flow,
// since imported dependencies haven't been added to the database yet when
// RemapCollisions runs.
//
// This test demonstrates that even if we had dependencies with the old imported IDs
// in the database, they are NOT touched because they don't have the NEW remapped IDs.
func TestRemapCollisionsDoesNotUpdateNonexistentDependencies(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "collision-noop-deps-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Step 1: Create existing issue with dependency
existing1 := &types.Issue{
ID: "bd-1",
Title: "Existing BD-1",
Description: "Original database issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
existing2 := &types.Issue{
ID: "bd-2",
Title: "Existing BD-2",
Description: "Original database issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, existing1, "test"); err != nil {
t.Fatalf("failed to create existing issue bd-1: %v", err)
}
if err := store.CreateIssue(ctx, existing2, "test"); err != nil {
t.Fatalf("failed to create existing issue bd-2: %v", err)
}
// Add dependency between existing issues
existingDep := &types.Dependency{
IssueID: "bd-1",
DependsOnID: "bd-2",
Type: types.DepBlocks,
}
if err := store.AddDependency(ctx, existingDep, "test"); err != nil {
t.Fatalf("failed to add existing dependency: %v", err)
}
// Step 2: Import colliding issues (without dependencies in DB)
imported := []*types.Issue{
{
ID: "bd-1",
Title: "Imported BD-1",
Description: "From import, will be remapped",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
}
// Detect and resolve collisions
collisionResult, err := sqlite.DetectCollisions(ctx, store, imported)
if err != nil {
t.Fatalf("collision detection failed: %v", err)
}
allExisting, _ := store.SearchIssues(ctx, "", types.IssueFilter{})
if err := sqlite.ScoreCollisions(ctx, store, collisionResult.Collisions, allExisting); err != nil {
t.Fatalf("failed to score collisions: %v", err)
}
// Now remap collisions - this should NOT touch the existing bd-1 → bd-2 dependency
idMapping, err := sqlite.RemapCollisions(ctx, store, collisionResult.Collisions, allExisting)
if err != nil {
t.Fatalf("RemapCollisions failed: %v", err)
}
// Step 3: Verify existing dependency is untouched
existingDeps, err := store.GetDependencyRecords(ctx, "bd-1")
if err != nil {
t.Fatalf("failed to get dependencies for bd-1: %v", err)
}
if len(existingDeps) != 1 {
t.Errorf("Expected 1 dependency for existing bd-1, got %d (dependency should not be touched)", len(existingDeps))
} else {
if existingDeps[0].DependsOnID != "bd-2" {
t.Errorf("Expected bd-1 → bd-2, got bd-1 → %s", existingDeps[0].DependsOnID)
}
}
// Verify the remapped issue exists but has no dependencies
// (because dependencies are imported later in Phase 5)
remappedID := idMapping["bd-1"]
remappedDeps, err := store.GetDependencyRecords(ctx, remappedID)
if err != nil {
t.Fatalf("failed to get dependencies for %s: %v", remappedID, err)
}
if len(remappedDeps) != 0 {
t.Errorf("Expected 0 dependencies for remapped %s (dependencies added later), got %d",
remappedID, len(remappedDeps))
}
t.Logf("Verified: updateDependencyReferences is effectively a no-op when no remapped dependencies exist")
}