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>
481 lines
12 KiB
Go
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")
|
|
}
|
|
}
|