Files
beads/internal/storage/sqlite/collision_test.go

1147 lines
32 KiB
Go

package sqlite
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/types"
)
const testIssueBD1 = "bd-1"
func TestDetectCollisions(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "collision-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 := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Set issue prefix to prevent "database not initialized" errors
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Setup: Create some existing issues in the database
existingIssue1 := &types.Issue{
ID: testIssueBD1,
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
existingIssue2 := &types.Issue{
ID: "bd-2",
Title: "Existing issue 2",
Description: "Another existing issue",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
}
if err := store.CreateIssue(ctx, existingIssue1, "test"); err != nil {
t.Fatalf("failed to create existing issue 1: %v", err)
}
if err := store.CreateIssue(ctx, existingIssue2, "test"); err != nil {
t.Fatalf("failed to create existing issue 2: %v", err)
}
// Test cases
tests := []struct {
name string
incomingIssues []*types.Issue
expectedExact int
expectedCollision int
expectedNew int
checkCollisions func(t *testing.T, collisions []*CollisionDetail)
}{
{
name: "exact match - idempotent import",
incomingIssues: []*types.Issue{
{
ID: testIssueBD1,
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
},
expectedExact: 1,
expectedCollision: 0,
expectedNew: 0,
},
{
name: "new issue - doesn't exist in DB",
incomingIssues: []*types.Issue{
{
ID: "bd-100",
Title: "Brand new issue",
Description: "This doesn't exist yet",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeFeature,
},
},
expectedExact: 0,
expectedCollision: 0,
expectedNew: 1,
},
{
name: "collision - same ID, different title",
incomingIssues: []*types.Issue{
{
ID: testIssueBD1,
Title: "Modified title",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
},
expectedExact: 0,
expectedCollision: 1,
expectedNew: 0,
checkCollisions: func(t *testing.T, collisions []*CollisionDetail) {
if len(collisions) != 1 {
t.Fatalf("expected 1 collision, got %d", len(collisions))
}
if collisions[0].ID != testIssueBD1 {
t.Errorf("expected collision ID bd-1, got %s", collisions[0].ID)
}
if len(collisions[0].ConflictingFields) != 1 {
t.Errorf("expected 1 conflicting field, got %d", len(collisions[0].ConflictingFields))
}
if collisions[0].ConflictingFields[0] != "title" {
t.Errorf("expected conflicting field 'title', got %s", collisions[0].ConflictingFields[0])
}
},
},
{
name: "collision - multiple fields differ",
incomingIssues: []*types.Issue{
{
ID: "bd-2",
Title: "Changed title",
Description: "Changed description",
Status: types.StatusClosed,
Priority: 3,
IssueType: types.TypeFeature,
},
},
expectedExact: 0,
expectedCollision: 1,
expectedNew: 0,
checkCollisions: func(t *testing.T, collisions []*CollisionDetail) {
if len(collisions) != 1 {
t.Fatalf("expected 1 collision, got %d", len(collisions))
}
// Should have multiple conflicting fields
expectedFields := map[string]bool{
"title": true,
"description": true,
"status": true,
"priority": true,
"issue_type": true,
}
for _, field := range collisions[0].ConflictingFields {
if !expectedFields[field] {
t.Errorf("unexpected conflicting field: %s", field)
}
delete(expectedFields, field)
}
if len(expectedFields) > 0 {
t.Errorf("missing expected conflicting fields: %v", expectedFields)
}
},
},
{
name: "mixed - exact, collision, and new",
incomingIssues: []*types.Issue{
{
// Exact match
ID: testIssueBD1,
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
{
// Collision
ID: "bd-2",
Title: "Modified issue 2",
Description: "Another existing issue",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
},
{
// New issue
ID: "bd-200",
Title: "New issue",
Description: "This is new",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
},
expectedExact: 1,
expectedCollision: 1,
expectedNew: 1,
},
{
name: "collision - estimated_minutes differs",
incomingIssues: []*types.Issue{
{
ID: testIssueBD1,
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(60),
},
},
expectedExact: 0,
expectedCollision: 1,
expectedNew: 0,
checkCollisions: func(t *testing.T, collisions []*CollisionDetail) {
if len(collisions[0].ConflictingFields) != 1 {
t.Errorf("expected 1 conflicting field, got %d", len(collisions[0].ConflictingFields))
}
if collisions[0].ConflictingFields[0] != "estimated_minutes" {
t.Errorf("expected conflicting field 'estimated_minutes', got %s", collisions[0].ConflictingFields[0])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := DetectCollisions(ctx, store, tt.incomingIssues)
if err != nil {
t.Fatalf("DetectCollisions failed: %v", err)
}
if len(result.ExactMatches) != tt.expectedExact {
t.Errorf("expected %d exact matches, got %d", tt.expectedExact, len(result.ExactMatches))
}
if len(result.Collisions) != tt.expectedCollision {
t.Errorf("expected %d collisions, got %d", tt.expectedCollision, len(result.Collisions))
}
if len(result.NewIssues) != tt.expectedNew {
t.Errorf("expected %d new issues, got %d", tt.expectedNew, len(result.NewIssues))
}
if tt.checkCollisions != nil {
tt.checkCollisions(t, result.Collisions)
}
})
}
}
func TestCompareIssues(t *testing.T) {
tests := []struct {
name string
existing *types.Issue
incoming *types.Issue
expected []string
}{
{
name: "identical issues",
existing: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test desc",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
incoming: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test desc",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
expected: []string{},
},
{
name: "different title",
existing: &types.Issue{
ID: testIssueBD1,
Title: "Original",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
incoming: &types.Issue{
ID: testIssueBD1,
Title: "Modified",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
expected: []string{"title"},
},
{
name: "different status and priority",
existing: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
incoming: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusClosed,
Priority: 3,
IssueType: types.TypeTask,
},
expected: []string{"status", "priority"},
},
{
name: "estimated_minutes - both nil",
existing: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: nil,
},
incoming: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: nil,
},
expected: []string{},
},
{
name: "estimated_minutes - existing nil, incoming set",
existing: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: nil,
},
incoming: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(30),
},
expected: []string{"estimated_minutes"},
},
{
name: "estimated_minutes - same values",
existing: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(60),
},
incoming: &types.Issue{
ID: testIssueBD1,
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(60),
},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conflicts := compareIssues(tt.existing, tt.incoming)
if len(conflicts) != len(tt.expected) {
t.Errorf("expected %d conflicts, got %d: %v", len(tt.expected), len(conflicts), conflicts)
return
}
for i, expected := range tt.expected {
if conflicts[i] != expected {
t.Errorf("conflict[%d]: expected %s, got %s", i, expected, conflicts[i])
}
}
})
}
}
func TestEqualIntPtr(t *testing.T) {
tests := []struct {
name string
a *int
b *int
expected bool
}{
{"both nil", nil, nil, true},
{"a nil, b set", nil, intPtr(5), false},
{"a set, b nil", intPtr(5), nil, false},
{"same values", intPtr(10), intPtr(10), true},
{"different values", intPtr(10), intPtr(20), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := equalIntPtr(tt.a, tt.b)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
// Helper function to create *int from int value
func intPtr(i int) *int {
return &i
}
func TestScoreCollisions(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "score-collision-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 := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Set issue prefix to prevent "database not initialized" errors
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Setup: Create issues with various reference patterns
issue1 := &types.Issue{
ID: "bd-1",
Title: "Issue 1",
Description: "Depends on bd-2",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue2 := &types.Issue{
ID: "bd-2",
Title: "Issue 2",
Description: "Referenced by bd-1 and bd-3",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "bd-3",
Title: "Issue 3",
Description: "Mentions bd-2 multiple times: bd-2 and bd-2",
Notes: "Also mentions bd-2 here",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue4 := &types.Issue{
ID: "bd-4",
Title: "Issue 4",
Description: "Lonely issue with no references",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
// Create issues in DB
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("failed to create issue1: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("failed to create issue2: %v", err)
}
if err := store.CreateIssue(ctx, issue3, "test"); err != nil {
t.Fatalf("failed to create issue3: %v", err)
}
if err := store.CreateIssue(ctx, issue4, "test"); err != nil {
t.Fatalf("failed to create issue4: %v", err)
}
// Add dependencies
dep1 := &types.Dependency{IssueID: "bd-1", DependsOnID: "bd-2", Type: types.DepBlocks}
dep2 := &types.Dependency{IssueID: "bd-3", DependsOnID: "bd-2", Type: types.DepBlocks}
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
t.Fatalf("failed to add dependency1: %v", err)
}
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
t.Fatalf("failed to add dependency2: %v", err)
}
// Create collision details (simulated)
collisions := []*CollisionDetail{
{
ID: "bd-1",
IncomingIssue: issue1,
ExistingIssue: issue1,
},
{
ID: "bd-2",
IncomingIssue: issue2,
ExistingIssue: issue2,
},
{
ID: "bd-3",
IncomingIssue: issue3,
ExistingIssue: issue3,
},
{
ID: "bd-4",
IncomingIssue: issue4,
ExistingIssue: issue4,
},
}
allIssues := []*types.Issue{issue1, issue2, issue3, issue4}
// Score the collisions
err = ScoreCollisions(ctx, store, collisions, allIssues)
if err != nil {
t.Fatalf("ScoreCollisions failed: %v", err)
}
// Verify RemapIncoming was set based on content hashes (bd-95)
// ScoreCollisions now uses content-based hashing instead of reference counting
// Each collision should have RemapIncoming set based on hash comparison
for _, collision := range collisions {
existingHash := hashIssueContent(collision.ExistingIssue)
incomingHash := hashIssueContent(collision.IncomingIssue)
expectedRemapIncoming := existingHash < incomingHash
if collision.RemapIncoming != expectedRemapIncoming {
t.Errorf("collision %s: RemapIncoming=%v but expected %v (existingHash=%s, incomingHash=%s)",
collision.ID, collision.RemapIncoming, expectedRemapIncoming,
existingHash[:8], incomingHash[:8])
}
}
}
func TestReplaceIDReferences(t *testing.T) {
tests := []struct {
name string
text string
idMapping map[string]string
expected string
}{
{
name: "single replacement",
text: "This references bd-1 in the description",
idMapping: map[string]string{
"bd-1": "bd-100",
},
expected: "This references bd-100 in the description",
},
{
name: "multiple replacements",
text: "bd-1 depends on bd-2 and bd-3",
idMapping: map[string]string{
"bd-1": "bd-100",
"bd-2": "bd-101",
"bd-3": "bd-102",
},
expected: "bd-100 depends on bd-101 and bd-102",
},
{
name: "word boundary - don't replace partial matches",
text: "bd-10 and bd-100 and bd-1",
idMapping: map[string]string{
"bd-1": "bd-200",
},
expected: "bd-10 and bd-100 and bd-200",
},
{
name: "no replacements needed",
text: "This has no matching IDs",
idMapping: map[string]string{
"bd-1": "bd-100",
},
expected: "This has no matching IDs",
},
{
name: "replace same ID multiple times",
text: "bd-1 is mentioned twice: bd-1",
idMapping: map[string]string{
"bd-1": "bd-100",
},
expected: "bd-100 is mentioned twice: bd-100",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := replaceIDReferences(tt.text, tt.idMapping)
if result != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, result)
}
})
}
}
func TestRemapCollisions(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "remap-collision-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 := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Set issue prefix to prevent "database not initialized" errors
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Setup: Create an existing issue in the database with a high ID number
// This ensures that when we remap bd-2 and bd-3, they get new IDs that don't conflict
existingIssue := &types.Issue{
ID: "bd-10",
Title: "Existing issue",
Description: "This mentions bd-2 and bd-3",
Notes: "Also bd-2 here",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, existingIssue, "test"); err != nil {
t.Fatalf("failed to create existing issue: %v", err)
}
// Create existing issues in DB that will collide with incoming issues
dbIssue2 := &types.Issue{
ID: "bd-2",
Title: "Existing issue bd-2",
Description: "Original content for bd-2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, dbIssue2, "test"); err != nil {
t.Fatalf("failed to create dbIssue2: %v", err)
}
dbIssue3 := &types.Issue{
ID: "bd-3",
Title: "Existing issue bd-3",
Description: "Original content for bd-3",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, dbIssue3, "test"); err != nil {
t.Fatalf("failed to create dbIssue3: %v", err)
}
// Create collisions (incoming issues with same IDs as DB but different content)
collision1 := &CollisionDetail{
ID: "bd-2",
ExistingIssue: dbIssue2,
IncomingIssue: &types.Issue{
ID: "bd-2",
Title: "Collision 2",
Description: "This is different content",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
RemapIncoming: true, // Incoming will be remapped
}
collision2 := &CollisionDetail{
ID: "bd-3",
ExistingIssue: dbIssue3,
IncomingIssue: &types.Issue{
ID: "bd-3",
Title: "Collision 3",
Description: "Different content for bd-3",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
RemapIncoming: true, // Incoming will be remapped
}
collisions := []*CollisionDetail{collision1, collision2}
allIssues := []*types.Issue{existingIssue, dbIssue2, dbIssue3, collision1.IncomingIssue, collision2.IncomingIssue}
// Remap collisions
idMapping, err := RemapCollisions(ctx, store, collisions, allIssues)
if err != nil {
t.Fatalf("RemapCollisions failed: %v", err)
}
// Verify ID mapping was created
if len(idMapping) != 2 {
t.Errorf("expected 2 ID mappings, got %d", len(idMapping))
}
newID2, ok := idMapping["bd-2"]
if !ok {
t.Fatal("bd-2 was not remapped")
}
newID3, ok := idMapping["bd-3"]
if !ok {
t.Fatal("bd-3 was not remapped")
}
// Verify new issues were created with new IDs
remappedIssue2, err := store.GetIssue(ctx, newID2)
if err != nil {
t.Fatalf("failed to get remapped issue %s: %v", newID2, err)
}
if remappedIssue2 == nil {
t.Fatalf("remapped issue %s not found", newID2)
}
if remappedIssue2.Title != "Collision 2" {
t.Errorf("unexpected title for remapped issue: %s", remappedIssue2.Title)
}
// Verify references in existing issue were updated
updatedExisting, err := store.GetIssue(ctx, "bd-10")
if err != nil {
t.Fatalf("failed to get updated existing issue: %v", err)
}
// Check that description was updated
if updatedExisting.Description != fmt.Sprintf("This mentions %s and %s", newID2, newID3) {
t.Errorf("description was not updated correctly. Got: %q", updatedExisting.Description)
}
// Check that notes were updated
if updatedExisting.Notes != fmt.Sprintf("Also %s here", newID2) {
t.Errorf("notes were not updated correctly. Got: %q", updatedExisting.Notes)
}
}
// BenchmarkReplaceIDReferences benchmarks the old approach (compiling regex every time)
func BenchmarkReplaceIDReferences(b *testing.B) {
// Simulate a realistic scenario: 10 ID mappings
idMapping := make(map[string]string)
for i := 1; i <= 10; i++ {
idMapping[fmt.Sprintf("bd-%d", i)] = fmt.Sprintf("bd-%d", i+100)
}
text := "This mentions bd-1, bd-2, bd-3, bd-4, and bd-5 multiple times. " +
"Also bd-6, bd-7, bd-8, bd-9, and bd-10 are referenced here."
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = replaceIDReferences(text, idMapping)
}
}
// BenchmarkReplaceIDReferencesWithCache benchmarks the new cached approach
func BenchmarkReplaceIDReferencesWithCache(b *testing.B) {
// Simulate a realistic scenario: 10 ID mappings
idMapping := make(map[string]string)
for i := 1; i <= 10; i++ {
idMapping[fmt.Sprintf("bd-%d", i)] = fmt.Sprintf("bd-%d", i+100)
}
text := "This mentions bd-1, bd-2, bd-3, bd-4, and bd-5 multiple times. " +
"Also bd-6, bd-7, bd-8, bd-9, and bd-10 are referenced here."
// Pre-compile the cache (this is done once in real usage)
cache, err := BuildReplacementCache(idMapping)
if err != nil {
b.Fatalf("failed to build cache: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ReplaceIDReferencesWithCache(text, cache)
}
}
// BenchmarkReplaceIDReferencesMultipleTexts simulates the real-world scenario:
// processing multiple text fields (4 per issue) across 100 issues
func BenchmarkReplaceIDReferencesMultipleTexts(b *testing.B) {
// 10 ID mappings (typical collision scenario)
idMapping := make(map[string]string)
for i := 1; i <= 10; i++ {
idMapping[fmt.Sprintf("bd-%d", i)] = fmt.Sprintf("bd-%d", i+100)
}
// Simulate 100 issues with 4 text fields each
texts := make([]string, 400)
for i := 0; i < 400; i++ {
texts[i] = fmt.Sprintf("Issue %d mentions bd-1, bd-2, and bd-5", i)
}
b.Run("without cache", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, text := range texts {
_ = replaceIDReferences(text, idMapping)
}
}
})
b.Run("with cache", func(b *testing.B) {
cache, _ := BuildReplacementCache(idMapping)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, text := range texts {
_ = ReplaceIDReferencesWithCache(text, cache)
}
}
})
}
// TestDetectCollisionsReadOnly verifies that DetectCollisions does not modify the database
func TestDetectCollisionsReadOnly(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "collision-readonly-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 := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create an issue in the database
dbIssue := &types.Issue{
ID: "bd-1",
Title: "Original issue",
Description: "Original content",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("failed to create DB issue: %v", err)
}
// Create incoming issue with SAME CONTENT but DIFFERENT ID (rename scenario)
incomingIssue := &types.Issue{
ID: "bd-100",
Title: "Original issue",
Description: "Original content",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
// Call DetectCollisions
result, err := DetectCollisions(ctx, store, []*types.Issue{incomingIssue})
if err != nil {
t.Fatalf("DetectCollisions failed: %v", err)
}
// Verify rename was detected
if len(result.Renames) != 1 {
t.Fatalf("expected 1 rename, got %d", len(result.Renames))
}
if result.Renames[0].OldID != "bd-1" {
t.Errorf("expected OldID bd-1, got %s", result.Renames[0].OldID)
}
if result.Renames[0].NewID != "bd-100" {
t.Errorf("expected NewID bd-100, got %s", result.Renames[0].NewID)
}
// CRITICAL: Verify the old issue still exists in the database (not deleted)
oldIssue, err := store.GetIssue(ctx, "bd-1")
if err != nil {
t.Fatalf("failed to get old issue: %v", err)
}
if oldIssue == nil {
t.Fatal("old issue bd-1 was deleted - DetectCollisions is not read-only!")
}
if oldIssue.Title != "Original issue" {
t.Errorf("old issue was modified - expected title 'Original issue', got '%s'", oldIssue.Title)
}
}
// TestSymmetricCollision tests that hash-based collision resolution is deterministic and symmetric.
// Two issues with same ID but different content should always resolve the same way,
// regardless of which one is treated as "existing" vs "incoming".
func TestSymmetricCollision(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "symmetric-collision-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 := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create two issues with same ID but different content
issueA := &types.Issue{
ID: "test-1",
Title: "Issue from clone A",
Description: "Content from clone A",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issueB := &types.Issue{
ID: "test-1",
Title: "Issue from clone B",
Description: "Content from clone B",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
// Compute content hashes
hashA := hashIssueContent(issueA)
hashB := hashIssueContent(issueB)
t.Logf("Hash A: %s", hashA)
t.Logf("Hash B: %s", hashB)
// Test Case 1: A is existing, B is incoming
collision1 := &CollisionDetail{
ID: "test-1",
ExistingIssue: issueA,
IncomingIssue: issueB,
}
err = ScoreCollisions(ctx, store, []*CollisionDetail{collision1}, []*types.Issue{issueA, issueB})
if err != nil {
t.Fatalf("ScoreCollisions (case 1) failed: %v", err)
}
remapIncoming1 := collision1.RemapIncoming
t.Logf("Case 1 (A existing, B incoming): RemapIncoming=%v", remapIncoming1)
// Test Case 2: B is existing, A is incoming (reversed)
collision2 := &CollisionDetail{
ID: "test-1",
ExistingIssue: issueB,
IncomingIssue: issueA,
}
err = ScoreCollisions(ctx, store, []*CollisionDetail{collision2}, []*types.Issue{issueA, issueB})
if err != nil {
t.Fatalf("ScoreCollisions (case 2) failed: %v", err)
}
remapIncoming2 := collision2.RemapIncoming
t.Logf("Case 2 (B existing, A incoming): RemapIncoming=%v", remapIncoming2)
// CRITICAL VERIFICATION: The decision must be symmetric
// If A < B (hashA < hashB), then:
// - Case 1: Keep existing (A), remap incoming (B) → RemapIncoming=true
// - Case 2: Remap incoming (A), keep existing (B) → RemapIncoming=true
// If B < A (hashB < hashA), then:
// - Case 1: Remap existing (A), keep incoming (B) → RemapIncoming=false
// - Case 2: Keep existing (B), remap incoming (A) → RemapIncoming=false
//
// In both cases, the SAME version wins (the one with lower hash)
var expectedWinner, expectedLoser *types.Issue
if hashA < hashB {
expectedWinner = issueA
expectedLoser = issueB
} else {
expectedWinner = issueB
expectedLoser = issueA
}
t.Logf("Expected winner: %s (hash: %s)", expectedWinner.Title, hashIssueContent(expectedWinner))
t.Logf("Expected loser: %s (hash: %s)", expectedLoser.Title, hashIssueContent(expectedLoser))
// Verify that RemapIncoming decisions lead to correct winner
// Case 1: A existing, B incoming
// - If RemapIncoming=true: keep A (existing), remap B (incoming)
// - If RemapIncoming=false: keep B (incoming), remap A (existing)
// Case 2: B existing, A incoming
// - If RemapIncoming=true: keep B (existing), remap A (incoming)
// - If RemapIncoming=false: keep A (incoming), remap B (existing)
// The winner should be the one with lower hash
if expectedWinner.Title == issueA.Title {
// A should win in both cases
// Case 1: A is existing, so RemapIncoming should be true (remap B)
if !remapIncoming1 {
t.Errorf("Case 1: Expected A to win (RemapIncoming=true to remap B), but got RemapIncoming=false")
}
// Case 2: A is incoming, so RemapIncoming should be false (keep A, remap B existing)
if remapIncoming2 {
t.Errorf("Case 2: Expected A to win (RemapIncoming=false to keep A), but got RemapIncoming=true")
}
} else {
// B should win in both cases
// Case 1: B is incoming, so RemapIncoming should be false (keep B, remap A existing)
if remapIncoming1 {
t.Errorf("Case 1: Expected B to win (RemapIncoming=false to keep B), but got RemapIncoming=true")
}
// Case 2: B is existing, so RemapIncoming should be true (remap A)
if !remapIncoming2 {
t.Errorf("Case 2: Expected B to win (RemapIncoming=true to remap A), but got RemapIncoming=false")
}
}
// Final check: Same winner in both cases
var winner1, winner2 *types.Issue
if remapIncoming1 {
winner1 = collision1.ExistingIssue // A
} else {
winner1 = collision1.IncomingIssue // B
}
if remapIncoming2 {
winner2 = collision2.ExistingIssue // B
} else {
winner2 = collision2.IncomingIssue // A
}
if winner1.Title != winner2.Title {
t.Errorf("SYMMETRY VIOLATION: Different winners! Case 1 winner: %s, Case 2 winner: %s",
winner1.Title, winner2.Title)
}
t.Logf("✓ SUCCESS: Collision resolution is symmetric - same winner in both cases: %s", winner1.Title)
}
// TestApplyCollisionResolution verifies that ApplyCollisionResolution correctly applies renames
func TestApplyCollisionResolution(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "apply-resolution-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 := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create an issue to be renamed
oldIssue := &types.Issue{
ID: "bd-1",
Title: "Issue to rename",
Description: "Content",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, oldIssue, "test"); err != nil {
t.Fatalf("failed to create old issue: %v", err)
}
// Create a collision result with a rename
newIssue := &types.Issue{
ID: "bd-100",
Title: "Issue to rename",
Description: "Content",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
result := &CollisionResult{
Renames: []*RenameDetail{
{
OldID: "bd-1",
NewID: "bd-100",
Issue: newIssue,
},
},
}
// Apply the resolution
emptyMapping := make(map[string]string)
if err := ApplyCollisionResolution(ctx, store, result, emptyMapping); err != nil {
t.Fatalf("ApplyCollisionResolution failed: %v", err)
}
// Verify old issue was deleted
oldDeleted, err := store.GetIssue(ctx, "bd-1")
if err != nil {
t.Fatalf("failed to check old issue: %v", err)
}
if oldDeleted != nil {
t.Error("old issue bd-1 was not deleted")
}
}