Changes: - CheckStaleness now returns errors for corrupted last_import_time metadata instead of silently returning false (bd-o4qy) - Added handling for empty string metadata (memory store behavior) - Enhanced warning messages when staleness check fails to be more explicit that operation continues with potentially stale data (bd-n4td) - Added test coverage for corrupted metadata scenario Closes bd-2q6d, bd-o4qy, bd-n4td 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
520 lines
12 KiB
Go
520 lines
12 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")
|
|
})
|
|
}
|