- Update version in version.go, default.nix, hook templates - Add CHANGELOG.md entry for v0.30.2 - Add v0.30.1 and v0.30.2 to versionChanges in info.go - Remove stale internal/deletions import from integration test (fixes CI failure from bd-fom refactor) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
175 lines
4.6 KiB
Go
175 lines
4.6 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package importer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestConcurrentExternalRefUpdates tests concurrent updates to same external_ref with different timestamps
|
|
// This is a slow integration test that verifies no deadlocks occur
|
|
func TestConcurrentExternalRefUpdates(t *testing.T) {
|
|
store, err := sqlite.New(context.Background(), ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %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)
|
|
}
|
|
|
|
externalRef := "JIRA-200"
|
|
existing := &types.Issue{
|
|
ID: "bd-1",
|
|
Title: "Existing issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
ExternalRef: &externalRef,
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, existing, "test"); err != nil {
|
|
t.Fatalf("Failed to create existing issue: %v", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]*Result, 3)
|
|
done := make(chan bool, 1)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
|
|
updated := &types.Issue{
|
|
ID: "bd-import-" + string(rune('1'+idx)),
|
|
Title: "Updated from worker " + string(rune('A'+idx)),
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
ExternalRef: &externalRef,
|
|
UpdatedAt: time.Now().Add(time.Duration(idx) * time.Second),
|
|
}
|
|
|
|
result, _ := ImportIssues(ctx, "", store, []*types.Issue{updated}, Options{})
|
|
results[idx] = result
|
|
}(i)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Test completed normally
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("Test timed out after 30 seconds - likely deadlock in concurrent imports")
|
|
}
|
|
|
|
finalIssue, err := store.GetIssueByExternalRef(ctx, externalRef)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get final issue: %v", err)
|
|
}
|
|
|
|
if finalIssue == nil {
|
|
t.Fatal("Expected final issue to exist")
|
|
}
|
|
|
|
// Verify that we got the update with the latest timestamp (worker 2)
|
|
if finalIssue.Title != "Updated from worker C" {
|
|
t.Errorf("Expected last update to win, got title: %s", finalIssue.Title)
|
|
}
|
|
}
|
|
|
|
// TestLocalUnpushedIssueNotDeleted verifies that local issues that were never
|
|
// in git are NOT deleted during import (they are local work, not deletions)
|
|
func TestLocalUnpushedIssueNotDeleted(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create temp directory structure
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
store, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
// Create a local issue that was never exported/pushed
|
|
localIssue := &types.Issue{
|
|
ID: "bd-local-work",
|
|
Title: "Local work in progress",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, localIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create local issue: %v", err)
|
|
}
|
|
|
|
// Create an issue that exists in JSONL (remote)
|
|
remoteIssue := &types.Issue{
|
|
ID: "bd-remote-123",
|
|
Title: "Synced from remote",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, remoteIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create remote issue: %v", err)
|
|
}
|
|
|
|
// JSONL only contains the remote issue (local issue was never exported)
|
|
jsonlIssues := []*types.Issue{remoteIssue}
|
|
|
|
// Import - local issue should NOT be purged
|
|
result, err := ImportIssues(ctx, dbPath, store, jsonlIssues, Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
// No purges should happen (local issues not in JSONL are preserved)
|
|
if result.Purged != 0 {
|
|
t.Errorf("Expected 0 purged issues, got %d (purged: %v)", result.Purged, result.PurgedIDs)
|
|
}
|
|
|
|
// Both issues should still exist
|
|
finalIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to search final issues: %v", err)
|
|
}
|
|
|
|
if len(finalIssues) != 2 {
|
|
t.Errorf("Expected 2 issues after import, got %d", len(finalIssues))
|
|
}
|
|
|
|
// Local work should still exist
|
|
localFound, _ := store.GetIssue(ctx, "bd-local-work")
|
|
if localFound == nil {
|
|
t.Error("Local issue was incorrectly purged")
|
|
}
|
|
}
|
|
|