Files
beads/cmd/bd/doctor/deep_test.go
Steve Yegge f3c663d31c feat: add bd doctor --deep for full graph integrity validation (bd-cwpl)
Add a --deep flag to bd doctor that runs comprehensive graph integrity
checks on the beads database:

- Parent consistency: verify parent-child deps point to existing issues
- Dependency integrity: all dependencies reference valid issues
- Epic completeness: find epics ready to close (all children closed)
- Agent bead integrity: validate agent beads have valid state values
- Mail thread integrity: verify thread_id references exist
- Molecule integrity: check molecules have valid parent-child structures

The deep validation mode warns about potential slowness on large databases
and provides both human-readable and JSON output formats.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 02:13:34 -08:00

303 lines
7.8 KiB
Go

package doctor
import (
"database/sql"
"os"
"path/filepath"
"testing"
)
// TestRunDeepValidation_NoBeadsDir verifies deep validation handles missing .beads directory
func TestRunDeepValidation_NoBeadsDir(t *testing.T) {
tmpDir := t.TempDir()
result := RunDeepValidation(tmpDir)
if len(result.AllChecks) != 1 {
t.Errorf("Expected 1 check, got %d", len(result.AllChecks))
}
if result.AllChecks[0].Status != StatusOK {
t.Errorf("Status = %q, want %q", result.AllChecks[0].Status, StatusOK)
}
}
// TestRunDeepValidation_EmptyBeadsDir verifies deep validation with empty .beads directory
func TestRunDeepValidation_EmptyBeadsDir(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
result := RunDeepValidation(tmpDir)
// Should return OK with "no database" message
if len(result.AllChecks) != 1 {
t.Errorf("Expected 1 check, got %d", len(result.AllChecks))
}
if result.AllChecks[0].Status != StatusOK {
t.Errorf("Status = %q, want %q", result.AllChecks[0].Status, StatusOK)
}
}
// TestRunDeepValidation_WithDatabase verifies deep validation with a basic database
func TestRunDeepValidation_WithDatabase(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create a minimal database (use canonical name beads.db)
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create minimal schema matching what deep validation expects
_, err = db.Exec(`
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
issue_type TEXT NOT NULL DEFAULT 'task',
notes TEXT DEFAULT ''
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
created_by TEXT NOT NULL DEFAULT '',
thread_id TEXT DEFAULT '',
PRIMARY KEY (issue_id, depends_on_id)
);
CREATE TABLE labels (
issue_id TEXT NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY (issue_id, label)
);
`)
if err != nil {
t.Fatal(err)
}
result := RunDeepValidation(tmpDir)
// Should have 6 checks (one for each validation type)
if len(result.AllChecks) != 6 {
// Log what we got for debugging
t.Logf("Got %d checks:", len(result.AllChecks))
for i, check := range result.AllChecks {
t.Logf(" %d: %s - %s", i, check.Name, check.Message)
}
t.Errorf("Expected 6 checks, got %d", len(result.AllChecks))
}
// All should pass on empty database
for _, check := range result.AllChecks {
if check.Status == StatusError {
t.Errorf("Check %s failed: %s", check.Name, check.Message)
}
}
}
// TestCheckParentConsistency_OrphanedDeps verifies detection of orphaned parent-child deps
func TestCheckParentConsistency_OrphanedDeps(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create schema
_, err = db.Exec(`
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open'
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
PRIMARY KEY (issue_id, depends_on_id)
);
`)
if err != nil {
t.Fatal(err)
}
// Insert an issue
_, err = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('bd-1', 'Test Issue', 'open')`)
if err != nil {
t.Fatal(err)
}
// Insert a parent-child dep pointing to non-existent parent
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, type) VALUES ('bd-1', 'bd-missing', 'parent-child')`)
if err != nil {
t.Fatal(err)
}
check := checkParentConsistency(db)
if check.Status != StatusError {
t.Errorf("Status = %q, want %q", check.Status, StatusError)
}
}
// TestCheckEpicCompleteness_CompletedEpic verifies detection of closeable epics
func TestCheckEpicCompleteness_CompletedEpic(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create schema
_, err = db.Exec(`
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
issue_type TEXT NOT NULL DEFAULT 'task'
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
PRIMARY KEY (issue_id, depends_on_id)
);
`)
if err != nil {
t.Fatal(err)
}
// Insert an open epic
_, err = db.Exec(`INSERT INTO issues (id, title, status, issue_type) VALUES ('epic-1', 'Epic', 'open', 'epic')`)
if err != nil {
t.Fatal(err)
}
// Insert a closed child task
_, err = db.Exec(`INSERT INTO issues (id, title, status, issue_type) VALUES ('task-1', 'Task', 'closed', 'task')`)
if err != nil {
t.Fatal(err)
}
// Create parent-child relationship
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, type) VALUES ('task-1', 'epic-1', 'parent-child')`)
if err != nil {
t.Fatal(err)
}
check := checkEpicCompleteness(db)
// Epic with all children closed should be detected
if check.Status != StatusWarning {
t.Errorf("Status = %q, want %q", check.Status, StatusWarning)
}
}
// TestCheckMailThreadIntegrity_ValidThreads verifies valid thread references pass
func TestCheckMailThreadIntegrity_ValidThreads(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create schema with thread_id column
_, err = db.Exec(`
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open'
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
thread_id TEXT DEFAULT '',
PRIMARY KEY (issue_id, depends_on_id)
);
`)
if err != nil {
t.Fatal(err)
}
// Insert issues
_, err = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('thread-root', 'Thread Root', 'open')`)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('reply-1', 'Reply', 'open')`)
if err != nil {
t.Fatal(err)
}
// Insert a dependency with valid thread_id
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, type, thread_id) VALUES ('reply-1', 'thread-root', 'replies-to', 'thread-root')`)
if err != nil {
t.Fatal(err)
}
check := checkMailThreadIntegrity(db)
if check.Status != StatusOK {
t.Errorf("Status = %q, want %q: %s", check.Status, StatusOK, check.Message)
}
}
// TestDeepValidationResultJSON verifies JSON serialization
func TestDeepValidationResultJSON(t *testing.T) {
result := DeepValidationResult{
TotalIssues: 10,
TotalDependencies: 5,
OverallOK: true,
AllChecks: []DoctorCheck{
{Name: "Test", Status: StatusOK, Message: "All good"},
},
}
jsonBytes, err := DeepValidationResultJSON(result)
if err != nil {
t.Fatalf("Failed to serialize: %v", err)
}
if len(jsonBytes) == 0 {
t.Error("Expected non-empty JSON output")
}
// Should contain expected fields
jsonStr := string(jsonBytes)
if !contains(jsonStr, "total_issues") {
t.Error("JSON should contain total_issues")
}
if !contains(jsonStr, "overall_ok") {
t.Error("JSON should contain overall_ok")
}
}