package main import ( "context" "encoding/json" "os" "path/filepath" "testing" "time" "github.com/steveyegge/beads/internal/types" ) // TestIssueDataChanged tests the issueDataChanged helper function func TestIssueDataChanged(t *testing.T) { tests := []struct { name string existing *types.Issue updates map[string]interface{} want bool // true if changed, false if unchanged }{ { name: "no changes", existing: &types.Issue{ Title: "Test", Description: "Desc", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, }, updates: map[string]interface{}{ "title": "Test", "description": "Desc", "status": types.StatusOpen, "priority": 2, "issue_type": types.TypeTask, }, want: false, }, { name: "title changed", existing: &types.Issue{ Title: "Old Title", }, updates: map[string]interface{}{ "title": "New Title", }, want: true, }, { name: "status as string vs enum - unchanged", existing: &types.Issue{ Status: types.StatusOpen, }, updates: map[string]interface{}{ "status": "open", // string instead of enum }, want: false, }, { name: "status as enum - unchanged", existing: &types.Issue{ Status: types.StatusInProgress, }, updates: map[string]interface{}{ "status": types.StatusInProgress, // enum }, want: false, }, { name: "issue_type as string vs enum - unchanged", existing: &types.Issue{ IssueType: types.TypeBug, }, updates: map[string]interface{}{ "issue_type": "bug", // string instead of enum }, want: false, }, { name: "priority as int - unchanged", existing: &types.Issue{ Priority: 3, }, updates: map[string]interface{}{ "priority": 3, }, want: false, }, { name: "priority as int64 - unchanged", existing: &types.Issue{ Priority: 2, }, updates: map[string]interface{}{ "priority": int64(2), }, want: false, }, { name: "priority as float64 whole number - unchanged", existing: &types.Issue{ Priority: 1, }, updates: map[string]interface{}{ "priority": float64(1), }, want: false, }, { name: "priority as float64 fractional - changed", existing: &types.Issue{ Priority: 1, }, updates: map[string]interface{}{ "priority": 1.5, // fractional not allowed }, want: true, }, { name: "empty string vs empty - unchanged", existing: &types.Issue{ Design: "", }, updates: map[string]interface{}{ "design": "", }, want: false, }, { name: "empty string vs nil - unchanged (treated as equal)", existing: &types.Issue{ Assignee: "", }, updates: map[string]interface{}{ "assignee": nil, }, want: false, }, { name: "non-empty to empty - changed", existing: &types.Issue{ Notes: "Some notes", }, updates: map[string]interface{}{ "notes": "", }, want: true, }, { name: "external_ref nil vs empty - unchanged", existing: &types.Issue{ ExternalRef: nil, }, updates: map[string]interface{}{ "external_ref": nil, }, want: false, }, { name: "external_ref pointer to empty vs nil - unchanged", existing: &types.Issue{ ExternalRef: strPtr(""), }, updates: map[string]interface{}{ "external_ref": nil, }, want: false, }, { name: "external_ref changed", existing: &types.Issue{ ExternalRef: strPtr("gh-123"), }, updates: map[string]interface{}{ "external_ref": "gh-456", }, want: true, }, { name: "unknown field - treated as changed", existing: &types.Issue{ Title: "Test", }, updates: map[string]interface{}{ "title": "Test", "unknown_field": "value", }, want: true, }, { name: "invalid type for title - treated as changed", existing: &types.Issue{ Title: "Test", }, updates: map[string]interface{}{ "title": 123, // wrong type }, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := issueDataChanged(tt.existing, tt.updates) if got != tt.want { t.Errorf("issueDataChanged() = %v, want %v", got, tt.want) } }) } } // strPtr helper for tests func strPtr(s string) *string { return &s } // TestIdempotentImportNoTimestampChurn verifies that importing unchanged issues // does not update their timestamps (bd-84) func TestIdempotentImportNoTimestampChurn(t *testing.T) { // FIX: Initialize rootCtx for autoImportIfNewer (issue #355) testRootCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() oldRootCtx := rootCtx rootCtx = testRootCtx defer func() { rootCtx = oldRootCtx }() // Create temp directory tmpDir, err := os.MkdirTemp("", "bd-test-idempotent-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) dbPath = filepath.Join(tmpDir, "test.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create store testStore := newTestStoreWithPrefix(t, dbPath, "bd") store = testStore storeMutex.Lock() storeActive = true storeMutex.Unlock() defer func() { storeMutex.Lock() storeActive = false storeMutex.Unlock() }() ctx := context.Background() // Create an issue issue := &types.Issue{ ID: "bd-1", Title: "Test Issue", Description: "Test description", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := testStore.CreateIssue(ctx, issue, "test"); err != nil { t.Fatalf("Failed to create issue: %v", err) } // Get initial timestamp issue1, err := testStore.GetIssue(ctx, "bd-1") if err != nil { t.Fatalf("Failed to get issue: %v", err) } initialUpdatedAt := issue1.UpdatedAt // Export to JSONL f, err := os.Create(jsonlPath) if err != nil { t.Fatalf("Failed to create JSONL: %v", err) } encoder := json.NewEncoder(f) if err := encoder.Encode(issue1); err != nil { t.Fatalf("Failed to encode issue: %v", err) } f.Close() // Wait a bit to ensure timestamps would be different if updated time.Sleep(100 * time.Millisecond) // Import the same JSONL (should be idempotent) autoImportIfNewer() // Get issue again issue2, err := testStore.GetIssue(ctx, "bd-1") if err != nil { t.Fatalf("Failed to get issue after import: %v", err) } // Verify timestamp was NOT updated if !issue2.UpdatedAt.Equal(initialUpdatedAt) { t.Errorf("Import updated timestamp even though data unchanged!\n"+ "Before: %v\nAfter: %v", initialUpdatedAt, issue2.UpdatedAt) } } // TestImportMultipleUnchangedIssues verifies that importing multiple unchanged issues // does not update any of their timestamps (bd-84) func TestImportMultipleUnchangedIssues(t *testing.T) { // FIX: Initialize rootCtx for autoImportIfNewer (issue #355) testRootCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() oldRootCtx := rootCtx rootCtx = testRootCtx defer func() { rootCtx = oldRootCtx }() // Create temp directory tmpDir, err := os.MkdirTemp("", "bd-test-changed-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) dbPath = filepath.Join(tmpDir, "test.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create store testStore := newTestStoreWithPrefix(t, dbPath, "bd") store = testStore storeMutex.Lock() storeActive = true storeMutex.Unlock() defer func() { storeMutex.Lock() storeActive = false storeMutex.Unlock() }() ctx := context.Background() // Create two issues issue1 := &types.Issue{ ID: "bd-1", Title: "Unchanged Issue", Description: "Will not change", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), } issue2 := &types.Issue{ ID: "bd-2", Title: "Changed Issue", Description: "Will change", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil { t.Fatalf("Failed to create issue 1: %v", err) } if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil { t.Fatalf("Failed to create issue 2: %v", err) } // Get initial timestamps unchanged, _ := testStore.GetIssue(ctx, "bd-1") changed, _ := testStore.GetIssue(ctx, "bd-2") unchangedInitialTS := unchanged.UpdatedAt changedInitialTS := changed.UpdatedAt // Export both issues to JSONL (unchanged) f, err := os.Create(jsonlPath) if err != nil { t.Fatalf("Failed to create JSONL: %v", err) } encoder := json.NewEncoder(f) if err := encoder.Encode(unchanged); err != nil { t.Fatalf("Failed to encode issue 1: %v", err) } if err := encoder.Encode(changed); err != nil { t.Fatalf("Failed to encode issue 2: %v", err) } f.Close() // Wait to ensure timestamps would differ if updated time.Sleep(100 * time.Millisecond) // Import same JSONL (both issues unchanged - should be idempotent) autoImportIfNewer() // Check timestamps - neither should have changed issue1After, _ := testStore.GetIssue(ctx, "bd-1") issue2After, _ := testStore.GetIssue(ctx, "bd-2") // bd-1 should have same timestamp if !issue1After.UpdatedAt.Equal(unchangedInitialTS) { t.Errorf("bd-1 timestamp changed even though issue unchanged!\n"+ "Before: %v\nAfter: %v", unchangedInitialTS, issue1After.UpdatedAt) } // bd-2 should also have same timestamp if !issue2After.UpdatedAt.Equal(changedInitialTS) { t.Errorf("bd-2 timestamp changed even though issue unchanged!\n"+ "Before: %v\nAfter: %v", changedInitialTS, issue2After.UpdatedAt) } }