Add Beads library API for Go integration
Expose full Storage interface and all types through public beads.go API, enabling external Go projects (like VC) to import Beads directly instead of spawning CLI processes. Changes: - Expanded beads.go with all public types (Issue, Dependency, Comment, etc.) - Added all constants (Status, IssueType, DependencyType, EventType) - Created comprehensive integration tests (beads_integration_test.go) - Added library usage example at examples/library-usage/ - Documented library integration in README.md Test coverage: 96.4% on public API, 14 integration tests, all passing. Closes bd-58, bd-59 Amp-Thread-ID: https://ampcode.com/threads/T-f0093c79-7422-45e2-b0ed-0ddfebc9ffea Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
480
beads_integration_test.go
Normal file
480
beads_integration_test.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package beads_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads"
|
||||
)
|
||||
|
||||
// TestLibraryIntegration tests the full public API that external users will use
|
||||
func TestLibraryIntegration(t *testing.T) {
|
||||
// Setup: Create a temporary database
|
||||
tmpDir, err := os.MkdirTemp("", "beads-integration-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := beads.NewSQLiteStorage(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStorage failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test 1: Create issue
|
||||
t.Run("CreateIssue", func(t *testing.T) {
|
||||
issue := &beads.Issue{
|
||||
Title: "Test task",
|
||||
Description: "Integration test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, issue, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if issue.ID == "" {
|
||||
t.Error("Issue ID should be auto-generated")
|
||||
}
|
||||
|
||||
t.Logf("Created issue: %s", issue.ID)
|
||||
})
|
||||
|
||||
// Test 2: Get issue
|
||||
t.Run("GetIssue", func(t *testing.T) {
|
||||
// Create an issue first
|
||||
issue := &beads.Issue{
|
||||
Title: "Get test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeBug,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-actor")
|
||||
|
||||
// Get it back
|
||||
retrieved, err := store.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Title != issue.Title {
|
||||
t.Errorf("Expected title %q, got %q", issue.Title, retrieved.Title)
|
||||
}
|
||||
if retrieved.IssueType != beads.TypeBug {
|
||||
t.Errorf("Expected type bug, got %v", retrieved.IssueType)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Update issue
|
||||
t.Run("UpdateIssue", func(t *testing.T) {
|
||||
issue := &beads.Issue{
|
||||
Title: "Update test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-actor")
|
||||
|
||||
// Update status
|
||||
updates := map[string]interface{}{
|
||||
"status": beads.StatusInProgress,
|
||||
"assignee": "test-user",
|
||||
}
|
||||
|
||||
err := store.UpdateIssue(ctx, issue.ID, updates, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify update
|
||||
updated, _ := store.GetIssue(ctx, issue.ID)
|
||||
if updated.Status != beads.StatusInProgress {
|
||||
t.Errorf("Expected status in_progress, got %v", updated.Status)
|
||||
}
|
||||
if updated.Assignee != "test-user" {
|
||||
t.Errorf("Expected assignee test-user, got %q", updated.Assignee)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: Add dependency
|
||||
t.Run("AddDependency", func(t *testing.T) {
|
||||
issue1 := &beads.Issue{
|
||||
Title: "Parent task",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
issue2 := &beads.Issue{
|
||||
Title: "Child task",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-actor")
|
||||
store.CreateIssue(ctx, issue2, "test-actor")
|
||||
|
||||
// Add dependency: issue2 blocks issue1
|
||||
dep := &beads.Dependency{
|
||||
IssueID: issue1.ID,
|
||||
DependsOnID: issue2.ID,
|
||||
Type: beads.DepBlocks,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "test-actor",
|
||||
}
|
||||
|
||||
err := store.AddDependency(ctx, dep, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify dependency
|
||||
deps, _ := store.GetDependencies(ctx, issue1.ID)
|
||||
if len(deps) != 1 {
|
||||
t.Fatalf("Expected 1 dependency, got %d", len(deps))
|
||||
}
|
||||
if deps[0].ID != issue2.ID {
|
||||
t.Errorf("Expected dependency on %s, got %s", issue2.ID, deps[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 5: Add label
|
||||
t.Run("AddLabel", func(t *testing.T) {
|
||||
issue := &beads.Issue{
|
||||
Title: "Label test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: beads.TypeFeature,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-actor")
|
||||
|
||||
err := store.AddLabel(ctx, issue.ID, "urgent", "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("AddLabel failed: %v", err)
|
||||
}
|
||||
|
||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("Expected 1 label, got %d", len(labels))
|
||||
}
|
||||
if labels[0] != "urgent" {
|
||||
t.Errorf("Expected label 'urgent', got %q", labels[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test 6: Add comment
|
||||
t.Run("AddComment", func(t *testing.T) {
|
||||
issue := &beads.Issue{
|
||||
Title: "Comment test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-actor")
|
||||
|
||||
comment, err := store.AddIssueComment(ctx, issue.ID, "test-user", "Test comment")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
|
||||
if comment.Text != "Test comment" {
|
||||
t.Errorf("Expected comment text 'Test comment', got %q", comment.Text)
|
||||
}
|
||||
|
||||
comments, _ := store.GetIssueComments(ctx, issue.ID)
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("Expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
})
|
||||
|
||||
// Test 7: Get ready work
|
||||
t.Run("GetReadyWork", func(t *testing.T) {
|
||||
// Create some issues
|
||||
for i := 0; i < 3; i++ {
|
||||
issue := &beads.Issue{
|
||||
Title: "Ready work test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: i,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-actor")
|
||||
}
|
||||
|
||||
ready, err := store.GetReadyWork(ctx, beads.WorkFilter{
|
||||
Status: beads.StatusOpen,
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) == 0 {
|
||||
t.Error("Expected some ready work, got none")
|
||||
}
|
||||
|
||||
t.Logf("Found %d ready issues", len(ready))
|
||||
})
|
||||
|
||||
// Test 8: Get statistics
|
||||
t.Run("GetStatistics", func(t *testing.T) {
|
||||
stats, err := store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatistics failed: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalIssues == 0 {
|
||||
t.Error("Expected some total issues, got 0")
|
||||
}
|
||||
|
||||
t.Logf("Stats: Total=%d, Open=%d, InProgress=%d, Closed=%d",
|
||||
stats.TotalIssues, stats.OpenIssues, stats.InProgressIssues, stats.ClosedIssues)
|
||||
})
|
||||
|
||||
// Test 9: Close issue
|
||||
t.Run("CloseIssue", func(t *testing.T) {
|
||||
issue := &beads.Issue{
|
||||
Title: "Close test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-actor")
|
||||
|
||||
err := store.CloseIssue(ctx, issue.ID, "Completed", "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
|
||||
closed, _ := store.GetIssue(ctx, issue.ID)
|
||||
if closed.Status != beads.StatusClosed {
|
||||
t.Errorf("Expected status closed, got %v", closed.Status)
|
||||
}
|
||||
if closed.ClosedAt == nil {
|
||||
t.Error("Expected ClosedAt to be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDependencyTypes ensures all dependency type constants are exported
|
||||
func TestDependencyTypes(t *testing.T) {
|
||||
types := []beads.DependencyType{
|
||||
beads.DepBlocks,
|
||||
beads.DepRelated,
|
||||
beads.DepParentChild,
|
||||
beads.DepDiscoveredFrom,
|
||||
}
|
||||
|
||||
for _, dt := range types {
|
||||
if dt == "" {
|
||||
t.Errorf("Dependency type should not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusConstants ensures all status constants are exported
|
||||
func TestStatusConstants(t *testing.T) {
|
||||
statuses := []beads.Status{
|
||||
beads.StatusOpen,
|
||||
beads.StatusInProgress,
|
||||
beads.StatusClosed,
|
||||
beads.StatusBlocked,
|
||||
}
|
||||
|
||||
for _, s := range statuses {
|
||||
if s == "" {
|
||||
t.Errorf("Status should not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueTypeConstants ensures all issue type constants are exported
|
||||
func TestIssueTypeConstants(t *testing.T) {
|
||||
types := []beads.IssueType{
|
||||
beads.TypeBug,
|
||||
beads.TypeFeature,
|
||||
beads.TypeTask,
|
||||
beads.TypeEpic,
|
||||
beads.TypeChore,
|
||||
}
|
||||
|
||||
for _, it := range types {
|
||||
if it == "" {
|
||||
t.Errorf("IssueType should not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchCreateIssues tests creating multiple issues at once
|
||||
func TestBatchCreateIssues(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "beads-batch-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := beads.NewSQLiteStorage(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStorage failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create multiple issues
|
||||
issues := make([]*beads.Issue, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
issues[i] = &beads.Issue{
|
||||
Title: "Batch test",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
err = store.CreateIssues(ctx, issues, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssues failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all got IDs
|
||||
for i, issue := range issues {
|
||||
if issue.ID == "" {
|
||||
t.Errorf("Issue %d should have ID set", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindDatabasePathIntegration tests the database discovery
|
||||
func TestFindDatabasePathIntegration(t *testing.T) {
|
||||
// Save original working directory
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create temporary directory with .beads
|
||||
tmpDir, err := os.MkdirTemp("", "beads-find-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0o755)
|
||||
|
||||
dbPath := filepath.Join(beadsDir, "test.db")
|
||||
f, _ := os.Create(dbPath)
|
||||
f.Close()
|
||||
|
||||
// Change to temp directory
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// Should find the database
|
||||
found := beads.FindDatabasePath()
|
||||
if found == "" {
|
||||
t.Error("Expected to find database, got empty string")
|
||||
}
|
||||
|
||||
t.Logf("Found database at: %s", found)
|
||||
}
|
||||
|
||||
// TestRoundTripIssue tests creating, updating, and retrieving an issue
|
||||
func TestRoundTripIssue(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "beads-roundtrip-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := beads.NewSQLiteStorage(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStorage failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issue with all fields
|
||||
original := &beads.Issue{
|
||||
Title: "Complete issue",
|
||||
Description: "Full description",
|
||||
Design: "Design notes",
|
||||
AcceptanceCriteria: "Acceptance criteria",
|
||||
Notes: "Implementation notes",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeFeature,
|
||||
Assignee: "developer",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err = store.CreateIssue(ctx, original, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify all fields
|
||||
retrieved, err := store.GetIssue(ctx, original.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Title != original.Title {
|
||||
t.Errorf("Title mismatch: expected %q, got %q", original.Title, retrieved.Title)
|
||||
}
|
||||
if retrieved.Description != original.Description {
|
||||
t.Errorf("Description mismatch")
|
||||
}
|
||||
if retrieved.Design != original.Design {
|
||||
t.Errorf("Design mismatch")
|
||||
}
|
||||
if retrieved.AcceptanceCriteria != original.AcceptanceCriteria {
|
||||
t.Errorf("AcceptanceCriteria mismatch")
|
||||
}
|
||||
if retrieved.Notes != original.Notes {
|
||||
t.Errorf("Notes mismatch")
|
||||
}
|
||||
if retrieved.Status != original.Status {
|
||||
t.Errorf("Status mismatch")
|
||||
}
|
||||
if retrieved.Priority != original.Priority {
|
||||
t.Errorf("Priority mismatch")
|
||||
}
|
||||
if retrieved.IssueType != original.IssueType {
|
||||
t.Errorf("IssueType mismatch")
|
||||
}
|
||||
if retrieved.Assignee != original.Assignee {
|
||||
t.Errorf("Assignee mismatch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user