Files
beads/cmd/bd/doctor/fix/fix_test.go
matt wilkie 2de4d0facd fix(bd-68e4): make DBJSONLSync bidirectional - export DB when it has more issues
- Modified fix.DBJSONLSync() to detect which direction to sync:
  - If DB > JSONL: run 'bd export' to sync JSONL (DB has newer data)
  - If JSONL > DB: run 'bd sync --import-only' to import (JSONL is source of truth)
  - If equal but different timestamps: use file mtime to decide direction

- Updated CheckDatabaseJSONLSync() error messages to recommend correct fix direction:
  - Shows different guidance based on whether DB or JSONL has more issues

- Added helper functions:
  - countDatabaseIssues() to count issues in SQLite
  - countJSONLIssues() to count issues in JSONL (local, avoids circular import)

- Added tests for countJSONLIssues() with edge cases

Fixes issue where 'bd doctor --fix' would recommend 'bd sync --import-only'
when DB > JSONL, which would be a no-op since JSONL hasn't changed.
2025-12-21 11:22:37 -07:00

711 lines
19 KiB
Go

package fix
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// setupTestWorkspace creates a temporary directory with a .beads directory
func setupTestWorkspace(t *testing.T) string {
t.Helper()
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
return dir
}
// setupTestGitRepo creates a temporary git repository with a .beads directory
func setupTestGitRepo(t *testing.T) string {
t.Helper()
dir := setupTestWorkspace(t)
// Initialize git repo
cmd := exec.Command("git", "init")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Configure git user for commits
cmd = exec.Command("git", "config", "user.email", "test@test.com")
cmd.Dir = dir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = dir
_ = cmd.Run()
return dir
}
// runGit runs a git command and returns output
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("git %v: %s", args, output)
}
return string(output)
}
// TestValidateBeadsWorkspace tests the workspace validation function
func TestValidateBeadsWorkspace(t *testing.T) {
t.Run("invalid path", func(t *testing.T) {
err := validateBeadsWorkspace("/nonexistent/path/that/does/not/exist")
if err == nil {
t.Error("expected error for nonexistent path")
}
})
}
// TestGitHooks_Validation tests GitHooks validation
func TestGitHooks_Validation(t *testing.T) {
t.Run("not a git repository", func(t *testing.T) {
dir := setupTestWorkspace(t)
err := GitHooks(dir)
if err == nil {
t.Error("expected error for non-git repository")
}
if err.Error() != "not a git repository" {
t.Errorf("unexpected error: %v", err)
}
})
}
// TestMergeDriver_Validation tests MergeDriver validation
func TestMergeDriver_Validation(t *testing.T) {
t.Run("sets correct merge driver config", func(t *testing.T) {
dir := setupTestGitRepo(t)
err := MergeDriver(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the config was set
cmd := exec.Command("git", "config", "merge.beads.driver")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get git config: %v", err)
}
expected := "bd merge %A %O %A %B\n"
if string(output) != expected {
t.Errorf("expected %q, got %q", expected, string(output))
}
})
}
// TestDaemon_Validation tests Daemon validation
func TestDaemon_Validation(t *testing.T) {
t.Run("no socket - nothing to do", func(t *testing.T) {
dir := setupTestWorkspace(t)
err := Daemon(dir)
if err != nil {
t.Errorf("expected no error when no socket exists, got: %v", err)
}
})
}
// TestDBJSONLSync_Validation tests DBJSONLSync validation
func TestDBJSONLSync_Validation(t *testing.T) {
t.Run("no database - nothing to do", func(t *testing.T) {
dir := setupTestWorkspace(t)
err := DBJSONLSync(dir)
if err != nil {
t.Errorf("expected no error when no database exists, got: %v", err)
}
})
t.Run("no JSONL - nothing to do", func(t *testing.T) {
dir := setupTestWorkspace(t)
// Create a database file
dbPath := filepath.Join(dir, ".beads", "beads.db")
if err := os.WriteFile(dbPath, []byte("test"), 0600); err != nil {
t.Fatalf("failed to create test db: %v", err)
}
err := DBJSONLSync(dir)
if err != nil {
t.Errorf("expected no error when no JSONL exists, got: %v", err)
}
})
}
// TestSyncBranchConfig_Validation tests SyncBranchConfig validation
func TestSyncBranchConfig_Validation(t *testing.T) {
t.Run("not a git repository", func(t *testing.T) {
dir := setupTestWorkspace(t)
err := SyncBranchConfig(dir)
if err == nil {
t.Error("expected error for non-git repository")
}
})
}
// TestSyncBranchHealth_Validation tests SyncBranchHealth validation
func TestSyncBranchHealth_Validation(t *testing.T) {
t.Run("no main or master branch", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create a commit on a different branch
cmd := exec.Command("git", "checkout", "-b", "other")
cmd.Dir = dir
_ = cmd.Run()
// Create a file and commit
testFile := filepath.Join(dir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
cmd = exec.Command("git", "add", "test.txt")
cmd.Dir = dir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "initial")
cmd.Dir = dir
_ = cmd.Run()
err := SyncBranchHealth(dir, "beads-sync")
if err == nil {
t.Error("expected error when neither main nor master exists")
}
})
}
// TestUntrackedJSONL_Validation tests UntrackedJSONL validation
func TestUntrackedJSONL_Validation(t *testing.T) {
t.Run("not a git repository", func(t *testing.T) {
dir := setupTestWorkspace(t)
err := UntrackedJSONL(dir)
if err == nil {
t.Error("expected error for non-git repository")
}
})
t.Run("no untracked files", func(t *testing.T) {
dir := setupTestGitRepo(t)
err := UntrackedJSONL(dir)
// Should succeed with no untracked files
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
}
// TestMigrateTombstones tests the MigrateTombstones function
func TestMigrateTombstones(t *testing.T) {
t.Run("no deletions.jsonl - nothing to migrate", func(t *testing.T) {
dir := setupTestWorkspace(t)
err := MigrateTombstones(dir)
if err != nil {
t.Errorf("expected no error when no deletions.jsonl exists, got: %v", err)
}
})
t.Run("empty deletions.jsonl", func(t *testing.T) {
dir := setupTestWorkspace(t)
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
if err := os.WriteFile(deletionsPath, []byte(""), 0600); err != nil {
t.Fatalf("failed to create deletions.jsonl: %v", err)
}
err := MigrateTombstones(dir)
if err != nil {
t.Errorf("expected no error for empty deletions.jsonl, got: %v", err)
}
})
t.Run("migrates deletions to tombstones", func(t *testing.T) {
dir := setupTestWorkspace(t)
// Create deletions.jsonl with a deletion record
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
deletion := legacyDeletionRecord{
ID: "test-123",
Timestamp: time.Now(),
Actor: "testuser",
Reason: "test deletion",
}
data, _ := json.Marshal(deletion)
if err := os.WriteFile(deletionsPath, append(data, '\n'), 0600); err != nil {
t.Fatalf("failed to create deletions.jsonl: %v", err)
}
// Create empty issues.jsonl
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(""), 0600); err != nil {
t.Fatalf("failed to create issues.jsonl: %v", err)
}
err := MigrateTombstones(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify deletions.jsonl was renamed
if _, err := os.Stat(deletionsPath); !os.IsNotExist(err) {
t.Error("deletions.jsonl should have been renamed")
}
migratedPath := deletionsPath + ".migrated"
if _, err := os.Stat(migratedPath); os.IsNotExist(err) {
t.Error("deletions.jsonl.migrated should exist")
}
// Verify tombstone was written to issues.jsonl
content, err := os.ReadFile(jsonlPath)
if err != nil {
t.Fatalf("failed to read issues.jsonl: %v", err)
}
if len(content) == 0 {
t.Error("expected tombstone to be written to issues.jsonl")
}
// Verify the tombstone content
var issue struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(content[:len(content)-1], &issue); err != nil {
t.Fatalf("failed to parse tombstone: %v", err)
}
if issue.ID != "test-123" {
t.Errorf("expected ID test-123, got %s", issue.ID)
}
if issue.Status != "tombstone" {
t.Errorf("expected status tombstone, got %s", issue.Status)
}
})
t.Run("skips already existing tombstones", func(t *testing.T) {
dir := setupTestWorkspace(t)
// Create deletions.jsonl with a deletion record
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
deletion := legacyDeletionRecord{
ID: "test-123",
Timestamp: time.Now(),
Actor: "testuser",
}
data, _ := json.Marshal(deletion)
if err := os.WriteFile(deletionsPath, append(data, '\n'), 0600); err != nil {
t.Fatalf("failed to create deletions.jsonl: %v", err)
}
// Create issues.jsonl with an existing tombstone for the same ID
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
existingTombstone := map[string]interface{}{
"id": "test-123",
"status": "tombstone",
}
existingData, _ := json.Marshal(existingTombstone)
if err := os.WriteFile(jsonlPath, append(existingData, '\n'), 0600); err != nil {
t.Fatalf("failed to create issues.jsonl: %v", err)
}
originalContent, _ := os.ReadFile(jsonlPath)
err := MigrateTombstones(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify issues.jsonl was not modified (tombstone already exists)
newContent, _ := os.ReadFile(jsonlPath)
if string(newContent) != string(originalContent) {
t.Error("issues.jsonl should not have been modified when tombstone already exists")
}
})
}
// TestLoadLegacyDeletions tests the loadLegacyDeletions helper
func TestLoadLegacyDeletions(t *testing.T) {
t.Run("nonexistent file returns empty map", func(t *testing.T) {
records, err := loadLegacyDeletions("/nonexistent/path")
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
if len(records) != 0 {
t.Errorf("expected empty map, got %d records", len(records))
}
})
t.Run("parses valid deletions", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deletions.jsonl")
deletion := legacyDeletionRecord{
ID: "test-abc",
Timestamp: time.Now(),
Actor: "user",
Reason: "testing",
}
data, _ := json.Marshal(deletion)
if err := os.WriteFile(path, append(data, '\n'), 0600); err != nil {
t.Fatalf("failed to write file: %v", err)
}
records, err := loadLegacyDeletions(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records["test-abc"].Actor != "user" {
t.Errorf("expected actor 'user', got %s", records["test-abc"].Actor)
}
})
t.Run("skips invalid lines", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deletions.jsonl")
content := `{"id":"valid-1","ts":"2024-01-01T00:00:00Z","by":"user"}
invalid json line
{"id":"valid-2","ts":"2024-01-01T00:00:00Z","by":"user"}
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("failed to write file: %v", err)
}
records, err := loadLegacyDeletions(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 2 {
t.Fatalf("expected 2 valid records, got %d", len(records))
}
})
t.Run("skips records without ID", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deletions.jsonl")
content := `{"id":"valid-1","ts":"2024-01-01T00:00:00Z","by":"user"}
{"ts":"2024-01-01T00:00:00Z","by":"user"}
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("failed to write file: %v", err)
}
records, err := loadLegacyDeletions(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected 1 valid record, got %d", len(records))
}
})
}
// TestConvertLegacyDeletionToTombstone tests tombstone conversion
func TestConvertLegacyDeletionToTombstone(t *testing.T) {
t.Run("converts with all fields", func(t *testing.T) {
ts := time.Now()
record := legacyDeletionRecord{
ID: "test-xyz",
Timestamp: ts,
Actor: "admin",
Reason: "cleanup",
}
tombstone := convertLegacyDeletionToTombstone(record)
if tombstone.ID != "test-xyz" {
t.Errorf("expected ID test-xyz, got %s", tombstone.ID)
}
if tombstone.Status != "tombstone" {
t.Errorf("expected status tombstone, got %s", tombstone.Status)
}
if tombstone.DeletedBy != "admin" {
t.Errorf("expected DeletedBy admin, got %s", tombstone.DeletedBy)
}
if tombstone.DeleteReason != "cleanup" {
t.Errorf("expected DeleteReason cleanup, got %s", tombstone.DeleteReason)
}
if tombstone.DeletedAt == nil {
t.Error("expected DeletedAt to be set")
}
})
t.Run("handles zero timestamp", func(t *testing.T) {
record := legacyDeletionRecord{
ID: "test-zero",
Actor: "user",
}
tombstone := convertLegacyDeletionToTombstone(record)
if tombstone.DeletedAt == nil {
t.Error("expected DeletedAt to be set even with zero timestamp")
}
})
}
// TestFindJSONLPath tests the findJSONLPath helper
func TestFindJSONLPath(t *testing.T) {
t.Run("returns empty for no JSONL", func(t *testing.T) {
dir := t.TempDir()
path := findJSONLPath(dir)
if path != "" {
t.Errorf("expected empty path, got %s", path)
}
})
t.Run("finds issues.jsonl", func(t *testing.T) {
dir := t.TempDir()
jsonlPath := filepath.Join(dir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte("{}"), 0600); err != nil {
t.Fatalf("failed to create file: %v", err)
}
path := findJSONLPath(dir)
if path != jsonlPath {
t.Errorf("expected %s, got %s", jsonlPath, path)
}
})
t.Run("finds beads.jsonl as fallback", func(t *testing.T) {
dir := t.TempDir()
jsonlPath := filepath.Join(dir, "beads.jsonl")
if err := os.WriteFile(jsonlPath, []byte("{}"), 0600); err != nil {
t.Fatalf("failed to create file: %v", err)
}
path := findJSONLPath(dir)
if path != jsonlPath {
t.Errorf("expected %s, got %s", jsonlPath, path)
}
})
t.Run("prefers issues.jsonl over beads.jsonl", func(t *testing.T) {
dir := t.TempDir()
issuesPath := filepath.Join(dir, "issues.jsonl")
beadsPath := filepath.Join(dir, "beads.jsonl")
if err := os.WriteFile(issuesPath, []byte("{}"), 0600); err != nil {
t.Fatalf("failed to create issues.jsonl: %v", err)
}
if err := os.WriteFile(beadsPath, []byte("{}"), 0600); err != nil {
t.Fatalf("failed to create beads.jsonl: %v", err)
}
path := findJSONLPath(dir)
if path != issuesPath {
t.Errorf("expected %s, got %s", issuesPath, path)
}
})
}
// TestIsWithinWorkspace tests the isWithinWorkspace helper
func TestIsWithinWorkspace(t *testing.T) {
root := t.TempDir()
tests := []struct {
name string
candidate string
want bool
}{
{
name: "same directory",
candidate: root,
want: true,
},
{
name: "subdirectory",
candidate: filepath.Join(root, "subdir"),
want: true,
},
{
name: "nested subdirectory",
candidate: filepath.Join(root, "sub", "dir", "nested"),
want: true,
},
{
name: "parent directory",
candidate: filepath.Dir(root),
want: false,
},
{
name: "sibling directory",
candidate: filepath.Join(filepath.Dir(root), "sibling"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isWithinWorkspace(root, tt.candidate)
if got != tt.want {
t.Errorf("isWithinWorkspace(%q, %q) = %v, want %v", root, tt.candidate, got, tt.want)
}
})
}
}
// TestDBJSONLSync_MissingDatabase tests DBJSONLSync when database doesn't exist
func TestDBJSONLSync_MissingDatabase(t *testing.T) {
dir := setupTestWorkspace(t)
beadsDir := filepath.Join(dir, ".beads")
// Create only JSONL file
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
issue := map[string]interface{}{
"id": "test-no-db",
"title": "No DB Test",
"status": "open",
}
data, _ := json.Marshal(issue)
if err := os.WriteFile(jsonlPath, append(data, '\n'), 0600); err != nil {
t.Fatalf("failed to create jsonl: %v", err)
}
// Should return without error since there's nothing to sync
err := DBJSONLSync(dir)
if err != nil {
t.Errorf("expected no error when database doesn't exist, got: %v", err)
}
}
// TestSyncBranchConfig_BranchDoesNotExist tests fixing config when branch doesn't exist
func TestSyncBranchConfig_BranchDoesNotExist(t *testing.T) {
// Skip if running as test binary (can't execute bd subcommands)
skipIfTestBinary(t)
dir := setupTestGitRepo(t)
// Try to run fix without any commits (no branch exists yet)
err := SyncBranchConfig(dir)
if err == nil {
t.Error("expected error when no branch exists")
}
if err != nil && !strings.Contains(err.Error(), "failed to get current branch") {
t.Errorf("unexpected error: %v", err)
}
}
// TestSyncBranchConfig_InvalidRemoteURL tests fix behavior with invalid remote
func TestSyncBranchConfig_InvalidRemoteURL(t *testing.T) {
// Skip if running as test binary (can't execute bd subcommands)
skipIfTestBinary(t)
dir := setupTestGitRepo(t)
// Create initial commit
testFile := filepath.Join(dir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "test.txt")
runGit(t, dir, "commit", "-m", "initial commit")
// Add invalid remote
runGit(t, dir, "remote", "add", "origin", "invalid://bad-url")
// Fix should still succeed - it only sets config, doesn't interact with remote
err := SyncBranchConfig(dir)
if err != nil {
t.Fatalf("unexpected error with invalid remote: %v", err)
}
// Verify config was set
cmd := exec.Command("git", "config", "sync.branch")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get sync.branch config: %v", err)
}
if strings.TrimSpace(string(output)) == "" {
t.Error("sync.branch config was not set")
}
}
func TestCountJSONLIssues(t *testing.T) {
t.Parallel()
t.Run("empty_JSONL", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
// Create empty JSONL
if err := os.WriteFile(jsonlPath, []byte(""), 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, err := countJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 0 {
t.Errorf("expected 0, got %d", count)
}
})
t.Run("valid_issues", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
// Create JSONL with 3 issues
jsonl := []byte(`{"id":"bd-1","title":"First"}
{"id":"bd-2","title":"Second"}
{"id":"bd-3","title":"Third"}
`)
if err := os.WriteFile(jsonlPath, jsonl, 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, err := countJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 3 {
t.Errorf("expected 3, got %d", count)
}
})
t.Run("mixed_valid_and_invalid", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
// Create JSONL with 2 valid and some invalid lines
jsonl := []byte(`{"id":"bd-1","title":"First"}
invalid json line
{"id":"bd-2","title":"Second"}
{"title":"No ID"}
`)
if err := os.WriteFile(jsonlPath, jsonl, 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, err := countJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
})
t.Run("nonexistent_file", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "nonexistent.jsonl")
count, err := countJSONLIssues(jsonlPath)
if err == nil {
t.Error("expected error for nonexistent file")
}
if count != 0 {
t.Errorf("expected 0, got %d", count)
}
})
}