package importer import ( "context" "os" "path/filepath" "testing" "time" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) // TestImportTimestampPrecedence verifies that imports respect updated_at timestamps (bd-e55c) // When importing an issue with the same ID but different content, the newer version should win. func TestImportTimestampPrecedence(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") // Initialize storage store, err := sqlite.New(context.Background(), dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } defer store.Close() ctx := context.Background() // Set up database with prefix if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil { t.Fatalf("Failed to set prefix: %v", err) } // Create an issue locally at time T1 now := time.Now() closedAt := now localIssue := &types.Issue{ ID: "bd-test123", Title: "Test Issue", Description: "Local version", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeBug, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now, // Newer timestamp ClosedAt: &closedAt, } localIssue.ContentHash = localIssue.ComputeContentHash() if err := store.CreateIssue(ctx, localIssue, "test"); err != nil { t.Fatalf("Failed to create local issue: %v", err) } // Simulate importing an older version from remote (e.g., from git pull) // This represents the scenario in bd-e55c where remote has status=open from yesterday olderRemoteIssue := &types.Issue{ ID: "bd-test123", // Same ID Title: "Test Issue", Description: "Remote version", Status: types.StatusOpen, // Different status Priority: 1, IssueType: types.TypeBug, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-1 * time.Hour), // Older timestamp } olderRemoteIssue.ContentHash = olderRemoteIssue.ComputeContentHash() // Import the older remote version result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{olderRemoteIssue}, Options{ SkipPrefixValidation: true, }) if err != nil { t.Fatalf("Import failed: %v", err) } // Verify that the import did NOT update the local version // The local version is newer, so it should be preserved if result.Updated > 0 { t.Errorf("Expected 0 updates, got %d - older remote should not overwrite newer local", result.Updated) } if result.Unchanged == 0 { t.Errorf("Expected unchanged count > 0, got %d", result.Unchanged) } // Verify the database still has the local (newer) version dbIssue, err := store.GetIssue(ctx, "bd-test123") if err != nil { t.Fatalf("Failed to get issue: %v", err) } if dbIssue.Status != types.StatusClosed { t.Errorf("Expected status=closed (local version), got status=%s", dbIssue.Status) } if dbIssue.Description != "Local version" { t.Errorf("Expected description='Local version', got '%s'", dbIssue.Description) } // Now test the reverse: importing a NEWER version should update newerRemoteIssue := &types.Issue{ ID: "bd-test123", Title: "Test Issue", Description: "Even newer remote version", Status: types.StatusOpen, Priority: 2, // Changed priority too IssueType: types.TypeBug, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(1 * time.Hour), // Newer than current DB } newerRemoteIssue.ContentHash = newerRemoteIssue.ComputeContentHash() result2, err := ImportIssues(ctx, dbPath, store, []*types.Issue{newerRemoteIssue}, Options{ SkipPrefixValidation: true, }) if err != nil { t.Fatalf("Import of newer version failed: %v", err) } if result2.Updated == 0 { t.Errorf("Expected 1 update, got 0 - newer remote should overwrite older local") } // Verify the database now has the newer remote version dbIssue2, err := store.GetIssue(ctx, "bd-test123") if err != nil { t.Fatalf("Failed to get issue after second import: %v", err) } if dbIssue2.Priority != 2 { t.Errorf("Expected priority=2 (newer remote), got %d", dbIssue2.Priority) } if dbIssue2.Description != "Even newer remote version" { t.Errorf("Expected description='Even newer remote version', got '%s'", dbIssue2.Description) } } // TestImportSameTimestamp tests behavior when timestamps are equal func TestImportSameTimestamp(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") store, err := sqlite.New(context.Background(), 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 prefix: %v", err) } now := time.Now() // Create local issue localIssue := &types.Issue{ ID: "bd-test456", Title: "Test Issue", Description: "Local version", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, CreatedAt: now, UpdatedAt: now, } localIssue.ContentHash = localIssue.ComputeContentHash() if err := store.CreateIssue(ctx, localIssue, "test"); err != nil { t.Fatalf("Failed to create local issue: %v", err) } // Import with SAME timestamp but different content remoteIssue := &types.Issue{ ID: "bd-test456", Title: "Test Issue", Description: "Remote version", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeTask, CreatedAt: now, UpdatedAt: now, // Same timestamp } remoteIssue.ContentHash = remoteIssue.ComputeContentHash() result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{remoteIssue}, Options{ SkipPrefixValidation: true, }) if err != nil { t.Fatalf("Import failed: %v", err) } // With equal timestamps, we should NOT update (local wins) if result.Updated > 0 { t.Errorf("Expected 0 updates with equal timestamps, got %d", result.Updated) } // Verify local version is preserved dbIssue, err := store.GetIssue(ctx, "bd-test456") if err != nil { t.Fatalf("Failed to get issue: %v", err) } if dbIssue.Description != "Local version" { t.Errorf("Expected local version to be preserved, got '%s'", dbIssue.Description) } } // TestImportTimestampAwareProtection tests the GH#865 fix: timestamp-aware snapshot protection // The ProtectLocalExportIDs map should only protect issues if the local snapshot version is newer. func TestImportTimestampAwareProtection(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") store, err := sqlite.New(context.Background(), 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 prefix: %v", err) } now := time.Now() // Create a local issue in the database localIssue := &types.Issue{ ID: "bd-protect1", Title: "Test Issue", Description: "Local version", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-1 * time.Hour), // DB has old timestamp } localIssue.ContentHash = localIssue.ComputeContentHash() if err := store.CreateIssue(ctx, localIssue, "test"); err != nil { t.Fatalf("Failed to create local issue: %v", err) } t.Run("incoming newer than local snapshot - should update", func(t *testing.T) { // Scenario: Remote closed the issue after we exported locally // Local snapshot: issue open at T1 (10:00) // Incoming: issue closed at T2 (11:30) - NEWER // Expected: Update should proceed (remote is newer) snapshotTime := now.Add(-30 * time.Minute) // Local snapshot at 10:00 incomingTime := now // Incoming at 11:30 (newer) closedAt := incomingTime incomingIssue := &types.Issue{ ID: "bd-protect1", Title: "Test Issue", Description: "Remote closed version", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: incomingTime, ClosedAt: &closedAt, } incomingIssue.ContentHash = incomingIssue.ComputeContentHash() // Protection map has the issue with the local snapshot timestamp protectMap := map[string]time.Time{ "bd-protect1": snapshotTime, } result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{incomingIssue}, Options{ SkipPrefixValidation: true, ProtectLocalExportIDs: protectMap, }) if err != nil { t.Fatalf("Import failed: %v", err) } // Incoming is newer than snapshot, so update should proceed if result.Updated == 0 { t.Errorf("Expected 1 update (incoming newer than snapshot), got 0") } if result.Skipped > 0 { t.Errorf("Expected 0 skipped, got %d", result.Skipped) } // Verify the issue was updated to closed dbIssue, err := store.GetIssue(ctx, "bd-protect1") if err != nil { t.Fatalf("Failed to get issue: %v", err) } if dbIssue.Status != types.StatusClosed { t.Errorf("Expected status=closed (remote version), got %s", dbIssue.Status) } }) t.Run("incoming older than local snapshot - should protect", func(t *testing.T) { // Reset the issue for next test resetIssue := &types.Issue{ ID: "bd-protect2", Title: "Another Issue", Description: "Local modified version", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeBug, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now, // Current timestamp } resetIssue.ContentHash = resetIssue.ComputeContentHash() if err := store.CreateIssue(ctx, resetIssue, "test"); err != nil { t.Fatalf("Failed to create reset issue: %v", err) } // Scenario: We modified locally, remote has older version // Local snapshot: issue in_progress at T2 (11:30) // Incoming: issue open at T1 (10:00) - OLDER // Expected: Skip update (protect local changes) snapshotTime := now // Local snapshot at 11:30 incomingTime := now.Add(-30 * time.Minute) // Incoming at 10:00 (older) incomingIssue := &types.Issue{ ID: "bd-protect2", Title: "Another Issue", Description: "Old remote version", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: incomingTime, } incomingIssue.ContentHash = incomingIssue.ComputeContentHash() // Protection map has the issue with the local snapshot timestamp protectMap := map[string]time.Time{ "bd-protect2": snapshotTime, } result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{incomingIssue}, Options{ SkipPrefixValidation: true, ProtectLocalExportIDs: protectMap, }) if err != nil { t.Fatalf("Import failed: %v", err) } // Incoming is older than snapshot, so update should be skipped (protected) if result.Skipped == 0 { t.Errorf("Expected 1 skipped (local snapshot newer), got 0") } if result.Updated > 0 { t.Errorf("Expected 0 updates, got %d", result.Updated) } // Verify the issue was NOT updated dbIssue, err := store.GetIssue(ctx, "bd-protect2") if err != nil { t.Fatalf("Failed to get issue: %v", err) } if dbIssue.Status != types.StatusInProgress { t.Errorf("Expected status=in_progress (protected local), got %s", dbIssue.Status) } }) t.Run("issue not in protection map - normal behavior", func(t *testing.T) { // Create an issue not in the protection map unprotectedIssue := &types.Issue{ ID: "bd-unprotected", Title: "Unprotected Issue", Description: "Original", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-1 * time.Hour), } unprotectedIssue.ContentHash = unprotectedIssue.ComputeContentHash() if err := store.CreateIssue(ctx, unprotectedIssue, "test"); err != nil { t.Fatalf("Failed to create unprotected issue: %v", err) } // Incoming version is newer closedAt := now incomingIssue := &types.Issue{ ID: "bd-unprotected", Title: "Unprotected Issue", Description: "Updated", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now, // Newer than DB ClosedAt: &closedAt, } incomingIssue.ContentHash = incomingIssue.ComputeContentHash() // Protection map does NOT contain this issue protectMap := map[string]time.Time{ "bd-other": now, // Different issue } result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{incomingIssue}, Options{ SkipPrefixValidation: true, ProtectLocalExportIDs: protectMap, }) if err != nil { t.Fatalf("Import failed: %v", err) } // Issue not in protection map, incoming is newer - should update if result.Updated == 0 { t.Errorf("Expected 1 update (not in protection map), got 0") } dbIssue, err := store.GetIssue(ctx, "bd-unprotected") if err != nil { t.Fatalf("Failed to get issue: %v", err) } if dbIssue.Status != types.StatusClosed { t.Errorf("Expected status=closed (updated), got %s", dbIssue.Status) } }) } // TestShouldProtectFromUpdate tests the helper function directly func TestShouldProtectFromUpdate(t *testing.T) { now := time.Now() t.Run("nil map - no protection", func(t *testing.T) { if shouldProtectFromUpdate("bd-123", now, nil) { t.Error("Expected no protection with nil map") } }) t.Run("issue not in map - no protection", func(t *testing.T) { protectMap := map[string]time.Time{ "bd-other": now, } if shouldProtectFromUpdate("bd-123", now, protectMap) { t.Error("Expected no protection when issue not in map") } }) t.Run("incoming newer than local - no protection", func(t *testing.T) { localTime := now.Add(-1 * time.Hour) protectMap := map[string]time.Time{ "bd-123": localTime, } if shouldProtectFromUpdate("bd-123", now, protectMap) { t.Error("Expected no protection when incoming is newer") } }) t.Run("incoming same as local - protect", func(t *testing.T) { protectMap := map[string]time.Time{ "bd-123": now, } if !shouldProtectFromUpdate("bd-123", now, protectMap) { t.Error("Expected protection when timestamps are equal") } }) t.Run("incoming older than local - protect", func(t *testing.T) { localTime := now.Add(1 * time.Hour) // Local is newer protectMap := map[string]time.Time{ "bd-123": localTime, } if !shouldProtectFromUpdate("bd-123", now, protectMap) { t.Error("Expected protection when local is newer") } }) } func TestMain(m *testing.M) { // Ensure test DB files are cleaned up code := m.Run() os.Exit(code) }