Files
beads/internal/autoimport/autoimport_test.go
Steve Yegge 887c958567 fix(autoimport): prevent export to wrong JSONL file (bd-tqo)
Add FindJSONLInDir helper that correctly prefers issues.jsonl over other
.jsonl files. Previously, glob patterns could return deletions.jsonl or
merge artifacts (beads.base.jsonl, etc.) first alphabetically, causing
issue data to be written to the wrong file.

This fixes the root cause of deletions.jsonl corruption where full issue
objects were written instead of deletion records, leading to all issues
being purged during sync.

Changes:
- Add FindJSONLInDir() in internal/autoimport with proper file selection
- Update AutoImportIfNewer() to use FindJSONLInDir
- Update CheckStaleness() to use FindJSONLInDir
- Update triggerExport() in RPC server to use FindJSONLInDir
- Add comprehensive tests for FindJSONLInDir

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 23:25:32 -08:00

596 lines
14 KiB
Go

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")
})
}
// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl
// and avoids deletions.jsonl and merge artifacts (bd-tqo fix)
func TestFindJSONLInDir(t *testing.T) {
tests := []struct {
name string
files []string
expected string
}{
{
name: "only issues.jsonl",
files: []string{"issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "issues.jsonl and deletions.jsonl - prefers issues",
files: []string{"deletions.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "issues.jsonl with merge artifacts - prefers issues",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "beads.jsonl as legacy fallback",
files: []string{"beads.jsonl"},
expected: "beads.jsonl",
},
{
name: "issues.jsonl preferred over beads.jsonl",
files: []string{"beads.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "only deletions.jsonl - returns default issues.jsonl",
files: []string{"deletions.jsonl"},
expected: "issues.jsonl",
},
{
name: "only merge artifacts - returns default issues.jsonl",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"},
expected: "issues.jsonl",
},
{
name: "no files - returns default issues.jsonl",
files: []string{},
expected: "issues.jsonl",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test files
for _, file := range tt.files {
path := filepath.Join(tmpDir, file)
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
}
result := FindJSONLInDir(tmpDir)
got := filepath.Base(result)
if got != tt.expected {
t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected)
}
})
}
}