Files
beads/cmd/bd/doctor_test.go
Ryan 2ab064b2eb Doctor sync issues (#231)
* feat: enhance bd doctor sync detection with count and prefix mismatch checks

Improves bd doctor to detect actual database-JSONL sync issues instead of relying only on file modification times:

Key improvements:
1. Count detection: Reports when database issue count differs from JSONL (e.g., "Count mismatch: database has 0 issues, JSONL has 61")
2. Prefix detection: Identifies prefix mismatches when majority of JSONL issues use different prefix than database config
3. Error handling: Returns errors from helper functions instead of silent failures, distinguishing "can't open DB" from "counts differ"
4. Query optimization: Single database connection for all checks (reduced from 3 opens to 1)
5. Better error reporting: Shows actual error details when database or JSONL can't be read

This addresses the core issue where bd doctor would incorrectly report "Database and JSONL are in sync" when the database was empty but JSONL contained issues (as happened in privacy2 project).

Tests:
- Added TestCountJSONLIssuesWithMalformedLines to verify malformed JSON handling
- Existing doctor tests still pass
- countJSONLIssues now returns error to indicate parsing issues

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: correct git hooks installation instructions in bd doctor

The original message referenced './examples/git-hooks/install.sh' which doesn't exist in user projects. This fix changes the message to point to the actual location in the beads GitHub repository:

Before: "Run './examples/git-hooks/install.sh' to install recommended git hooks"
After: "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions"

This works for any project using bd, not just the beads repository itself.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add recovery suggestions when database fails but JSONL has issues

When bd doctor detects that the database cannot be opened/queried but the JSONL file contains issues, it now suggests the recovery command:

  Fix: Run 'bd import -i issues.jsonl --rename-on-import' to recover issues from JSONL

This addresses the case where:
- Database is corrupted or inaccessible
- JSONL has all the issues backed up
- User needs a clear path to recover

The check now:
1. Reads JSONL first (doesn't depend on database)
2. If database fails but JSONL has issues, suggests recovery command
3. If database can be queried, continues with sync checks as before

Tested on privacy2 project which has 61 issues in JSONL but inaccessible database.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: support hash-based issue IDs in import rename

The import --rename-on-import flag was rejecting valid issue IDs with
hash-based suffixes (e.g., privacy-09ea) because the validation only
accepted numeric suffixes. Beads now generates and accepts base36-encoded
hash IDs, so update the validation to match.

Changes:
- Update isNumeric() to accept base36 characters (0-9, a-z)
- Update tests to reflect hash-based ID support
- Add gosec nolint comment for safe file path construction

Fixes the error: "cannot rename issue privacy-09ea: non-numeric suffix '09ea'"

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 14:25:48 -08:00

499 lines
12 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDoctorNoBeadsDir(t *testing.T) {
// Create temporary directory
tmpDir := t.TempDir()
// Run diagnostics
result := runDiagnostics(tmpDir)
// Should fail overall
if result.OverallOK {
t.Error("Expected OverallOK to be false when .beads/ directory is missing")
}
// Check installation check failed
if len(result.Checks) == 0 {
t.Fatal("Expected at least one check")
}
installCheck := result.Checks[0]
if installCheck.Name != "Installation" {
t.Errorf("Expected first check to be Installation, got %s", installCheck.Name)
}
if installCheck.Status != "error" {
t.Errorf("Expected Installation status to be error, got %s", installCheck.Status)
}
if installCheck.Fix == "" {
t.Error("Expected Installation check to have a fix")
}
}
func TestDoctorWithBeadsDir(t *testing.T) {
// Create temporary directory with .beads
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
// Run diagnostics
result := runDiagnostics(tmpDir)
// Should have installation check passing
if len(result.Checks) == 0 {
t.Fatal("Expected at least one check")
}
installCheck := result.Checks[0]
if installCheck.Name != "Installation" {
t.Errorf("Expected first check to be Installation, got %s", installCheck.Name)
}
if installCheck.Status != "ok" {
t.Errorf("Expected Installation status to be ok, got %s", installCheck.Status)
}
}
func TestDoctorJSONOutput(t *testing.T) {
// Create temporary directory
tmpDir := t.TempDir()
// Run diagnostics
result := runDiagnostics(tmpDir)
// Marshal to JSON to verify structure
jsonBytes, err := json.Marshal(result)
if err != nil {
t.Fatalf("Failed to marshal result to JSON: %v", err)
}
// Unmarshal back to verify structure
var decoded doctorResult
if err := json.Unmarshal(jsonBytes, &decoded); err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
// Verify key fields
if decoded.Path != result.Path {
t.Errorf("Path mismatch: %s != %s", decoded.Path, result.Path)
}
if decoded.CLIVersion != result.CLIVersion {
t.Errorf("CLIVersion mismatch: %s != %s", decoded.CLIVersion, result.CLIVersion)
}
if decoded.OverallOK != result.OverallOK {
t.Errorf("OverallOK mismatch: %v != %v", decoded.OverallOK, result.OverallOK)
}
if len(decoded.Checks) != len(result.Checks) {
t.Errorf("Checks length mismatch: %d != %d", len(decoded.Checks), len(result.Checks))
}
}
// Note: isHashID is tested in migrate_hash_ids_test.go
func TestCheckInstallation(t *testing.T) {
// Test with missing .beads directory
tmpDir := t.TempDir()
check := checkInstallation(tmpDir)
if check.Status != statusError {
t.Errorf("Expected error status, got %s", check.Status)
}
if check.Fix == "" {
t.Error("Expected fix to be provided")
}
// Test with existing .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
check = checkInstallation(tmpDir)
if check.Status != statusOK {
t.Errorf("Expected ok status, got %s", check.Status)
}
}
func TestCheckDatabaseVersionJSONLMode(t *testing.T) {
// Create temporary directory with .beads but no database
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
// Create empty issues.jsonl to simulate --no-db mode
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
check := checkDatabaseVersion(tmpDir)
if check.Status != statusOK {
t.Errorf("Expected ok status for JSONL mode, got %s", check.Status)
}
if check.Message != "JSONL-only mode" {
t.Errorf("Expected JSONL-only mode message, got %s", check.Message)
}
if check.Detail == "" {
t.Error("Expected detail field to be set for JSONL mode")
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
v1 string
v2 string
expected int
}{
{"0.20.1", "0.20.1", 0}, // Equal
{"0.20.1", "0.20.0", 1}, // v1 > v2
{"0.20.0", "0.20.1", -1}, // v1 < v2
{"0.10.0", "0.9.9", 1}, // Major.minor comparison
{"1.0.0", "0.99.99", 1}, // Major version difference
{"0.20.1", "0.3.0", 1}, // String comparison would fail this
{"1.2", "1.2.0", 0}, // Different length, equal
{"1.2.1", "1.2", 1}, // Different length, v1 > v2
}
for _, tc := range tests {
result := compareVersions(tc.v1, tc.v2)
if result != tc.expected {
t.Errorf("compareVersions(%q, %q) = %d, expected %d", tc.v1, tc.v2, result, tc.expected)
}
}
}
func TestCheckMultipleDatabases(t *testing.T) {
tests := []struct {
name string
dbFiles []string
expectedStatus string
expectWarning bool
}{
{
name: "no databases",
dbFiles: []string{},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "single database",
dbFiles: []string{"beads.db"},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "multiple databases",
dbFiles: []string{"beads.db", "old.db"},
expectedStatus: statusWarning,
expectWarning: true,
},
{
name: "backup files ignored",
dbFiles: []string{"beads.db", "beads.backup.db"},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "vc.db ignored",
dbFiles: []string{"beads.db", "vc.db"},
expectedStatus: statusOK,
expectWarning: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
// Create test database files
for _, dbFile := range tc.dbFiles {
path := filepath.Join(beadsDir, dbFile)
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatal(err)
}
}
check := checkMultipleDatabases(tmpDir)
if check.Status != tc.expectedStatus {
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
}
if tc.expectWarning && check.Fix == "" {
t.Error("Expected fix message for warning status")
}
})
}
}
func TestCheckMultipleJSONLFiles(t *testing.T) {
tests := []struct {
name string
jsonlFiles []string
expectedStatus string
expectWarning bool
}{
{
name: "no JSONL files",
jsonlFiles: []string{},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "single issues.jsonl",
jsonlFiles: []string{"issues.jsonl"},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "single beads.jsonl",
jsonlFiles: []string{"beads.jsonl"},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "both JSONL files",
jsonlFiles: []string{"issues.jsonl", "beads.jsonl"},
expectedStatus: statusWarning,
expectWarning: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
// Create test JSONL files
for _, jsonlFile := range tc.jsonlFiles {
path := filepath.Join(beadsDir, jsonlFile)
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatal(err)
}
}
check := checkMultipleJSONLFiles(tmpDir)
if check.Status != tc.expectedStatus {
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
}
if tc.expectWarning && check.Fix == "" {
t.Error("Expected fix message for warning status")
}
})
}
}
func TestCheckPermissions(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
check := checkPermissions(tmpDir)
if check.Status != statusOK {
t.Errorf("Expected ok status for writable directory, got %s: %s", check.Status, check.Message)
}
}
func TestCheckDatabaseJSONLSync(t *testing.T) {
tests := []struct {
name string
hasDB bool
hasJSONL bool
expectedStatus string
}{
{
name: "no database",
hasDB: false,
hasJSONL: true,
expectedStatus: statusOK,
},
{
name: "no JSONL",
hasDB: true,
hasJSONL: false,
expectedStatus: statusOK,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
if tc.hasDB {
dbPath := filepath.Join(beadsDir, "beads.db")
// Skip database creation tests due to SQLite driver registration in tests
// The real doctor command works fine with actual databases
if tc.hasJSONL {
t.Skip("Database creation in tests requires complex driver setup")
}
// For no-JSONL case, just create an empty file
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
}
if tc.hasJSONL {
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
}
check := checkDatabaseJSONLSync(tmpDir)
if check.Status != tc.expectedStatus {
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
}
})
}
}
func TestCountJSONLIssuesWithMalformedLines(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
// Create JSONL file with mixed valid and invalid JSON
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
jsonlContent := `{"id":"test-001","title":"Valid 1"}
invalid json line here
{"id":"test-002","title":"Valid 2"}
{"broken": incomplete
{"id":"test-003","title":"Valid 3"}
`
if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0644); err != nil {
t.Fatal(err)
}
count, prefixes, err := countJSONLIssues(jsonlPath)
// Should count valid issues (3)
if count != 3 {
t.Errorf("Expected 3 issues, got %d", count)
}
// Should have 1 error for malformed lines
if err == nil {
t.Error("Expected error for malformed lines, got nil")
}
if !strings.Contains(err.Error(), "skipped") {
t.Errorf("Expected error about skipped lines, got: %v", err)
}
// Should have extracted prefix
if prefixes["test"] != 3 {
t.Errorf("Expected 3 'test' prefixes, got %d", prefixes["test"])
}
}
func TestCheckGitHooks(t *testing.T) {
tests := []struct {
name string
hasGitDir bool
installedHooks []string
expectedStatus string
expectWarning bool
}{
{
name: "not a git repository",
hasGitDir: false,
installedHooks: []string{},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "all hooks installed",
hasGitDir: true,
installedHooks: []string{"pre-commit", "post-merge", "pre-push"},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "no hooks installed",
hasGitDir: true,
installedHooks: []string{},
expectedStatus: statusWarning,
expectWarning: true,
},
{
name: "some hooks installed",
hasGitDir: true,
installedHooks: []string{"pre-commit"},
expectedStatus: statusWarning,
expectWarning: true,
},
{
name: "partial hooks installed",
hasGitDir: true,
installedHooks: []string{"pre-commit", "post-merge"},
expectedStatus: statusWarning,
expectWarning: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
if tc.hasGitDir {
gitDir := filepath.Join(tmpDir, ".git")
hooksDir := filepath.Join(gitDir, "hooks")
if err := os.MkdirAll(hooksDir, 0750); err != nil {
t.Fatal(err)
}
// Create installed hooks
for _, hookName := range tc.installedHooks {
hookPath := filepath.Join(hooksDir, hookName)
if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
}
}
check := checkGitHooks(tmpDir)
if check.Status != tc.expectedStatus {
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
}
if tc.expectWarning && check.Fix == "" {
t.Error("Expected fix message for warning status")
}
if !tc.expectWarning && check.Fix != "" && tc.hasGitDir {
t.Error("Expected no fix message for non-warning status")
}
})
}
}