package autoimport import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/steveyegge/beads/internal/storage/memory" "github.com/steveyegge/beads/internal/types" ) // testNotifier captures notifications for assertions type testNotifier struct { debugs []string infos []string warns []string errors []string } func (n *testNotifier) Debugf(format string, args ...interface{}) { n.debugs = append(n.debugs, format) } func (n *testNotifier) Infof(format string, args ...interface{}) { n.infos = append(n.infos, format) } func (n *testNotifier) Warnf(format string, args ...interface{}) { n.warns = append(n.warns, format) } func (n *testNotifier) Errorf(format string, args ...interface{}) { n.errors = append(n.errors, format) } func TestAutoImportIfNewer_NoJSONL(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") store := memory.New("") notify := &testNotifier{} importCalled := false importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) { importCalled = true return 0, 0, nil, nil } err = AutoImportIfNewer(context.Background(), store, dbPath, notify, importFunc, nil) if err != nil { t.Errorf("Expected no error, got: %v", err) } if importCalled { t.Error("Import should not be called when JSONL doesn't exist") } } func TestAutoImportIfNewer_UnchangedHash(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create test JSONL issue := &types.Issue{ ID: "test-1", Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), } f, err := os.Create(jsonlPath) if err != nil { t.Fatal(err) } json.NewEncoder(f).Encode(issue) f.Close() // Compute hash data, _ := os.ReadFile(jsonlPath) hasher := sha256.New() hasher.Write(data) hash := hex.EncodeToString(hasher.Sum(nil)) // Store hash in metadata store := memory.New("") ctx := context.Background() store.SetMetadata(ctx, "last_import_hash", hash) notify := &testNotifier{} importCalled := false importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) { importCalled = true return 0, 0, nil, nil } err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil) if err != nil { t.Errorf("Expected no error, got: %v", err) } if importCalled { t.Error("Import should not be called when hash is unchanged") } } func TestAutoImportIfNewer_ChangedHash(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create test JSONL issue := &types.Issue{ ID: "test-1", Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), } f, err := os.Create(jsonlPath) if err != nil { t.Fatal(err) } json.NewEncoder(f).Encode(issue) f.Close() // Store different hash in metadata store := memory.New("") ctx := context.Background() store.SetMetadata(ctx, "last_import_hash", "different-hash") notify := &testNotifier{} importCalled := false var receivedIssues []*types.Issue importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) { importCalled = true receivedIssues = issues return 1, 0, nil, nil } err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil) if err != nil { t.Errorf("Expected no error, got: %v", err) } if !importCalled { t.Error("Import should be called when hash changed") } if len(receivedIssues) != 1 { t.Errorf("Expected 1 issue, got %d", len(receivedIssues)) } if receivedIssues[0].ID != "test-1" { t.Errorf("Expected issue ID 'test-1', got '%s'", receivedIssues[0].ID) } } func TestAutoImportIfNewer_MergeConflict(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create JSONL with merge conflict markers conflictData := `{"id":"test-1","title":"Issue 1"} <<<<<<< HEAD {"id":"test-2","title":"Local version"} ======= {"id":"test-2","title":"Remote version"} >>>>>>> main {"id":"test-3","title":"Issue 3"} ` os.WriteFile(jsonlPath, []byte(conflictData), 0644) store := memory.New("") ctx := context.Background() notify := &testNotifier{} importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) { t.Error("Import should not be called with merge conflict") return 0, 0, nil, nil } err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil) if err == nil { t.Error("Expected error for merge conflict") } if len(notify.errors) == 0 { t.Error("Expected error notification") } } func TestAutoImportIfNewer_WithRemapping(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create test JSONL issue := &types.Issue{ ID: "test-1", Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), } f, err := os.Create(jsonlPath) if err != nil { t.Fatal(err) } json.NewEncoder(f).Encode(issue) f.Close() store := memory.New("") ctx := context.Background() notify := &testNotifier{} idMapping := map[string]string{"test-1": "test-2"} importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) { return 1, 0, idMapping, nil } onChangedCalled := false var needsFullExport bool onChanged := func(fullExport bool) { onChangedCalled = true needsFullExport = fullExport } err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, onChanged) if err != nil { t.Errorf("Expected no error, got: %v", err) } if !onChangedCalled { t.Error("onChanged should be called when issues are remapped") } if !needsFullExport { t.Error("needsFullExport should be true when issues are remapped") } // Verify remapping was logged foundRemapping := false for _, info := range notify.infos { if strings.Contains(info, "remapped") { foundRemapping = true break } } if !foundRemapping { t.Error("Expected remapping notification") } } func TestCheckStaleness_NoMetadata(t *testing.T) { store := memory.New("") ctx := context.Background() tmpDir, err := os.MkdirTemp("", "bd-stale-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") stale, err := CheckStaleness(ctx, store, dbPath) if err != nil { t.Errorf("Expected no error, got: %v", err) } if stale { t.Error("Should not be stale with no metadata") } } func TestCheckStaleness_NewerJSONL(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-stale-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") // Create old import time oldTime := time.Now().Add(-1 * time.Hour) store := memory.New("") ctx := context.Background() store.SetMetadata(ctx, "last_import_time", oldTime.Format(time.RFC3339)) // Create newer JSONL file os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`), 0644) stale, err := CheckStaleness(ctx, store, dbPath) if err != nil { t.Errorf("Expected no error, got: %v", err) } if !stale { t.Error("Should be stale when JSONL is newer") } } func TestCheckStaleness_CorruptedMetadata(t *testing.T) { store := memory.New("") ctx := context.Background() tmpDir, err := os.MkdirTemp("", "bd-stale-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "bd.db") // Set invalid timestamp format store.SetMetadata(ctx, "last_import_time", "not-a-valid-timestamp") _, err = CheckStaleness(ctx, store, dbPath) if err == nil { t.Error("Expected error for corrupted metadata, got nil") } if err != nil && !strings.Contains(err.Error(), "corrupted last_import_time") { t.Errorf("Expected 'corrupted last_import_time' error, got: %v", err) } } func TestCheckForMergeConflicts(t *testing.T) { tests := []struct { name string data string wantError bool }{ { name: "no conflict", data: `{"id":"test-1"}`, wantError: false, }, { name: "conflict with HEAD marker", data: `<<<<<<< HEAD {"id":"test-1"}`, wantError: true, }, { name: "conflict with separator", data: `{"id":"test-1"} ======= {"id":"test-2"}`, wantError: true, }, { name: "conflict with end marker", data: `{"id":"test-1"} >>>>>>> main`, wantError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := checkForMergeConflicts([]byte(tt.data), "test.jsonl") if tt.wantError && err == nil { t.Error("Expected error for merge conflict") } if !tt.wantError && err != nil { t.Errorf("Unexpected error: %v", err) } }) } } func TestParseJSONL(t *testing.T) { notify := &testNotifier{} t.Run("valid jsonl", func(t *testing.T) { data := `{"id":"test-1","title":"Issue 1","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"} {"id":"test-2","title":"Issue 2","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}` issues, err := parseJSONL([]byte(data), notify) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(issues) != 2 { t.Errorf("Expected 2 issues, got %d", len(issues)) } }) t.Run("empty lines ignored", func(t *testing.T) { data := `{"id":"test-1","title":"Issue 1","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"} {"id":"test-2","title":"Issue 2","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}` issues, err := parseJSONL([]byte(data), notify) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(issues) != 2 { t.Errorf("Expected 2 issues, got %d", len(issues)) } }) t.Run("invalid json", func(t *testing.T) { data := `{"id":"test-1","title":"Issue 1"} not valid json` _, err := parseJSONL([]byte(data), notify) if err == nil { t.Error("Expected error for invalid JSON") } }) t.Run("closed without closedAt", func(t *testing.T) { data := `{"id":"test-1","title":"Closed Issue","status":"closed","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}` issues, err := parseJSONL([]byte(data), notify) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if issues[0].ClosedAt == nil { t.Error("Expected ClosedAt to be set for closed issue") } }) } func TestShowRemapping(t *testing.T) { notify := &testNotifier{} allIssues := []*types.Issue{ {ID: "test-1", Title: "Issue 1"}, {ID: "test-2", Title: "Issue 2"}, } idMapping := map[string]string{ "test-1": "test-3", "test-2": "test-4", } showRemapping(allIssues, idMapping, notify) if len(notify.infos) == 0 { t.Error("Expected info messages for remapping") } foundRemappingHeader := false for _, info := range notify.infos { if strings.Contains(info, "remapped") && strings.Contains(info, "colliding") { foundRemappingHeader = true break } } if !foundRemappingHeader { t.Errorf("Expected remapping summary message, got infos: %v", notify.infos) } } func TestStderrNotifier(t *testing.T) { t.Run("debug enabled", func(t *testing.T) { notify := NewStderrNotifier(true) // Just verify it doesn't panic notify.Debugf("test debug") notify.Infof("test info") notify.Warnf("test warn") notify.Errorf("test error") }) t.Run("debug disabled", func(t *testing.T) { notify := NewStderrNotifier(false) // Just verify it doesn't panic notify.Debugf("test debug") notify.Infof("test info") }) }