* test(doctor): add comprehensive tests for fix and check functions Add edge case tests, e2e tests, and improve test coverage for: - database_test.go: database integrity and sync checks - git_test.go: git hooks, merge driver, sync branch tests - gitignore_test.go: gitignore validation - prefix_test.go: ID prefix handling - fix/fix_test.go: fix operations - fix/e2e_test.go: end-to-end fix scenarios - fix/fix_edge_cases_test.go: edge case handling * docs: add testing philosophy and anti-patterns guide - Create TESTING_PHILOSOPHY.md covering test pyramid, priority matrix, what NOT to test, and 5 anti-patterns with code examples - Add cross-reference from README_TESTING.md - Document beads-specific guidance (well-covered areas vs gaps) - Include target metrics (test-to-code ratio, execution time targets) * chore: revert .beads/ to upstream/main state * refactor(doctor): add category grouping and Ayu theme colors - Add Category field to DoctorCheck for organizing checks by type - Define category constants: Core, Git, Runtime, Data, Integration, Metadata - Update thanks command to use shared Ayu color palette from internal/ui - Simplify test fixtures by removing redundant test cases * fix(doctor): prevent test fork bomb and fix test failures - Add ErrTestBinary guard in getBdBinary() to prevent tests from recursively executing the test binary when calling bd subcommands - Update claude_test.go to use new check names (CLI Availability, Prime Documentation) - Fix syncbranch test path comparison by resolving symlinks (/var vs /private/var on macOS) - Fix permissions check to use exact comparison instead of bitmask - Fix UntrackedJSONL to use git commit --only to preserve staged changes - Fix MergeDriver edge case test by making both .git dir and config read-only - Add skipIfTestBinary helper for E2E tests that need real bd binary * test(doctor): skip read-only config test in CI environments GitHub Actions containers may have CAP_DAC_OVERRIDE or similar capabilities that allow writing to read-only files, causing the test to fail. Skip the test when CI=true or GITHUB_ACTIONS=true.
918 lines
24 KiB
Go
918 lines
24 KiB
Go
package doctor
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/ncruces/go-sqlite3/driver"
|
|
_ "github.com/ncruces/go-sqlite3/embed"
|
|
)
|
|
|
|
// setupTestDatabase creates a minimal valid SQLite database for testing
|
|
func setupTestDatabase(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create minimal issues table
|
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS issues (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
status TEXT
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create table: %v", err)
|
|
}
|
|
|
|
return dbPath
|
|
}
|
|
|
|
func TestCheckDatabaseIntegrity(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
expectedStatus string
|
|
expectMessage string
|
|
}{
|
|
{
|
|
name: "no database",
|
|
setup: func(t *testing.T, dir string) {
|
|
// No database file created
|
|
},
|
|
expectedStatus: "ok",
|
|
expectMessage: "N/A (no database)",
|
|
},
|
|
{
|
|
name: "valid database",
|
|
setup: func(t *testing.T, dir string) {
|
|
setupTestDatabase(t, dir)
|
|
},
|
|
expectedStatus: "ok",
|
|
expectMessage: "No corruption detected",
|
|
},
|
|
{
|
|
name: "corrupt database",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
// Write garbage that isn't a valid SQLite file
|
|
if err := os.WriteFile(dbPath, []byte("not a sqlite database"), 0600); err != nil {
|
|
t.Fatalf("failed to create corrupt db: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "error",
|
|
expectMessage: "", // message varies based on sqlite driver error
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckDatabaseIntegrity(tmpDir)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q", tt.expectedStatus, check.Status)
|
|
}
|
|
if tt.expectMessage != "" && check.Message != tt.expectMessage {
|
|
t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckDatabaseJSONLSync(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
expectedStatus string
|
|
expectMessage string
|
|
}{
|
|
{
|
|
name: "no database",
|
|
setup: func(t *testing.T, dir string) {
|
|
// No database, but create JSONL
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "ok",
|
|
expectMessage: "N/A (no database)",
|
|
},
|
|
{
|
|
name: "no JSONL",
|
|
setup: func(t *testing.T, dir string) {
|
|
setupTestDatabase(t, dir)
|
|
},
|
|
expectedStatus: "ok",
|
|
expectMessage: "N/A (no JSONL file)",
|
|
},
|
|
{
|
|
name: "both exist with same count",
|
|
setup: func(t *testing.T, dir string) {
|
|
// Create database with one issue
|
|
dbPath := setupTestDatabase(t, dir)
|
|
db, _ := sql.Open("sqlite3", dbPath)
|
|
defer db.Close()
|
|
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test Issue', 'open')`)
|
|
|
|
// Create JSONL with one issue
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test Issue","status":"open"}`+"\n"), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "warning", // Warning because db doesn't have full schema for prefix check
|
|
expectMessage: "",
|
|
},
|
|
{
|
|
name: "count mismatch",
|
|
setup: func(t *testing.T, dir string) {
|
|
// Create database with one issue
|
|
dbPath := setupTestDatabase(t, dir)
|
|
db, _ := sql.Open("sqlite3", dbPath)
|
|
defer db.Close()
|
|
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test Issue', 'open')`)
|
|
|
|
// Create JSONL with two issues
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
content := `{"id":"test-1","title":"Test Issue 1","status":"open"}
|
|
{"id":"test-2","title":"Test Issue 2","status":"open"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "warning",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckDatabaseJSONLSync(tmpDir)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
|
}
|
|
if tt.expectMessage != "" && check.Message != tt.expectMessage {
|
|
t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckDatabaseVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
expectedStatus string
|
|
}{
|
|
{
|
|
name: "fresh clone with JSONL",
|
|
setup: func(t *testing.T, dir string) {
|
|
// No database but JSONL exists - fresh clone warning
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "warning", // Warning for fresh clone needing init
|
|
},
|
|
{
|
|
name: "no database no jsonl",
|
|
setup: func(t *testing.T, dir string) {
|
|
// No database, no JSONL - error (need to run bd init)
|
|
},
|
|
expectedStatus: "error",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckDatabaseVersion(tmpDir, "0.1.0")
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckSchemaCompatibility(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
expectedStatus string
|
|
}{
|
|
{
|
|
name: "no database",
|
|
setup: func(t *testing.T, dir string) {
|
|
// No database created
|
|
},
|
|
expectedStatus: "ok",
|
|
},
|
|
{
|
|
name: "minimal schema",
|
|
setup: func(t *testing.T, dir string) {
|
|
// Our minimal test database doesn't have full schema
|
|
setupTestDatabase(t, dir)
|
|
},
|
|
expectedStatus: "error", // Error because schema is incomplete
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckSchemaCompatibility(tmpDir)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountJSONLIssues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
expectedCount int
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "empty file",
|
|
content: "",
|
|
expectedCount: 0,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "single issue",
|
|
content: `{"id":"test-1","title":"Test"}` + "\n",
|
|
expectedCount: 1,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "multiple issues",
|
|
content: `{"id":"test-1","title":"Test 1"}
|
|
{"id":"test-2","title":"Test 2"}
|
|
{"id":"test-3","title":"Test 3"}
|
|
`,
|
|
expectedCount: 3,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "counts all including tombstones",
|
|
content: `{"id":"test-1","title":"Test 1","status":"open"}
|
|
{"id":"test-2","title":"Test 2","status":"tombstone"}
|
|
{"id":"test-3","title":"Test 3","status":"closed"}
|
|
`,
|
|
expectedCount: 3, // CountJSONLIssues counts all records including tombstones
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "skips empty lines",
|
|
content: `{"id":"test-1","title":"Test 1"}
|
|
|
|
{"id":"test-2","title":"Test 2"}
|
|
`,
|
|
expectedCount: 2,
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte(tt.content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
|
|
count, _, err := CountJSONLIssues(jsonlPath)
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if count != tt.expectedCount {
|
|
t.Errorf("expected count %d, got %d", tt.expectedCount, count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountJSONLIssues_NonexistentFile(t *testing.T) {
|
|
count, _, err := CountJSONLIssues("/nonexistent/path/issues.jsonl")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent file")
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected count 0, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestCountJSONLIssues_ExtractsPrefixes(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
content := `{"id":"bd-123","title":"Test 1"}
|
|
{"id":"bd-456","title":"Test 2"}
|
|
{"id":"proj-789","title":"Test 3"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
|
|
count, prefixes, err := CountJSONLIssues(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if count != 3 {
|
|
t.Errorf("expected count 3, got %d", count)
|
|
}
|
|
|
|
// Check prefixes were extracted
|
|
if _, ok := prefixes["bd"]; !ok {
|
|
t.Error("expected 'bd' prefix to be detected")
|
|
}
|
|
if _, ok := prefixes["proj"]; !ok {
|
|
t.Error("expected 'proj' prefix to be detected")
|
|
}
|
|
}
|
|
|
|
// Edge case tests
|
|
|
|
func TestCheckDatabaseIntegrity_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string) string
|
|
expectedStatus string
|
|
}{
|
|
{
|
|
name: "locked database file",
|
|
setup: func(t *testing.T, dir string) string {
|
|
dbPath := setupTestDatabase(t, dir)
|
|
|
|
// Open a connection with an exclusive lock
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
|
|
// Start a transaction to hold a lock
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
db.Close()
|
|
t.Fatalf("failed to begin transaction: %v", err)
|
|
}
|
|
|
|
// Write some data to ensure the lock is held
|
|
_, err = tx.Exec("INSERT INTO issues (id, title, status) VALUES ('lock-test', 'Lock Test', 'open')")
|
|
if err != nil {
|
|
tx.Rollback()
|
|
db.Close()
|
|
t.Fatalf("failed to insert test data: %v", err)
|
|
}
|
|
|
|
// Keep the transaction open by returning a cleanup function via test context
|
|
t.Cleanup(func() {
|
|
tx.Rollback()
|
|
db.Close()
|
|
})
|
|
|
|
return dbPath
|
|
},
|
|
expectedStatus: "ok", // Should still succeed with busy_timeout
|
|
},
|
|
{
|
|
name: "read-only database file",
|
|
setup: func(t *testing.T, dir string) string {
|
|
dbPath := setupTestDatabase(t, dir)
|
|
|
|
// Make the database file read-only
|
|
if err := os.Chmod(dbPath, 0400); err != nil {
|
|
t.Fatalf("failed to chmod database: %v", err)
|
|
}
|
|
|
|
return dbPath
|
|
},
|
|
expectedStatus: "ok", // Integrity check uses read-only mode
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckDatabaseIntegrity(tmpDir)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckDatabaseJSONLSync_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
expectedStatus string
|
|
}{
|
|
{
|
|
name: "malformed JSONL with some valid entries",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := setupTestDatabase(t, dir)
|
|
db, _ := sql.Open("sqlite3", dbPath)
|
|
defer db.Close()
|
|
|
|
// Insert test issue into database
|
|
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test Issue', 'open')`)
|
|
|
|
// Create JSONL with malformed entries
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
content := `{"id":"test-1","title":"Valid Entry"}
|
|
{malformed json without quotes
|
|
{"id":"test-2","incomplete
|
|
{"id":"test-3","title":"Another Valid Entry"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "warning", // Should warn about malformed lines
|
|
},
|
|
{
|
|
name: "JSONL with mixed valid and invalid JSON",
|
|
setup: func(t *testing.T, dir string) {
|
|
setupTestDatabase(t, dir)
|
|
|
|
// Create JSONL with some invalid JSON lines
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
content := `{"id":"test-1","title":"Valid"}
|
|
not json at all
|
|
{"id":"test-2","title":"Also Valid"}
|
|
{"broken": json}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "warning",
|
|
},
|
|
{
|
|
name: "JSONL with entries missing id field",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := setupTestDatabase(t, dir)
|
|
db, _ := sql.Open("sqlite3", dbPath)
|
|
defer db.Close()
|
|
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test', 'open')`)
|
|
|
|
// Create JSONL where some entries don't have id field
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
content := `{"id":"test-1","title":"Has ID"}
|
|
{"title":"No ID field","status":"open"}
|
|
{"id":"test-2","title":"Has ID"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "warning", // Count mismatch: db has 1, jsonl counts only 2 valid
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckDatabaseJSONLSync(tmpDir)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckDatabaseVersion_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
cliVersion string
|
|
expectedStatus string
|
|
expectMessage string
|
|
}{
|
|
{
|
|
name: "future database version",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create metadata table with future version
|
|
_, err = db.Exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create metadata table: %v", err)
|
|
}
|
|
_, err = db.Exec(`INSERT INTO metadata (key, value) VALUES ('bd_version', '99.99.99')`)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert version: %v", err)
|
|
}
|
|
},
|
|
cliVersion: "0.1.0",
|
|
expectedStatus: "warning",
|
|
expectMessage: "version 99.99.99 (CLI: 0.1.0)",
|
|
},
|
|
{
|
|
name: "database with metadata table but no version",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create metadata table but don't insert version
|
|
_, err = db.Exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create metadata table: %v", err)
|
|
}
|
|
},
|
|
cliVersion: "0.1.0",
|
|
expectedStatus: "error",
|
|
expectMessage: "Unable to read database version",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckDatabaseVersion(tmpDir, tt.cliVersion)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
|
}
|
|
if tt.expectMessage != "" && check.Message != tt.expectMessage {
|
|
t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckSchemaCompatibility_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, dir string)
|
|
expectedStatus string
|
|
expectInDetail string
|
|
}{
|
|
{
|
|
name: "partial schema - missing dependencies table",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create only issues table, missing other required tables
|
|
_, err = db.Exec(`CREATE TABLE issues (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
content_hash TEXT,
|
|
external_ref TEXT,
|
|
compacted_at INTEGER,
|
|
close_reason TEXT
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create issues table: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "error",
|
|
expectInDetail: "table:dependencies",
|
|
},
|
|
{
|
|
name: "partial schema - missing columns in issues table",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create issues table missing some required columns
|
|
_, err = db.Exec(`CREATE TABLE issues (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create issues table: %v", err)
|
|
}
|
|
|
|
// Create other tables to avoid those errors
|
|
_, err = db.Exec(`CREATE TABLE dependencies (
|
|
issue_id TEXT,
|
|
depends_on_id TEXT,
|
|
type TEXT
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create dependencies table: %v", err)
|
|
}
|
|
|
|
_, err = db.Exec(`CREATE TABLE child_counters (
|
|
parent_id TEXT,
|
|
last_child INTEGER
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create child_counters table: %v", err)
|
|
}
|
|
|
|
_, err = db.Exec(`CREATE TABLE export_hashes (
|
|
issue_id TEXT,
|
|
content_hash TEXT
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create export_hashes table: %v", err)
|
|
}
|
|
},
|
|
expectedStatus: "error",
|
|
expectInDetail: "issues.content_hash",
|
|
},
|
|
{
|
|
name: "database with no tables",
|
|
setup: func(t *testing.T, dir string) {
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
// Execute a query to ensure the database file is created
|
|
_, err = db.Exec("SELECT 1")
|
|
if err != nil {
|
|
t.Fatalf("failed to initialize database: %v", err)
|
|
}
|
|
db.Close()
|
|
},
|
|
expectedStatus: "error",
|
|
expectInDetail: "table:",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tt.setup(t, tmpDir)
|
|
|
|
check := CheckSchemaCompatibility(tmpDir)
|
|
|
|
if check.Status != tt.expectedStatus {
|
|
t.Errorf("expected status %q, got %q (message: %s, detail: %s)",
|
|
tt.expectedStatus, check.Status, check.Message, check.Detail)
|
|
}
|
|
if tt.expectInDetail != "" && !strings.Contains(check.Detail, tt.expectInDetail) {
|
|
t.Errorf("expected detail to contain %q, got %q", tt.expectInDetail, check.Detail)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountJSONLIssues_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setupContent func() string
|
|
expectedCount int
|
|
expectError bool
|
|
errorContains string
|
|
}{
|
|
{
|
|
name: "malformed JSON lines",
|
|
setupContent: func() string {
|
|
return `{"id":"valid-1","title":"Valid"}
|
|
{this is not json
|
|
{"id":"valid-2","title":"Also Valid"}
|
|
{malformed: true}
|
|
{"id":"valid-3","title":"Third Valid"}
|
|
`
|
|
},
|
|
expectedCount: 3,
|
|
expectError: true,
|
|
errorContains: "malformed",
|
|
},
|
|
{
|
|
name: "very large file with 10000 issues",
|
|
setupContent: func() string {
|
|
var sb strings.Builder
|
|
for i := 0; i < 10000; i++ {
|
|
sb.WriteString(fmt.Sprintf(`{"id":"issue-%d","title":"Issue %d","status":"open"}`, i, i))
|
|
sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
},
|
|
expectedCount: 10000,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "file with unicode and special characters",
|
|
setupContent: func() string {
|
|
return `{"id":"test-1","title":"Issue with émojis 🎉","description":"Unicode: 日本語"}
|
|
{"id":"test-2","title":"Quotes \"escaped\" and 'mixed'","status":"open"}
|
|
{"id":"test-3","title":"Newlines\nand\ttabs","status":"closed"}
|
|
`
|
|
},
|
|
expectedCount: 3,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "file with trailing whitespace",
|
|
setupContent: func() string {
|
|
return `{"id":"test-1","title":"Test"}
|
|
{"id":"test-2","title":"Test 2"}
|
|
{"id":"test-3","title":"Test 3"}
|
|
`
|
|
},
|
|
expectedCount: 3,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "all lines are malformed",
|
|
setupContent: func() string {
|
|
return `not json
|
|
also not json
|
|
{still: not valid}
|
|
`
|
|
},
|
|
expectedCount: 0,
|
|
expectError: true,
|
|
errorContains: "malformed",
|
|
},
|
|
{
|
|
name: "valid JSON but missing id in all entries",
|
|
setupContent: func() string {
|
|
return `{"title":"No ID 1","status":"open"}
|
|
{"title":"No ID 2","status":"closed"}
|
|
{"title":"No ID 3","status":"pending"}
|
|
`
|
|
},
|
|
expectedCount: 0,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "entries with numeric ids",
|
|
setupContent: func() string {
|
|
return `{"id":123,"title":"Numeric ID"}
|
|
{"id":"valid-1","title":"String ID"}
|
|
{"id":null,"title":"Null ID"}
|
|
`
|
|
},
|
|
expectedCount: 1, // Only the string ID counts
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
content := tt.setupContent()
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to create JSONL: %v", err)
|
|
}
|
|
|
|
count, _, err := CountJSONLIssues(jsonlPath)
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if tt.expectError && err != nil && tt.errorContains != "" {
|
|
if !strings.Contains(err.Error(), tt.errorContains) {
|
|
t.Errorf("expected error to contain %q, got %q", tt.errorContains, err.Error())
|
|
}
|
|
}
|
|
if count != tt.expectedCount {
|
|
t.Errorf("expected count %d, got %d", tt.expectedCount, count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountJSONLIssues_Performance(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping performance test in short mode")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "large.jsonl")
|
|
|
|
// Create a very large JSONL file (100k issues)
|
|
file, err := os.Create(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 100000; i++ {
|
|
line := fmt.Sprintf(`{"id":"perf-%d","title":"Performance Test Issue %d","status":"open","description":"Testing performance with large files"}`, i, i)
|
|
if _, err := file.WriteString(line + "\n"); err != nil {
|
|
file.Close()
|
|
t.Fatalf("failed to write line: %v", err)
|
|
}
|
|
}
|
|
file.Close()
|
|
|
|
// Measure time to count issues
|
|
start := time.Now()
|
|
count, prefixes, err := CountJSONLIssues(jsonlPath)
|
|
duration := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if count != 100000 {
|
|
t.Errorf("expected count 100000, got %d", count)
|
|
}
|
|
if len(prefixes) != 1 || prefixes["perf"] != 100000 {
|
|
t.Errorf("expected single prefix 'perf' with count 100000, got %v", prefixes)
|
|
}
|
|
|
|
// Performance should be reasonable (< 5 seconds for 100k issues)
|
|
if duration > 5*time.Second {
|
|
t.Logf("Warning: counting 100k issues took %v (expected < 5s)", duration)
|
|
} else {
|
|
t.Logf("Performance: counted 100k issues in %v", duration)
|
|
}
|
|
}
|