Files
beads/beads_integration_test.go
Steve Yegge 5e7b3aa43a 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>
2025-10-22 15:49:40 -07:00

481 lines
12 KiB
Go

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")
}
}