Files
beads/cmd/bd/export_test.go
Alberto Garcia Illera 4669de7625 feat(export): add --id and --parent filters (#1292)
Add two new filter flags to the export command:
- --id: Filter by specific issue IDs (comma-separated)
- --parent: Filter by parent issue ID (shows children)

Also fix safety checks (empty DB, staleness) to skip when filters
are active, since filtered exports intentionally produce subsets.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:12:26 -08:00

705 lines
20 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestExportCommand(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-export-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
s := newTestStore(t, testDB)
defer s.Close()
ctx := context.Background()
// Create test issues
issues := []*types.Issue{
{
Title: "First Issue",
Description: "Test description 1",
Priority: 0,
IssueType: types.TypeBug,
Status: types.StatusOpen,
},
{
Title: "Second Issue",
Description: "Test description 2",
Priority: 1,
IssueType: types.TypeFeature,
Status: types.StatusInProgress,
},
}
for _, issue := range issues {
if err := s.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
}
// Add a label to first issue
if err := s.AddLabel(ctx, issues[0].ID, "critical", "test-user"); err != nil {
t.Fatalf("Failed to add label: %v", err)
}
// Add a dependency
dep := &types.Dependency{
IssueID: issues[0].ID,
DependsOnID: issues[1].ID,
Type: "blocks",
}
if err := s.AddDependency(ctx, dep, "test-user"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
t.Run("export to file", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export.jsonl")
// Set up global state
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Create a mock command with output flag
exportCmd.SetArgs([]string{"-o", exportPath})
exportCmd.Flags().Set("output", exportPath)
// Export
exportCmd.Run(exportCmd, []string{})
// Verify file was created
if _, err := os.Stat(exportPath); os.IsNotExist(err) {
t.Fatal("Export file was not created")
}
// Read and verify JSONL content
file, err := os.Open(exportPath)
if err != nil {
t.Fatalf("Failed to open export file: %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineCount := 0
for scanner.Scan() {
lineCount++
var issue types.Issue
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
t.Fatalf("Failed to parse JSONL line %d: %v", lineCount, err)
}
// Verify issue has required fields
if issue.ID == "" {
t.Error("Issue missing ID")
}
if issue.Title == "" {
t.Error("Issue missing title")
}
}
if lineCount != 2 {
t.Errorf("Expected 2 lines in export, got %d", lineCount)
}
})
t.Run("export includes labels", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_labels.jsonl")
// Clear export hashes to force re-export (test isolation)
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
exportCmd.Flags().Set("output", exportPath)
exportCmd.Run(exportCmd, []string{})
file, err := os.Open(exportPath)
if err != nil {
t.Fatalf("Failed to open export file: %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
foundLabeledIssue := false
for scanner.Scan() {
var issue types.Issue
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
t.Fatalf("Failed to parse JSONL: %v", err)
}
if issue.ID == issues[0].ID {
foundLabeledIssue = true
if len(issue.Labels) != 1 || issue.Labels[0] != "critical" {
t.Errorf("Expected label 'critical', got %v", issue.Labels)
}
}
}
if !foundLabeledIssue {
t.Error("Did not find labeled issue in export")
}
})
t.Run("export includes dependencies", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_deps.jsonl")
// Clear export hashes to force re-export (test isolation)
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
exportCmd.Flags().Set("output", exportPath)
exportCmd.Run(exportCmd, []string{})
file, err := os.Open(exportPath)
if err != nil {
t.Fatalf("Failed to open export file: %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
foundDependency := false
for scanner.Scan() {
var issue types.Issue
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
t.Fatalf("Failed to parse JSONL: %v", err)
}
if issue.ID == issues[0].ID && len(issue.Dependencies) > 0 {
foundDependency = true
if issue.Dependencies[0].DependsOnID != issues[1].ID {
t.Errorf("Expected dependency to %s, got %s", issues[1].ID, issue.Dependencies[0].DependsOnID)
}
}
}
if !foundDependency {
t.Error("Did not find dependency in export")
}
})
t.Run("validate export path", func(t *testing.T) {
// Test safe path
if err := validateExportPath(tmpDir); err != nil {
t.Errorf("Unexpected error for safe path: %v", err)
}
// Test Windows system directories
// Note: validateExportPath() only checks Windows paths on case-insensitive systems
// On Unix/Mac, C:\Windows won't match, so we skip this assertion
// Just verify the function doesn't panic with Windows-style paths
_ = validateExportPath("C:\\Windows\\system32\\test.jsonl")
})
t.Run("prevent exporting empty database over non-empty JSONL", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_empty_check.jsonl")
// First, create a JSONL file with issues
file, err := os.Create(exportPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
encoder := json.NewEncoder(file)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
t.Fatalf("Failed to encode issue: %v", err)
}
}
file.Close()
// Verify file has issues
count, err := countIssuesInJSONL(exportPath)
if err != nil {
t.Fatalf("Failed to count issues: %v", err)
}
if count != 2 {
t.Fatalf("Expected 2 issues in JSONL, got %d", count)
}
// Create empty database
emptyDBPath := filepath.Join(tmpDir, "empty.db")
emptyStore := newTestStore(t, emptyDBPath)
defer emptyStore.Close()
// Test using exportToJSONLWithStore directly (daemon code path)
err = exportToJSONLWithStore(ctx, emptyStore, exportPath)
if err == nil {
t.Error("Expected error when exporting empty database over non-empty JSONL")
} else {
expectedMsg := "refusing to export empty database over non-empty JSONL file (database: 0 issues, JSONL: 2 issues). This would result in data loss"
if err.Error() != expectedMsg {
t.Errorf("Unexpected error message:\nGot: %q\nExpected: %q", err.Error(), expectedMsg)
}
}
// Verify JSONL file is unchanged
countAfter, err := countIssuesInJSONL(exportPath)
if err != nil {
t.Fatalf("Failed to count issues after failed export: %v", err)
}
if countAfter != 2 {
t.Errorf("JSONL file was modified! Expected 2 issues, got %d", countAfter)
}
})
t.Run("verify JSONL line count matches exported count", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_verify.jsonl")
// Clear export hashes to force re-export
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
exportCmd.Flags().Set("output", exportPath)
exportCmd.Run(exportCmd, []string{})
// Verify the exported file has exactly 2 lines
actualCount, err := countIssuesInJSONL(exportPath)
if err != nil {
t.Fatalf("Failed to count issues in JSONL: %v", err)
}
if actualCount != 2 {
t.Errorf("Expected 2 issues in JSONL, got %d", actualCount)
}
// Simulate corrupted export by truncating file
corruptedPath := filepath.Join(tmpDir, "export_corrupted.jsonl")
// First export normally
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
rootCtx = ctx
defer func() { rootCtx = nil }()
exportCmd.Flags().Set("output", corruptedPath)
exportCmd.Run(exportCmd, []string{})
// Now manually corrupt it by removing one line
file, err := os.Open(corruptedPath)
if err != nil {
t.Fatalf("Failed to open file for corruption: %v", err)
}
scanner := bufio.NewScanner(file)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
file.Close()
// Write back only first line (simulating partial write)
corruptedFile, err := os.Create(corruptedPath)
if err != nil {
t.Fatalf("Failed to create corrupted file: %v", err)
}
corruptedFile.WriteString(lines[0] + "\n")
corruptedFile.Close()
// Verify countIssuesInJSONL detects the corruption
count, err := countIssuesInJSONL(corruptedPath)
if err != nil {
t.Fatalf("Failed to count corrupted file: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 line in corrupted file, got %d", count)
}
})
t.Run("export with id filter", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_id_filter.jsonl")
// Clear export hashes to force re-export
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Filter by first issue's ID only
exportCmd.Flags().Set("output", exportPath)
exportCmd.Flags().Set("id", issues[0].ID)
defer exportCmd.Flags().Set("id", "") // Reset flag after test
exportCmd.Run(exportCmd, []string{})
// Verify only one issue was exported
file, err := os.Open(exportPath)
if err != nil {
t.Fatalf("Failed to open export file: %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineCount := 0
var exportedIssue types.Issue
for scanner.Scan() {
lineCount++
if err := json.Unmarshal(scanner.Bytes(), &exportedIssue); err != nil {
t.Fatalf("Failed to parse JSONL line %d: %v", lineCount, err)
}
}
if lineCount != 1 {
t.Errorf("Expected 1 issue in export with ID filter, got %d", lineCount)
}
if exportedIssue.ID != issues[0].ID {
t.Errorf("Expected issue ID %s, got %s", issues[0].ID, exportedIssue.ID)
}
})
t.Run("export with multiple id filter", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_multi_id_filter.jsonl")
// Clear export hashes to force re-export
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Filter by both issue IDs (comma-separated)
exportCmd.Flags().Set("output", exportPath)
exportCmd.Flags().Set("id", issues[0].ID+","+issues[1].ID)
defer exportCmd.Flags().Set("id", "") // Reset flag after test
exportCmd.Run(exportCmd, []string{})
// Verify both issues were exported
actualCount, err := countIssuesInJSONL(exportPath)
if err != nil {
t.Fatalf("Failed to count issues: %v", err)
}
if actualCount != 2 {
t.Errorf("Expected 2 issues in export with multiple ID filter, got %d", actualCount)
}
})
t.Run("export with parent filter", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_parent_filter.jsonl")
// Create a parent issue (epic)
parentIssue := &types.Issue{
Title: "Parent Epic",
Description: "Parent issue for testing",
Priority: 0,
IssueType: types.TypeEpic,
Status: types.StatusOpen,
}
if err := s.CreateIssue(ctx, parentIssue, "test-user"); err != nil {
t.Fatalf("Failed to create parent issue: %v", err)
}
// Create child issues with parent-child dependency
childIssue1 := &types.Issue{
Title: "Child Task 1",
Description: "First child of parent",
Priority: 1,
IssueType: types.TypeTask,
Status: types.StatusOpen,
}
if err := s.CreateIssue(ctx, childIssue1, "test-user"); err != nil {
t.Fatalf("Failed to create child issue 1: %v", err)
}
childIssue2 := &types.Issue{
Title: "Child Task 2",
Description: "Second child of parent",
Priority: 2,
IssueType: types.TypeTask,
Status: types.StatusOpen,
}
if err := s.CreateIssue(ctx, childIssue2, "test-user"); err != nil {
t.Fatalf("Failed to create child issue 2: %v", err)
}
// Add parent-child dependencies
dep1 := &types.Dependency{
IssueID: childIssue1.ID,
DependsOnID: parentIssue.ID,
Type: types.DepParentChild,
}
if err := s.AddDependency(ctx, dep1, "test-user"); err != nil {
t.Fatalf("Failed to add parent-child dependency 1: %v", err)
}
dep2 := &types.Dependency{
IssueID: childIssue2.ID,
DependsOnID: parentIssue.ID,
Type: types.DepParentChild,
}
if err := s.AddDependency(ctx, dep2, "test-user"); err != nil {
t.Fatalf("Failed to add parent-child dependency 2: %v", err)
}
// Clear export hashes to force re-export
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Filter by parent ID
exportCmd.Flags().Set("output", exportPath)
exportCmd.Flags().Set("parent", parentIssue.ID)
defer exportCmd.Flags().Set("parent", "") // Reset flag after test
exportCmd.Run(exportCmd, []string{})
// Verify only children were exported (not the parent itself)
file, err := os.Open(exportPath)
if err != nil {
t.Fatalf("Failed to open export file: %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
var exportedIDs []string
for scanner.Scan() {
var issue types.Issue
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
t.Fatalf("Failed to parse JSONL: %v", err)
}
exportedIDs = append(exportedIDs, issue.ID)
}
// Should have exactly 2 children
if len(exportedIDs) != 2 {
t.Errorf("Expected 2 children in export with parent filter, got %d", len(exportedIDs))
}
// Verify the exported issues are the children, not the parent
for _, id := range exportedIDs {
if id == parentIssue.ID {
t.Error("Parent issue should not be included in parent filter results")
}
if id != childIssue1.ID && id != childIssue2.ID {
t.Errorf("Unexpected issue ID in export: %s", id)
}
}
})
t.Run("export with non-existent id filter", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_nonexistent_id.jsonl")
// Clear export hashes to force re-export
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Filter by non-existent ID
exportCmd.Flags().Set("output", exportPath)
exportCmd.Flags().Set("id", "nonexistent-id-12345")
exportCmd.Flags().Set("force", "true") // Force to allow empty export
defer func() {
exportCmd.Flags().Set("id", "")
exportCmd.Flags().Set("force", "false")
}()
exportCmd.Run(exportCmd, []string{})
// Verify no issues were exported (file may not exist or be empty)
actualCount, err := countIssuesInJSONL(exportPath)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("Failed to count issues: %v", err)
}
if actualCount != 0 {
t.Errorf("Expected 0 issues in export with non-existent ID filter, got %d", actualCount)
}
})
t.Run("filtered export skips staleness check", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_filtered_staleness.jsonl")
// First, create a JSONL file with more issues than we'll filter for
// This would normally trigger the staleness check
file, err := os.Create(exportPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
encoder := json.NewEncoder(file)
// Write all issues including some that won't match our filter
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
t.Fatalf("Failed to encode issue: %v", err)
}
}
// Add a fake issue that only exists in JSONL (would trigger staleness error)
fakeIssue := &types.Issue{
ID: "fake-issue-999",
Title: "Fake Issue",
Description: "This issue only exists in JSONL",
Status: types.StatusOpen,
}
if err := encoder.Encode(fakeIssue); err != nil {
t.Fatalf("Failed to encode fake issue: %v", err)
}
file.Close()
// Verify JSONL has 3 issues (2 real + 1 fake)
count, err := countIssuesInJSONL(exportPath)
if err != nil {
t.Fatalf("Failed to count issues: %v", err)
}
if count != 3 {
t.Fatalf("Expected 3 issues in JSONL, got %d", count)
}
// Clear export hashes
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Export with --id filter for just one issue
// Without the fix, this would fail with "refusing to export stale database"
exportCmd.Flags().Set("output", exportPath)
exportCmd.Flags().Set("id", issues[0].ID)
defer exportCmd.Flags().Set("id", "") // Reset flag after test
exportCmd.Run(exportCmd, []string{})
// Verify export succeeded and only has 1 issue
actualCount, err := countIssuesInJSONL(exportPath)
if err != nil {
t.Fatalf("Failed to count issues after filtered export: %v", err)
}
if actualCount != 1 {
t.Errorf("Expected 1 issue in filtered export, got %d", actualCount)
}
})
t.Run("filtered export with zero results succeeds", func(t *testing.T) {
exportPath := filepath.Join(tmpDir, "export_filtered_empty.jsonl")
// Create a JSONL with existing issues
file, err := os.Create(exportPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
encoder := json.NewEncoder(file)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
t.Fatalf("Failed to encode issue: %v", err)
}
}
file.Close()
// Clear export hashes
if err := s.ClearAllExportHashes(ctx); err != nil {
t.Fatalf("Failed to clear export hashes: %v", err)
}
store = s
dbPath = testDB
rootCtx = ctx
defer func() { rootCtx = nil }()
// Export with --id filter for non-existent issue
// Without the fix, this would fail with "refusing to export empty database"
exportCmd.Flags().Set("output", exportPath)
exportCmd.Flags().Set("id", "nonexistent-id-xyz")
defer exportCmd.Flags().Set("id", "") // Reset flag after test
exportCmd.Run(exportCmd, []string{})
// Verify export succeeded (file should be empty or have 0 issues)
actualCount, err := countIssuesInJSONL(exportPath)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("Failed to count issues after filtered export: %v", err)
}
if actualCount != 0 {
t.Errorf("Expected 0 issues in filtered export with non-existent ID, got %d", actualCount)
}
})
t.Run("export cancellation", func(t *testing.T) {
// Create a large number of issues to ensure export takes time
ctx := context.Background()
largeStore := newTestStore(t, filepath.Join(tmpDir, "large.db"))
defer largeStore.Close()
// Create 100 issues
for i := 0; i < 100; i++ {
issue := &types.Issue{
Title: "Test Issue",
Description: "Test description for cancellation",
Priority: 0,
IssueType: types.TypeBug,
Status: types.StatusOpen,
}
if err := largeStore.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
}
exportPath := filepath.Join(tmpDir, "export_cancel.jsonl")
// Create a cancellable context
cancelCtx, cancel := context.WithCancel(context.Background())
// Start export in a goroutine
errChan := make(chan error, 1)
go func() {
errChan <- exportToJSONLWithStore(cancelCtx, largeStore, exportPath)
}()
// Cancel after a short delay
cancel()
// Wait for export to finish
err := <-errChan
// Verify that the operation was cancelled
if err != nil && err != context.Canceled {
t.Logf("Export returned error: %v (expected context.Canceled)", err)
}
// Verify database integrity - we should still be able to query
issues, err := largeStore.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Database corrupted after cancellation: %v", err)
}
if len(issues) != 100 {
t.Errorf("Expected 100 issues after cancellation, got %d", len(issues))
}
})
}